Vite 源码(六)解析 importAnalysis 插件


theme: cyanosis

highlight: monokai

介绍

importAnalysis是 Vite 中内置的很重要的一个插件,它的作用如下

  • 解析请求文件中的导入,确保它们存在;并重写导入路径为绝对路径
  • 如果导入的模块需要更新,会在导入URL上挂载一个参数,从而强制浏览器请求新的文件
  • 对于引入 CommonJS 转成 ESM 的模块,会注入一段代码,以支持获取模块内容
  • 如果代码中有import.meta.hot.accept,注入import.meta.hot定义
  • 更新 Module Graph,以及收集请求文件接收的热更新模块
  • 如果代码中环境变量import.meta.env,注入import.meta.env定义

    源码

假设main.ts内容如下 ```typescript import React, { useState, createContext } from 'react' import a from './a' import { createApp } from 'vue' import App from './App.vue' console.log(React, useState, createContext, a) createApp(App).mount('#app')

if(import.meta.hot){ import.meta.hot.accept((a) => { console.log('hmr' ,a) }) } ```

先看下这个插件的大体样式,定义了两个钩子函数 ```typescript export function importAnalysisPlugin(config: ResolvedConfig): Plugin { const { root, base } = config // 拼接 /@vite/client const clientPublicPath = path.posix.join(base, CLIENTPUBLICPATH)

let server: ViteDevServer

return {
    name: 'vite:import-analysis',

    configureServer(_server) {
        server = _server
    },
    async transform(source, importer, ssr) {},
}

} 当请求`main.ts`时,会被`transformMiddleware`中间件拦截并触发所有插件的`transform`钩子函数,其中就包含`importAnalysis`插件中定义的`transform`方法。我们一块一块的分析 typescript // transform 方法内 // 这个方法接收3个参数,分别是 source:经 ESbuild 编译后的源码;importer:文件绝对路径;ssr

// es-module-lexer 的初始化 await init let imports: readonly ImportSpecifier[] = []

try { // 通过 es-module-lexer 获取文件中 import 语句的代码位置 imports = parseImports(source)[0] } catch (e: any) {}

if (!imports.length) { return source }

let hasHMR = false let isSelfAccepting = false let hasEnv = false let needQueryInjectHelper = false let s: MagicString | undefined const str = () => s || (s = new MagicString(source)) const { moduleGraph } = server // importer 文件绝对路径 // 根据文件绝对路径获取文件的 ModuleNode 对象 const importerModule = moduleGraph.getModuleById(importer)! const importedUrls = new Set () const staticImportedUrls = new Set () const acceptedUrls = new Set<{ url: string start: number end: number }>() const toAbsoluteUrl = (url: string) => path.posix.resolve(path.posix.dirname(importerModule.url), url)

const normalizeUrl = async (url: string, pos: number): Promise<[string, string]> => {} 上面的代码中,除了定义一些变量之外就是使用`es-module-lexer`获取文件中的导入信息,拿`main.ts`举例,`imports`的值为 bash

main.ts 中总共有 6 个元素,这里只列几个特殊的,其他都大致相同

[ { n: "react", # 模块的名称 s: 48, # 模块名称在导入语句中的开始位置 e: 53, # 模块名称在导入语句中的结束位置 ss: 0, # 导入语句在代码中的开始位置 se: 54, # 导入语句在代码中的结束位置 d: -1, # 导入语句是否为动态导入,如果是则为对应的开始位置,否则默认为 -1 a: -1, }, { n: "vue", s: 83, e: 86, ss: 56, se: 87, d: -1, a: -1}, { n: "./App.vue", s: 106, e: 115, ss: 89, se: 116, d: -1, a: -1 }, # if(import.meta.hot) { n: undefined, s: 221, e: 232, ss: 221, se: 232, d: -2, a: -1 }, # import.meta.hot.accept(/* */) { n: undefined, s: 242, e: 253, ss: 242, se: 253, d: -2, a: -1 } ] 继续向下,遍历`imports`分别处理每个导入 typescript for (let index = 0; index < imports.length; index++) { const { s: start, e: end, ss: expStart, se: expEnd, d: dynamicIndex, n: specifier, } = imports[index] // 获取导入的模块名称,比如第一个的是 react、热更新的是 import.meta const rawUrl = source.slice(start, end) if (rawUrl === 'import.meta') { const prop = source.slice(end, end + 4) if (prop === '.hot') {} // 热更新相关 else if (prop === '.env') {} // import.meta.env 相关 else if (prop === '.glo' && source[end + 4] === 'b') {} continue } // 如果是动态导入,则为 true const isDynamicImport = dynamicIndex >= 0 // 如果有模块名,就是普通的 import if (specifier) {} // es-module-lexer 没有解析到,并且文件绝对路径不是以 /@vite/client 开头 else if (!importer.startsWith(clientDir) && !ssr) {} } `` 可以看到,针对不同的导入有不同的处理方式,我们先以正常import`开始看。后面会说一下热更新

import xxx from 'xxx'是如何处理的

在开始分析ESM方式的导入之前,需要先看一个函数,就是上面定义的normalizeUrl函数;这个函数接收两个参数,分别是 模块名 | 绝对/相对路径 和 模块名的开始位置,即上面的start

```typescript // importer 当前正在被请求文件的绝对路径 const normalizeUrl = async ( url: string, // 比如 react、./App.vue pos: number ): Promise<[string, string]> => { // 将 base 替换成 / if (base !== '/' && url.startsWith(base)) { url = url.replace(base, '/') } // 获取 url(模块)绝对路径 resolved: { id: 'xxx/yyy/zzz/nodemodules/react/index.js' } const resolved = await this.resolve(url, importer) // 如果 url 是相对路径则为 true const isRelative = url.startsWith('.') // 如果是自己引用自己则为 true const isSelfImport = !isRelative && cleanUrl(url) === cleanUrl(importer) if (resolved.id.startsWith(root + '/')) { // 如果当前文件在项目根目录内,将 url 修改成 从根路径推断出来的绝对路径 // 比如:root = /xxx/yyy/zzz // /xxx/yyy/zzz/src/main.ts -> /src/main.ts url = resolved.id.slice(root.length) } else if (fs.existsSync(cleanUrl(resolved.id))) { // 文件存在,但不在根目录下,重写成 /@fs/ + 绝对路径 url = path.posix.join(FSPREFIX + resolved.id) } else { url = resolved.id } // 如果是外部 url /http(s)/ 则返回 if (isExternalUrl(url)) { return [url, url] } if (!ssr) { // 下面的 js 文件包含 jsx、tsx、js、ts、vue 等 // 给非 js、css文件添加query ?import。比如 json 文件、图片文件等 url = markExplicitImport(url) // 对相对路径引入的 js、css 的 url 挂载 v=xxx 参数 if ( (isRelative || isSelfImport) && !/[\?&]import=?\b/.test(url) ) { // const DEPVERSIONRE = /\?&\b/ // 这里要注意,是将 当前被解析文件的 v=xxx 参数,挂载到 url 上 const versionMatch = importer.match(DEPVERSIONRE) if (versionMatch) { url = injectQuery(url, versionMatch[1]) } }

// 热更新相关:检查 dep 是否已更新 HMR。如果是,需要附加它最近更新的时间戳,以强制浏览器获取该模块的最新版本。
    try {
        // 获取/创建 url 对应的 ModuleNode 对象
        const depModule = await moduleGraph.ensureEntryFromUrl(url)
        if (depModule.lastHMRTimestamp > 0) {
            // 更新 url 上的 t 参数
            url = injectQuery(url, `t=${depModule.lastHMRTimestamp}`)
        }
    } catch (e: any) {}

    // 将 url 重新和 base 拼接到一起
    url = base + url.replace(/^\//, '')
}
// 返回 url 和 文件绝对路径
return [url, resolved.id]

} 基本都加上注释了,这里就总结下这个函数的返回值 bash

假设 base = /

[url, url 对应的绝对路径]

url: - 从根路径推断出来的绝对路径。比如 /node_modules/react/index.js - 如果不是jsx、tsx、js、ts、vue、css等文件,会有一个 import 参数。比如 /src/logo.png?import - 如果是外部url,保持不变。比如 https/www.xxx.com/a.js - 如果是相对路径引入的jsx、tsx、js、ts、vue、css等文件,并且请求文件的绝对路径上有参数v 则给这个文件也添加相同的参数v。比如 /src/App.vue?v=xxx - 如果路径对应的 ModuleNode 对象的 lastHMRTimestamp 大于 0,添加 t 参数,这里的目的是修改引入的路径参数,从而防止走浏览器缓存 ```

知道了normalizeUrl函数的作用之后,继续上面的流程 ```typescript if (specifier) { // specifier 导入的模块名,或者文件的相/绝对路径 // 跳过 http(s)或data:开头的路径 if (isExternalUrl(specifier) || isDataUrl(specifier)) { continue }

// 跳过 /@vite/client
if (specifier === clientPublicPath) {
    continue
}

// 调用 normalizeUrl 函数,并传入模块名和模块名字符串的开始位置
const [normalizedUrl, resolvedId] = await normalizeUrl(
    specifier,
    start
)
let url = normalizedUrl // 比如 /src/main.ts,也有可能带有参数

// 记录到 moduleGraph.safeModulesPath 中
server?.moduleGraph.safeModulesPath.add(
    cleanUrl(url).slice(4 /* '/@fs'.length */)
)

// rewrite
if (url !== specifier) {
    // 如果导入的文件是 cjs 并通过预构建转成 ESM的文件会挂在 &es-interop
    if (resolvedId.endsWith(`&es-interop`)) {
    /* ... */
    } else {
        // 将 import 的模块名替换成 url
        // 比如, import vue from 'Vue' -> import vue from '/node_modules/vue/dist/vue.runtime.esm-bundler.js'
        str().overwrite(
            start,
            end,
            isDynamicImport ? `'${url}'` : url
        )
    }
}

// 将文件添加到 importedUrls 中
const urlWithoutBase = url.replace(base, '/')
importedUrls.add(urlWithoutBase)
if (!isDynamicImport) {
    // 如果不是动态导入,则添加到 staticImportedUrls 中
    staticImportedUrls.add(urlWithoutBase)
}

} 首先通过`normalizeUrl`函数获取路径,如果源码中的导入和新获取的路径不同,则重写`import`的导入。比如 bash import vue from 'Vue' -> import vue from '/node_modules/vue/dist/vue.runtime.esm-bundler.js' `` 完成之后,将导入的文件路径添加到importedUrls中,如果不是动态导入的添加到staticImportedUrls`中。

在上面代码中其实还会处理一种比较特殊的情况,预构建有一个功能,就是将 CommonJS 规范的文件通过 ESbuild 转成 ESM 规范的文件。比如 React。 bash import React, { useState, createContext } from 'react' 但是 ESbuild 从 CommonJS 转成 ESM 有个问题就是转换之后的代码是不能通过上面这种方式引入,所以需要重写一下,这块的处理逻辑如下 typescript // 如果是 CommonJS 转成 ESM 的文件,在解析路径的时候会在路径上挂一个 es-interop 参数 // 比如:/xxx/yyy/zzz/node_modules/.vite/react.js?v=af73c26f&es-interop if (resolvedId.endsWith(`&es-interop`)) { // 去除 url 上面的 &es-interop url = url.slice(0, -11) // 如果是动态引入的,重写方式如下 if (isDynamicImport) { // rewrite `import('package')` to expose the default directly str().overwrite( dynamicIndex, end + 1, `import('${url}').then(m => m.default && m.default.__esModule ? m.default : ({ ...m.default, default: m.default }))` ) } else { // 获取导入语句 import React, { useState, createContext } from 'react' const exp = source.slice(expStart, expEnd) // 调用 transformCjsImport 重写 const rewritten = transformCjsImport( exp, url, rawUrl, index ) if (rewritten) { str().overwrite(expStart, expEnd, rewritten) } else { // #1439 export * from '...' str().overwrite(start, end, url) } } } 如果是 CommonJS 转成 ESM 的文件,在解析路径的时候会在路径上挂一个&es-interop参数,比如/xxx/yyy/zzz/node_modules/.vite/react.js?v=af73c26f&es-interop,所以根据这个参数判断,如果有这个说明这个模块就是 CommonJS 转成 ESM 的模块。对于这种模块分为两种情况,一种是动态引入,即通过import()的方式;另一种就是上面这种直接import xxx from 'xxx'的方式,这里主要说一下第二种方式 Vite 是怎么处理的

首先调用 transformCjsImport 方法,并传入导入的语句import xxx from 'xxx'url、模块名(模块路径)、当前循环的索引。最后返回一段新代码,这段新代码就可以实现导入;然后将之前的导入语句替换成这段新代码。

比如这个import React, { useState, createContext } from 'react' ```bash 首先调用 transformCjsImport 方法 参数依次是 - import React, { useState, createContext } from 'react' - /node_modules/.vite/react.js?v=af73c26f - react - 0

transformCjsImport 的返回值是

import vitecjsImport0react from '/nodemodules/.vite/react.js?v=af73c26f' const React = vitecjsImport0react.esModule ? _vitecjsImport0react.default : vitecjsImport0react const useState = vitecjsImport0react['useState'] const createContext = vitecjsImport0react['createContext']

将 main.ts 里面的 import React, { useState, createContext } from 'react' 替换成上面这段代码 当前请求文件的所有导入都修改并重写完成之后,继续向下执行 typescript if (hasEnv) {} if (hasHMR && !ssr) {} if (needQueryInjectHelper) {} // normalize and rewrite accepted urls const normalizedAcceptedUrls = new Set () for (const { url, start, end } of acceptedUrls) {} 这块就是关于热更新和环境变量相关的了,后面再说。继续向下 typescript if (!isCSSRequest(importer)) { // ... // 更新 Module Graph,前面讲过这个方法,这里就不赘述了 const prunedImports = await moduleGraph.updateModuleInfo( importerModule, // 当前文件的 Module 实例 importedUrls, // 当前文件中的导入 normalizedAcceptedUrls, // 接受直接依赖项的更新,而无需重新加载自身 isSelfAccepting // 接收模块自身热更新 ) // 热更新相关,后面一起说 if (hasHMR && prunedImports) {} }

// 提前转换已知的导入 import xxx from 'xxx' if (staticImportedUrls.size) { staticImportedUrls.forEach((url) => { transformRequest(unwrapId(removeImportQuery(url)), server, { ssr, }) }) }

if (s) { return s.toString() } else { return source } `` 剩下的代码就比较简单了,就是更新 Module Graph,然后提前转换已知的导入import xxx from 'xxx'`,而不是等到请求这个文件之后再转换。由于这个过程是异步的,所以不会导致当前请求文件被阻塞。最后转换完成的文件内容存储在 MoudleNode 对象中,等请求的时候可以直接从这里拿对应内容。

import.meta.hot.accept()是如何处理的

在上面利用for循环遍历并处理文件中所有import时,还会处理import.meta.hot.accept()

假设文件中有这样的代码 typescript if(import.meta.hot){ import.meta.hot.accept((a) => { console.log('hmr' ,a) }) } es-module-lexer解析成 AST 之后,获取的imports数组为 ```typescript

if(import.meta.hot)

{ n: undefined, s: 221, e: 232, ss: 221, se: 232, d: -2, a: -1 },

import.meta.hot.accept(/* */)

{ n: undefined, s: 242, e: 253, ss: 242, se: 253, d: -2, a: -1 } 在`for`循环中有这样的逻辑 typescript const acceptedUrls = new Set<{ url: string start: number end: number }>() // 获取导入的模块名城 const rawUrl = source.slice(start, end)

if (rawUrl === 'import.meta') { const prop = source.slice(end, end + 4) if (prop === '.hot') { hasHMR = true if (source.slice(end + 4, end + 11) === '.accept') { // further analyze accepted modules if ( lexAcceptedHmrDeps( source, source.indexOf('(', end + 11) + 1, acceptedUrls ) ) { // 接收模块自身热更新 isSelfAccepting = true } } } else if (prop === '.env') { // 环境变量 hasEnv = true } else if (prop === '.glo' && source[end + 4] === 'b') {} continue } `` 依次判断源码是不是import.meta.hot.accept,如果是,会调用lexAcceptedHmrDeps方法。在看这个方法之前,先看下import.meta.hot.accept`有哪些形式

  • import.meta.hot.accept(cb)接收自身热更新
  • import.meta.hot.accept(deps, cb)可以接受直接依赖项的更新,而无需重新加载自身。deps可以是路径字符串也可以是路径数组

lexAcceptedHmrDeps方法,就是遍历源码,根据import.meta.hot.accept方法的参数,判断。如果是接收直接依赖项的更新,则将路径添加到acceptedUrls中,并返回false。如果是接收自身更新,返回true

回到上面的for循环中,如果lexAcceptedHmrDeps返回true,说明是接收自身更新,则将isSelfAccepting置为true

到这循环中解析热更新的逻辑就完成了,接下来就是循环外是怎么处理的

typescript if (hasHMR && !ssr) { // 将下面这段代码注入源码中 // clientPublicPath 就是客户端接收热更新的代码 str().prepend( `import { createHotContext as __vite__createHotContext } from "${clientPublicPath}";` + `import.meta.hot = __vite__createHotContext(${JSON.stringify( importerModule.url )});` ) } 上述代码的作用是,从客户端接收热更新的js文件中导出createHotContext方法,createHotContext方法的返回值就是import.meta.hot的值。传入的是当前文件的路径以 / 开头的 url

注入完成之后,继续执行 typescript const normalizedAcceptedUrls = new Set<string>() for (const { url, start, end } of acceptedUrls) { const [normalized] = await moduleGraph.resolveUrl(toAbsoluteUrl(markExplicitImport(url))) normalizedAcceptedUrls.add(normalized) str().overwrite(start, end, JSON.stringify(normalized)) } acceptedUrls的内容是当前文件接受热更新的直接依赖项;获取这些依赖项的绝对路径,并将源码中的路径改成绝对路径。然后将其添加到normalizedAcceptedUrls中,用于更新 Module Graph。

继续向下执行 typescript if (!isCSSRequest(importer)) { const prunedImports = await moduleGraph.updateModuleInfo( importerModule, // 当前文件的 Module 实例 importedUrls, // 当前文件中的导入 normalizedAcceptedUrls, // 接受直接依赖项的更新,而无需重新加载自身 isSelfAccepting // 接收模块自身热更新 ) if (hasHMR && prunedImports) { handlePrunedModules(prunedImports, server) } } 调用updateModuleInfo更新 Module Graph 并将normalizedAcceptedUrls和是否接收模块自身热更新传入;

在分析 Vite 的 Module Graph 时曾说过updateModuleInfo方法有一个返回值Promise<Set<ModuleNode> | undefined>;该返回值返回的是一个之前被当前模块导入过但现在没有模块导入的Set集合。对于这种集合调用handlePrunedModules方法。 typescript export function handlePrunedModules( mods: Set<ModuleNode>, { ws }: ViteDevServer ): void { const t = Date.now() mods.forEach((mod) => { mod.lastHMRTimestamp = t }) ws.send({ type: 'prune', paths: [...mods].map((m) => m.url), }) } 更新这些模块的时间戳,并向客户端发送类型为prune的消息

import.meta.env是如何处理的

如果代码中存在import.meta.env,会将hasEnv置为true

循环结束后,如果hasEnvtrue,会拼接一个import.meta.env对象,并添加到代码中 typescript if (hasEnv) { // inject import.meta.env let env = `import.meta.env = ${JSON.stringify({ ...config.env, SSR: !!ssr, })};` // account for user env defines for (const key in config.define) { if (key.startsWith(`import.meta.env.`)) { const val = config.define[key] env += `${key} = ${ typeof val === 'string' ? val : JSON.stringify(val) };` } } str().prepend(env) }

总结

最后再回顾下这个插件的作用 importAnalysis是 Vite 中内置的很重要的一个插件,它的作用如下 - 解析请求文件中的导入,确保它们存在;并重写导入路径为绝对路径 - 如果导入的模块需要更新,会在导入URL上挂载一个参数,从而强制浏览器请求新的文件 - 导入 CommonJS 转成 ESM 的模块,会注入一段代码,以支持获取模块内容 - 如果代码中有import.meta.hot.accept,注入import.meta.hot定义 - 更新 Module Graph,以及收集请求文件接收的热更新模块 - 如果代码中环境变量import.meta.env,注入import.meta.env定义

需要注意的点是

  • 对相对路径引入的 js、css 挂载v=xxx参数,参数值和导入这个模块的v=xxx参数一致
  • 对非js、css 的文件挂载import参数
  • 如果模块对应的ModuleNode对象中的lastHMRTimestamp不为 0,会在导入的 URL 上挂一个t参数,这里的目的是修改引入的路径参数,从而防止走浏览器缓存
  • 2
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
首先,你需要了解 Vite 的基本用法和插件开发的规范。 Vite 是一个基于 ES Modules 的快速开发工具,它支持开发 JavaScriptTypeScript、CSS 等前端应用和插件。在 Vite 中,插件是通过导入一个函数并在配置文件中调用来注册的。 下面是一个最简单的 Vite 插件示例: ```js // plugin.js export default function myPlugin() { return { name: 'my-plugin', configureWebpack(config) { // 修改 Webpack 配置 } } } ``` 在这个示例中,我们定义了一个名为 `myPlugin` 的函数,并返回一个对象,其中包含一个名为 `name` 的属性和一个名为 `configureWebpack` 的函数。`name` 属性是必需的,因为它用于在 Vite 的日志中标识插件。`configureWebpack` 函数则允许我们修改 Webpack 配置。 接下来,我们需要在 Vite 的配置文件中导入并调用我们的插件函数: ```js // vite.config.js import myPlugin from './plugin.js' export default { plugins: [ myPlugin() ] } ``` 这样,我们就完成了一个最简单的 Vite 插件。你可以在 `configureWebpack` 函数中添加任何你需要的 Webpack 配置选项,或者在插件函数中实现其他功能。 具体开发过程中,你需要考虑你的插件的实际需求,然后在插件函数中实现相应的逻辑。例如,如果你需要在 Vite 中添加一个自定义的命令行选项,可以使用 `createServer` 函数和 `configureServer` 函数来实现。如果你需要修改 Vite 的构建流程,可以使用 `configureBuild` 函数来实现。 总之,插件开发是一个相对灵活和自由的过程,你可以根据自己的需求来实现任何你想要的功能。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值