Vite 源码(六)解析 importAnalysis 插件importAnalysis是 Vite 中内置的很重要的一
介绍
importAnalysis
是 Vite 中内置的很重要的一个插件,它的作用如下
- 解析请求文件中的导入,确保它们存在;并重写导入路径为绝对路径
- 如果导入的模块需要更新,会在导入URL上挂载一个参数,从而强制浏览器请求新的文件
- 对于引入 CommonJS 转成 ESM 的模块,会注入一段代码,以支持获取模块内容
- 如果代码中有
import.meta.hot.accept
,注入import.meta.hot
定义 - 更新 Module Graph,以及收集请求文件接收的热更新模块
- 如果代码中环境变量
import.meta.env
,注入import.meta.env
定义
源码
假设main.ts
内容如下
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)
})
}
先看下这个插件的大体样式,定义了两个钩子函数
export function importAnalysisPlugin(config: ResolvedConfig): Plugin {
const { root, base } = config
// 拼接 /@vite/client
const clientPublicPath = path.posix.join(base, CLIENT_PUBLIC_PATH)
let server: ViteDevServer
return {
name: 'vite:import-analysis',
configureServer(_server) {
server = _server
},
async transform(source, importer, ssr) {},
}
}
当请求main.ts
时,会被transformMiddleware
中间件拦截并触发所有插件的transform
钩子函数,其中就包含importAnalysis
插件中定义的transform
方法。我们一块一块的分析
// 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<string>()
const staticImportedUrls = new Set<string>()
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
的值为
# 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
分别处理每个导入
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
// 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/node_modules/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(FS_PREFIX + 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 DEP_VERSION_RE = /[\?&](v=[\w\.-]+)\b/
// 这里要注意,是将 当前被解析文件的 v=xxx 参数,挂载到 url 上
const versionMatch = importer.match(DEP_VERSION_RE)
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]
}
基本都加上注释了,这里就总结下这个函数的返回值
# 假设 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
函数的作用之后,继续上面的流程
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
的导入。比如
import vue from 'Vue'
->
import vue from '/node_modules/vue/dist/vue.runtime.esm-bundler.js'
完成之后,将导入的文件路径添加到importedUrls
中,如果不是动态导入的添加到staticImportedUrls
中。
在上面代码中其实还会处理一种比较特殊的情况,预构建有一个功能,就是将 CommonJS 规范的文件通过 ESbuild 转成 ESM 规范的文件。比如 React。
import React, { useState, createContext } from 'react'
但是 ESbuild 从 CommonJS 转成 ESM 有个问题就是转换之后的代码是不能通过上面这种方式引入,所以需要重写一下,这块的处理逻辑如下
// 如果是 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'
首先调用 transformCjsImport 方法
参数依次是
- import React, { useState, createContext } from 'react'
- /node_modules/.vite/react.js?v=af73c26f
- react
- 0
transformCjsImport 的返回值是
import __vite__cjsImport0_react from '/node_modules/.vite/react.js?v=af73c26f'
const React = __vite__cjsImport0_react.__esModule ? __vite__cjsImport0_react.default : __vite__cjsImport0_react
const useState = __vite__cjsImport0_react['useState']
const createContext = __vite__cjsImport0_react['createContext']
将 main.ts 里面的 `import React, { useState, createContext } from 'react'` 替换成上面这段代码
当前请求文件的所有导入都修改并重写完成之后,继续向下执行
if (hasEnv) {}
if (hasHMR && !ssr) {}
if (needQueryInjectHelper) {}
// normalize and rewrite accepted urls
const normalizedAcceptedUrls = new Set<string>()
for (const { url, start, end } of acceptedUrls) {}
这块就是关于热更新和环境变量相关的了,后面再说。继续向下
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()
假设文件中有这样的代码
if(import.meta.hot){
import.meta.hot.accept((a) => {
console.log('hmr' ,a)
})
}
经es-module-lexer
解析成 AST 之后,获取的imports
数组为
# 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
循环中有这样的逻辑
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
到这循环中解析热更新的逻辑就完成了,接下来就是循环外是怎么处理的
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
注入完成之后,继续执行
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。
继续向下执行
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
方法。
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
。
循环结束后,如果hasEnv
为true
,会拼接一个import.meta.env
对象,并添加到代码中
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
参数,这里的目的是修改引入的路径参数,从而防止走浏览器缓存
转载自:https://juejin.cn/post/7046344441653067790