Vite 源码(八)Vite 的预构建原理预构建会将CommonJS转换成ESM、缓存第三方文件、减少文件的HTTP请求
先看下官网介绍
当你首次启动 vite
时,你可能会注意到打印出了以下信息:
Optimizable dependencies detected: (侦测到可优化的依赖:)
react, react-dom
Pre-bundling them to speed up dev server page load...(将预构建它们以提升开发服务器页面加载速度)
(this will be run only when your dependencies have changed)(这将只会在你的依赖发生变化时执行)
预构建作用
CommonJS 和 UMD 兼容性
开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将作为 CommonJS 或 UMD 发布的依赖项转换为 ESM。
当转换 CommonJS 依赖时,Vite 会执行智能导入分析,这样即使导出是动态分配的(如 React),按名导入也会符合预期效果:
// 符合预期
import React, { useState } from 'react'
性能
Vite 将有许多内部模块的 ESM 依赖关系转换为单个模块,以提高后续页面加载性能;减少网络请求。
缓存
文件系统缓存
Vite 会将预构建的依赖缓存到 node_modules/.vite
。它根据几个源来决定是否需要重新运行预构建步骤:
package.json
中的dependencies
列表- 包管理器的 lockfile,例如
package-lock.json
,yarn.lock
,或者pnpm-lock.yaml
- 可能在
vite.config.js
相关字段中配置过的
只有在上述其中一项发生更改时,才需要重新运行预构建。
如果出于某些原因,你想要强制 Vite 重新构建依赖,你可以用 --force
命令行选项启动开发服务器,或者手动删除 node_modules/.vite
目录。
浏览器缓存
解析后的依赖请求会以 HTTP 头 max-age=31536000,immutable
强缓存,以提高在开发时的页面重载性能。一旦被缓存,这些请求将永远不会再到达开发服务器。如果安装了不同的版本(这反映在包管理器的 lockfile 中),则附加的版本 query(v=xxx
) 会自动使它们失效。
接下来来看源码是怎么实现上述功能的
源码
本地服务器启动时,会进行预构建
const server = await createServer({
root,
base: options.base,
mode: options.mode,
configFile: options.config,
logLevel: options.logLevel,
clearScreen: options.clearScreen,
server: cleanOptions(options),
})
await server.listen()
通过createServer
创建server
对象后,调用server.listen
方法启动服务器。启动后会执行httpServer.listen
方法
在执行createServer
时,会重写server.listen
方法
let isOptimized = false
// overwrite listen to run optimizer before server start
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
// 调用 所有插件的 buildStart 钩子函数
await container.buildStart({})
await runOptimize()
isOptimized = true
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
}) as any
首先调用所有插件的buildStart
方法,然后调用runOptimize
方法
const runOptimize = async () => {
// 获取缓存路径,默认是 node_modules/.vite
if (config.cacheDir) {
// 表示当前正在预构建
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
上述代码先调用optimizeDeps
方法,然后调用createMissingImporterRegisterFn
方法。
先看下optimizeDeps
方法,这个方法比较大,这里我们分步来看他做了啥
export async function optimizeDeps(
config: ResolvedConfig,
force = config.server.force, // 设置为 true 强制使依赖预构建
asCommand = false,
newDeps?: Record<string, string>, // missing imports encountered after server has started
ssr?: boolean
): Promise<DepOptimizationMetadata | null> {
// 重新赋值 config
config = {
...config,
command: 'build',
}
const { root, logger, cacheDir } = config
// 拼接 _metadata.json 文件的路径(一般在 node_modules/.vite/_metadata.json)
const dataPath = path.join(cacheDir, '_metadata.json')
// 根据包管理器的 lockfile、vite.config.js 相关字段生成 hash 值
// 官网说还会根据 package.json 中的 dependencies 列表,但是现在这个版本没有这样,可能是后续版本更新了
const mainHash = getDepHash(root, config)
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {},
}
// ...
首先拼接 _metadata.json
文件的路径,一般在node_modules/.vite/_metadata.json
。然后通过getDepHash
生成 hash 值。并创建一个data
对象
_metadata.json
文件的作用是存储了预构建模块的一些信息,后续会详细介绍
if (!force) {
let prevData: DepOptimizationMetadata | undefined
try {
// 获取 cacheDir 中 _metadata.json 文件的内容
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// 如果 _metadata.json 有内容,并且之前的 哈希值和现在刚生成的哈希值相同
// 则表示没有依赖项发生改变,直接返回现在的 _metadata.json 的内容
if (prevData && prevData.hash === data.hash) {
log('Hash is consistent. Skipping. Use --force to override.')
return prevData
}
}
如果没有设置force
(强制使依赖预构建),从_metadata.json
中读取上次文件的内容,并判断hash值是否和现在的相同,如果相同,则直接返回_metadata.json
中的内容。
接下来的逻辑就是如果依赖发生改变,或者没有预构建过;根据cacheDir
创建空文件夹,这里就是.vite
文件夹。然后在文件夹中创建package.json
文件,并写入"type": "module"
// 如果有 cacheDir(默认.vite),清空缓存文件夹
// 如果没有,创建一个空文件
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
// 如果 recursive 为 true 返回创建的第一个目录路径,反之返回 undefined
fs.mkdirSync(cacheDir, { recursive: true })
}
// cacheDir 中创建 package.json,并写入 "type": "module"
// 作用:给 Node 提示,缓存目录中的所有文件都应该被识别为 ES 模块
writeFile(
path.resolve(cacheDir, 'package.json'),
JSON.stringify({ type: 'module' })
)
小结
在这里先总结下上面的流程
- 根据包管理器的 lockfile、vite.config.js 相关字段生成 hash 值
- 获取
_metadata.json
文件的路径,里面的内容是上次预构建模块的信息 - 如果不是强制预构建,比对
_metadata.json
文件中的 hash 和新创建的 hash 值- 如果一致直接返回
_metadata.json
中的内容 - 如果不一致,创建/清空缓存文件夹(默认是
.vite
);缓存文件内创建package.json
文件,并写入"type": "module"
- 如果一致直接返回
继续向下,根据传入的newDeps
判断有没有依赖列表。如果没有,通过scanImports
方法收集列表
let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
自动依赖搜寻
scanImports
方法定义如下,也是一步一步的看
export async function scanImports(config: ResolvedConfig): Promise<{
deps: Record<string, string>
missing: Record<string, string>
}> {
const start = performance.now()
let entries: string[] = []
// 一般都是用默认的 index.html
// 默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项。如果指定了 build.rollupOptions.input,Vite 将转而去抓取这些入口点。
// 如果这两者都不适合你的需要,则可以使用此选项指定自定义条目,或者是相对于 vite 项目根的模式数组。这将覆盖掉默认条目推断。
const explicitEntryPatterns = config.optimizeDeps.entries
const buildInput = config.build.rollupOptions?.input
if (explicitEntryPatterns) {
// 如果配置了 config.optimizeDeps.entries
// 在 config.root 下查找 explicitEntryPatterns 中对应的文件,并返回绝对路径
entries = await globEntries(explicitEntryPatterns, config)
} else if (buildInput) {
// 如果配置了 build.rollupOptions.input
const resolvePath = (p: string) => path.resolve(config.root, p)
// 下面的逻辑都是将 buildInput 的路径修改为相对于 config.root 的路径
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)]
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath)
} else if (isObject(buildInput)) {
entries = Object.values(buildInput).map(resolvePath)
} else {
throw new Error('invalid rollupOptions.input value.')
}
} else {
// 查找 html 文件
entries = await globEntries('**/*.html', config)
}
// 不支持的入口文件类型和虚拟文件不应该扫描依赖项。
// 过滤非 .jsx .tsx .mjs .html .vue .svelte .astro 文件,并且文件必须存在
entries = entries.filter(
(entry) =>
(JS_TYPES_RE.test(entry) || htmlTypesRE.test(entry)) &&
fs.existsSync(entry)
)
首先查找入口文件
- 如果有
config.optimizeDeps.entries
配置项,则入口文件从这里查找 - 如果有
config.build.rollupOptions.input
配置项,则入口文件从这里查找 - 上述两种都没有,在项目跟目录下查找
html
文件
const deps: Record<string, string> = {}
const missing: Record<string, string> = {}
// 创建插件容器
const container = await createPluginContainer(config)
// 创建 esbuildScanPlugin 插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
// 获取 预构建的 plugins 和 配置项
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
接下来就是定义查找预构建模块需要的变量,比如插件容器container
、esbuildScanPlugin
插件、config.optimizeDeps.esbuildOptions
中定义的plugins
和其他 ESbuild 配置项
// 打包每个入口文件,并将引入的 js 合并到一起
await Promise.all(
entries.map((entry) =>
build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
logLevel: 'error',
plugins: [...plugins, plugin],
...esbuildOptions
})
)
)
return {
deps,
missing
}
接着就是调用 ESbuild 从入口模块开始构建整个项目,获取需要预构建的模块,并将依赖列表返回。其中会执行esbuildScanPlugin
插件,这个插件就是查找的核心,看下这个插件的实现
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
function esbuildScanPlugin(
config: ResolvedConfig,
container: PluginContainer,
depImports: Record<string, string>,
missing: Record<string, string>,
entries: string[]
): Plugin {
const seen = new Map<string, string | undefined>()
// 通过 container.resolveId 处理 id,最终返回该模块的绝对路径
// 并将这个模块路径添加到 seen 中,key 是 id + importer的上级目录,value是模块绝对路径
const resolve = async (id: string, importer?: string) => {}
const include = config.optimizeDeps?.include
// 忽略的包
const exclude = [
...(config.optimizeDeps?.exclude || []),
'@vite/client',
'@vite/env',
]
// 设置 build.onResolve 钩子函数的返回值
const externalUnlessEntry = ({ path }: { path: string }) => ({
path, // 模块路径
// 如果 entries 包含当前id,返回false
// 如果 external 为 true,不会将当前模块打包到 bundle 中
// 这段代码的意思是,如果当前模块包含在 entries 中,将这个模块打包到 bundle 中
external: !entries.includes(path),
})
return {
name: 'vite:dep-scan',
setup(build) {},
}
}
esbuildScanPlugin
方法返回一个插件对象;定义了一个查找路径的resolve
函数,和获取配置项中需要预构建的列表和忽略列表
这个插件会针对不同类型的文件做不同处理
外部文件、data:
开头的文件、css、json、不知名文件
setup(build) {
// 如果是 http(s) 的外部文件,不打包到 bundle 中
build.onResolve({ filter: externalRE }, ({ path }) => ({
path,
external: true,
}))
// 如果是以 data: 开头,不打包到 bundle 中
build.onResolve({ filter: dataUrlRE }, ({ path }) => ({
path,
external: true,
}))
// css & json
build.onResolve(
{filter: /\.(css|less|sass|scss|styl|stylus|pcss|postcss|json)$/},
externalUnlessEntry
)
// known asset types
build.onResolve(
{filter: new RegExp(`\\.(${KNOWN_ASSET_TYPES.join('|')})$`)},
externalUnlessEntry
)
// known vite query types: ?worker, ?raw
build.onResolve({ filter: SPECIAL_QUERY_RE }, ({ path }) => ({
path,
external: true, // 不注入 boundle 中
}))
}
第三方库
上面这些比较简单,这里就不多介绍了,接下来看下对于第三方依赖是怎么处理的
build.onResolve({ filter: /^[\w@][^:]/ }, async ({ path: id, importer }) => {
// 判断引入的第三方模块是不是包含在 exclude 中
if (exclude?.some((e) => e === id || id.startsWith(e + '/'))) {
return externalUnlessEntry({ path: id })
}
// 如果当前模块已经被收集
if (depImports[id]) {
return externalUnlessEntry({ path: id })
}
// 获取 第三方模块的绝对路径
const resolved = await resolve(id, importer)
if (resolved) {
// 虚拟路径、非绝对路径、非 .jsx .tsx .mjs .html .vue .svelte .astro 文件返回 true
if (shouldExternalizeDep(resolved, id)) {
return externalUnlessEntry({ path: id })
}
// 重点!!!! 这里进行收集第三方依赖
// 如果路径包含 node_modules 子字符串,或者该文件在 include 中存在
if (resolved.includes('node_modules') || include?.includes(id)) {
// OPTIMIZABLE_ENTRY_RE = /\\.(?:m?js|ts)$/
if (OPTIMIZABLE_ENTRY_RE.test(resolved)) {
// 添加到 depImports 中
depImports[id] = resolved
}
// 如果当前id,比如 vue,没有包含在entries时不将此文件打包到 bundle 中,反之打包
return externalUnlessEntry({ path: id })
} else {
const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined
// linked package, keep crawling
return {
path: path.resolve(resolved),
namespace,
}
}
} else {
// 说明没找到 id 对应的模块
missing[id] = normalizePath(importer)
}
})
如果模块导入路径以字母、数字、下划线、汉字、@
开头,会被这个钩子函数捕获;获取模块绝对路径;如果模块路径包含node_modules
子字符串,或者该模块在include
中存在,并且是mjs
、js
、ts
文件,则将这个模块添加到depImports
中
如果根据传入的id
没有解析到模块路径,添加到missing
中
HTML、Vue 文件
对于 HTML、Vue文件,设置namespace
为html
// htmlTypesRE = /\.(html|vue|svelte|astro)$/
// importer:绝对路径,该文件在哪个文件里被导入的
// 设置路径,并设置 namespace 为 html
build.onResolve(
{ filter: htmlTypesRE },
async ({ path, importer }) => {
return {
path: await resolve(path, importer)
namespace: 'html',
}
}
)
当执行build.onLoad
钩子函数时,namespace
为html
会命中一个onload
钩子函数
build.onLoad({ filter: htmlTypesRE, namespace: 'html' }, async ({ path }) => {
// 读取html、Vue内容
let raw = fs.readFileSync(path, 'utf-8')
// 将注释内容替换成 <!---->
raw = raw.replace(commentRE, '<!---->')
// 如果是 .html 结尾,则为true
const isHtml = path.endsWith('.html')
// 如果是 .html 结尾,则 regex 匹配 type 为 module 的 script 标签,反之,比如 Vue 匹配没有 type 属性的 script 标签
// scriptModuleRE[1]: <script type="module"> 开始标签
// scriptModuleRE[2]、scriptRE[1]: script 标签的内容
// scriptRE[1]: <script> 开始标签
const regex = isHtml ? scriptModuleRE : scriptRE
// 重置 regex.lastIndex
regex.lastIndex = 0
let js = ''
let loader: Loader = 'js'
let match: RegExpExecArray | null
while ((match = regex.exec(raw))) {
const [, openTag, content] = match
// 获取开始标签上的 src 内容
const srcMatch = openTag.match(srcRE)
// 获取开始标签上的 type 内容
const typeMatch = openTag.match(typeRE)
// 获取开始标签上的 lang 内容
const langMatch = openTag.match(langRE)
const type = typeMatch && (typeMatch[1] || typeMatch[2] || typeMatch[3])
const lang = langMatch && (langMatch[1] || langMatch[2] || langMatch[3])
// skip type="application/ld+json" and other non-JS types
if ( type && !(type.includes('javascript') || type.includes('ecmascript') || type === 'module')) {
continue
}
// 不同文件设置不同 loader
if (lang === 'ts' || lang === 'tsx' || lang === 'jsx') {
loader = lang
}
// 将 src 或者 script 代码块内容添加到 js 字符串中
if (srcMatch) {
const src = srcMatch[1] || srcMatch[2] || srcMatch[3]
js += `import ${JSON.stringify(src)}\n`
} else if (content.trim()) {
js += content + '\n'
}
}
// 清空多行注释和单行注释内容
const code = js.replace(multilineCommentsRE, '/* */').replace(singlelineCommentsRE, '')
// 处理 ts + <script setup 形式的 Vue 文件
if (loader.startsWith('ts') && (path.endsWith('.svelte') || (path.endsWith('.vue') && /<script\s+setup/.test(raw))) ) {
// 对于 ts + <script setup 形式的 Vue 文件,导入的内容会被 ESbuild 原样输出,这将阻止 ESbuild 进一步爬行
// 解决方案是为每个导入添加' import 'x' ',以强制 ESbuild 保持爬行
while ((m = importsRE.exec(code)) != null) {
if (m.index === importsRE.lastIndex) {
importsRE.lastIndex++
}
js += `\nimport ${m[1]}`
}
}
// ...
return {
loader,
contents: js,
}
})
这个钩子函数的主要作用就是将所有导入拼成一个字符串,并设置loader
属性值;然后交给 ESbuild 处理,这样做的目的就是 ESbuild 会加载这些导入内容,并收集符合条件的模块。
HTML文件:
获取 HTML 中所有script
标签,对于有src
属性的,通过import()
的形式拼接成字符串;对于内联的script
标签,将内联代码拼接到字符串中,因为这里面可能包含导入代码,最后返回loader
和content
Vue文件:和 HTML 文件大致相同,都是获取导入内容并返回loader
和content
jsx、tsx、mjs
这几个比较简单,也是通过onload
钩子函数,判断有没有配置jsxInject
选项,如果有将这个选项内容添加到代码中
build.onLoad({ filter: JS_TYPES_RE }, ({ path: id }) => {
let ext = path.extname(id).slice(1)
if (ext === 'mjs') ext = 'js'
let contents = fs.readFileSync(id, 'utf-8')
// 如果是 tsx、jsx并且配置了 jsxInject,则将 jsxInject 注入到代码中
if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) {
contents = config.esbuild.jsxInject + `\n` + contents
}
return {
loader: ext as Loader,
contents,
}
})
esbuildScanPlugin
插件小结
esbuildScanPlugin
插件的作用就是收集要进行预构建的模块。Vite 将从入口文件开始(默认是index.html
)通过 ESbuild 抓取源码,并自动寻找引入的依赖项(即 "bare import",表示期望从 node_modules
解析)
回到scanImports
方法中,通过 ESbuild 编译源码并收集需要预构建的第三方模块。最后返回deps
(收集的模块)和missing
(未找到的模块)
// 打包每个入口文件,并将引入的 js 合并到一起
await Promise.all(
entries.map((entry) =>
build({/* ... */})
)
)
return {
deps,
missing
}
deps
和missing
的数据结构如下
deps: {
导入模块名/路径: 经解析后的绝对路径
}
missing: {
导入模块名/路径: 导入该模块模块的路径
}
回到optimizeDeps
中,调用scanImports
方法之后,获取到丢失的模块和需要预构建的模块列表。
let deps: Record<string, string>, missing: Record<string, string>
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
继续向下
// update browser hash
// 这个属性就是预构建模块的url上的 v 参数
data.browserHash = createHash('sha256')
.update(data.hash + JSON.stringify(deps))
.digest('hex')
.substr(0, 8)
const missingIds = Object.keys(missing)
if (missingIds.length) {
throw new Error(/* ... */)
}
// 收集 include 中剩余需要预构建的模块
// 将 config.optimizeDeps?.include 中的文件写入 deps 中
const include = config.optimizeDeps?.include
if (include) {/* ... */}
const qualifiedIds = Object.keys(deps)
// 如果 deps 没有,写入 _metadata.json 中
if (!qualifiedIds.length) {
writeFile(dataPath, JSON.stringify(data, null, 2))
log(`No dependencies to bundle. Skipping.\n\n\n`)
return data
}
更新browserHash
,如果有丢失模块,就抛出异常。反之将config.optimizeDeps.include
中的剩余需要预构建的模块写入deps
中。判断有没有需要预构建的依赖,如果没有将data
写入_metadata.json
中。
到此,收集过程结束,接下来进入编译过程
依赖收集小结
通过 ESbuild从入口文件开始(index.html
)编译, 收集第三方依赖。如果是HTML、Vue文件会触发build.onload
钩子函数,函数内获取script
标签;对于有src
属性的,通过import()
的形式拼接成字符串;对于内联的script
标签,将内联代码拼接成字符串;最后返回这个字符串;这样就可以获取HTML、Vue文件的导入内容。编译完成后,将includes
中剩余需要预构建的模块添加到预构建列表中。到此预构建收集阶段结束,进入编译过程。
编译过程
获取 Esbuild 配置项、初始化变量
先是定义一堆变量,并获取 Esbuild 配置项
// 需要预构建的模块数量
const total = qualifiedIds.length
const maxListed = 5
const listed = Math.min(total, maxListed)
const flatIdDeps: Record<string, string> = {}
const idToExports: Record<string, ExportsData> = {}
const flatIdToExports: Record<string, ExportsData> = {}
const { plugins = [], ...esbuildOptions } = config.optimizeDeps?.esbuildOptions ?? {}
// 初始化 es-module-lexer
await init
收集
然后遍历所有需要预构建模块列表
for (const id in deps) {
// 将 > 替换成 __,将 \ 和 . 替换成 _
const flatId = flattenId(id)
const filePath = (flatIdDeps[flatId] = deps[id])
// 读取需要预构建模块的代码
const entryContent = fs.readFileSync(filePath, 'utf-8')
let exportsData: ExportsData
try {
// 通过 es-module-lexer 获取导入和导出位置
exportsData = parse(entryContent) as ExportsData
} catch {/* ... */}
for (const { ss, se } of exportsData[0]) {
// 获取导入内容
// 比如 exp = import { initCustomFormatter, warn } from '@vue/runtime-dom'
const exp = entryContent.slice(ss, se)
if (/export\s+\*\s+from/.test(exp)) {
// 如果 exp 是 export * from xxx 的形式,则设置 hasReExports 为 true
exportsData.hasReExports = true
}
}
idToExports[id] = exportsData
flatIdToExports[flatId] = exportsData
}
遍历所有需要预构建模块列表,将对应模块的绝对路径添加到flatIdDeps
中;读取模块代码,通过es-module-lexer
将模块转换成AST,并赋值给exportsData
。查找有没有export * from xxx
形式的代码,如果有exportsData.hasReExports
设置成true
。最后将AST赋值给idToExports
和flatIdToExports
flatIdToExports
、idToExports
、flatIdDeps
结构如下
# id: 导入的模块或导入的路径
# flatId: 将 > 替换成 __,将 \ 和 . 替换成 _ 的导入路径/模块
flatIdDeps: {
flatId: 对应模块的绝对路径
}
idToExports: {
id: 对应模块的AST,是一个数组
}
flatIdToExports: {
flatId: 对应模块的AST,是一个数组
}
开始构建
继续往下,开始通过 ESbuild 构建模块
// 构建过程中要替换的字符串
const define: Record<string, string> = {
'process.env.NODE_ENV': JSON.stringify(config.mode),
}
// 设置 esbuild.define 的内容,用于替换编译后的内容
for (const key in config.define) {
const value = config.define[key]
define[key] = typeof value === 'string' ? value : JSON.stringify(value)
}
// 打包 deps 中的文件
const result = await build({
absWorkingDir: process.cwd(),
entryPoints: Object.keys(flatIdDeps),
bundle: true, // 这里为 true,可以将有许多内部模块的 ESM 依赖关系转换为单个模块
format: 'esm',
target: config.build.target || undefined,
external: config.optimizeDeps?.exclude,
logLevel: 'error',
splitting: true,
sourcemap: true,
outdir: cacheDir,
ignoreAnnotations: true,
metafile: true,
define,
plugins: [
...plugins,
esbuildDepPlugin(flatIdDeps, flatIdToExports, config, ssr), // 注意这里
],
...esbuildOptions,
})
通过 ESbuild 编译所有需要预编译的模块,入口文件就是这些需要预编译的模块。这里面使用了一个自定义插件esbuildDepPlugin
,后面会分析,继续向下
生成预建模块的信息
// 获取打包到一起的文件生成的依赖图
const meta = result.metafile!
// 获取 cacheDir 相对于工作目录的路径
const cacheDirOutputPath = path.relative(process.cwd(), cacheDir)
// 拼接 data 数据,并将 data 数据写入到 _metadata.json 中
for (const id in deps) {
const entry = deps[id]
data.optimized[id] = {
file: normalizePath(path.resolve(cacheDir, flattenId(id) + '.js')),
src: entry,
needsInterop: needsInterop( // needsInterop 作用是判断这个模块是不是 CommonJS 模块
id,
idToExports[id],
meta.outputs,
cacheDirOutputPath
)
}
}
writeFile(dataPath, JSON.stringify(data, null, 2))
// 返回 data
return data
先获取依赖图,然后拼接data
对象,并写入到_metadata.json
中,最后返回`data
- 对于 CommonJS 模块,如果 ESbuild 设置了
format: 'esm'
,会导致将它包装为ESM。就像下面这样
// a.ts
module.exports = {
test: 1
}
// 编译后
import { __commonJS } from "./chunk-Z47AEMLX.js";
// src/a.ts
var require_a = __commonJS({
"src/a.ts"(exports, module) {
module.exports = { test: 1 };
}
});
// dep:__src_a_ts
var src_a_ts_default = require_a();
export {
src_a_ts_default as default
};
//# sourceMappingURL=__src_a_ts.js.map
es-module-lexer
转换 CommonJS 模块,转换后的内容,导出和引入都是空数组
_metadata.json
介绍
假设项目中只引入了 Vue,生成的_metadata.json
如下
{
"hash": "861d0c42",
"browserHash": "c30d2c95",
"optimized": {
"vue": {
"file": "/xxx/node_modules/.vite/vue.js", // 预构建生成的地址
"src": "/xxx/node_modules/vue/dist/vue.runtime.esm-bundler.js", // 源码地址
"needsInterop": false // 是否是 CommonJS 模块转成的 ESM 模块
}
}
}
esbuildDepPlugin
esbuildDepPlugin
插件定义如下
export function esbuildDepPlugin(
qualified: Record<string, string>,
exportsData: Record<string, ExportsData>,
config: ResolvedConfig,
ssr?: boolean
): Plugin {
// 创建 ESM 的路径查找函数
const _resolve = config.createResolver({ asSrc: false })
// 创建 CommonJS 的路径查找函数
const _resolveRequire = config.createResolver({
asSrc: false,
isRequire: true,
})
const resolve = (
id: string, // 当前文件
importer: string, // 导入该文件的文件地址,绝对路径
kind: ImportKind, // 导入类型
resolveDir?: string
): Promise<string | undefined> => {
let _importer: string
if (resolveDir) {
_importer = normalizePath(path.join(resolveDir, '*'))
} else {
// importer 表示导入该文件的文件
// 如果 importer 在 qualified 中存在则设置对应文件路径,反之设置 importer
_importer = importer in qualified ? qualified[importer] : importer
}
const resolver = kind.startsWith('require') ? _resolveRequire : _resolve
// 根据不同模块类型返回不同的路径查找函数
return resolver(id, _importer, undefined, ssr)
}
return {
name: 'vite:dep-pre-bundle',
setup(build) {},
}
}
esbuildDepPlugin
函数会创建一个函数,这个函数的作用是根据模块种类返回不同的路径查找函数;并返回一个插件对象
这个插件对象主要功能和钩子函数如下
// qualified 包含所有入口模块
// 如果 flatId 在入口模块里面,设置 namespace 为 dep
function resolveEntry(id: string) {
const flatId = flattenId(id)
if (flatId in qualified) {
return {
path: flatId,
namespace: 'dep',
}
}
}
// 拦截裸模块
build.onResolve(
{ filter: /^[\w@][^:]/ },
async ({ path: id, importer, kind }) => {
let entry: { path: string; namespace: string } | undefined
if (!importer) {
// 如果没有 importer 说明是入口文件
// 调用 resolveEntry 方法,如果有返回值直接返回
if ((entry = resolveEntry(id))) return entry
// 入口文件可能带有别名,去掉别名之后再调用 resolveEntry 方法
const aliased = await _resolve(id, undefined, true)
if (aliased && (entry = resolveEntry(aliased))) {
return entry
}
}
// use vite's own resolver
const resolved = await resolve(id, importer, kind)
if (resolved) {
// ...
// http(s)类型的路径
if (isExternalUrl(resolved)) {
return {
path: resolved,
external: true,
}
}
return {
path: path.resolve(resolved)
}
}
}
)
这个钩子函数的作用是
- 对预构建模块入口文件设置namespace
设置为
dep` - http(s)类型的路径不打包到 bundle 中,保持原样不变
- 其他类型的只返回路径
对于入口文件还有一个build.onLoad
钩子函数,内容如下
const root = path.resolve(config.root)
build.onLoad({ filter: /.*/, namespace: 'dep' }, ({ path: id }) => {
// 获取 id 对应的绝对路径
const entryFile = qualified[id]
// 获取 id 相对于 root 的路径
let relativePath = normalizePath(path.relative(root, entryFile))
// 拼接路径
if (
!relativePath.startsWith('./') &&
!relativePath.startsWith('../') &&
relativePath !== '.'
) {
relativePath = `./${relativePath}`
}
let contents = ''
const data = exportsData[id]
// 获取导入和导出信息
const [imports, exports] = data
if (!imports.length && !exports.length) {
// cjs
contents += `export default require("${relativePath}");`
} else {
if (exports.includes('default')) {
contents += `import d from "${relativePath}";export default d;`
}
if (
data.hasReExports ||
exports.length > 1 ||
exports[0] !== 'default'
) {
contents += `\nexport * from "${relativePath}"`
}
}
let ext = path.extname(entryFile).slice(1)
if (ext === 'mjs') ext = 'js'
return {
loader: ext as Loader,
contents,
resolveDir: root,
}
})
这个钩子函数的作用就是构建一个虚拟模块,并导入预构建的入口模块。虚拟模块内容如下
- CommonJS 类型的文件,导出的虚拟模块内容是
export default require("模块路径");
export default
的文件,导出的虚拟模块内容是import d from "模块路径";export default d;
- 其他 ESM 类型的文件,导出的虚拟模块内容是
export * from "模块路径"
之后就通过这个虚拟模块开始打包所有预渲染模块。
预构建模块小结
- 遍历所有预构建模块,将对应模块的绝对路径添加到
flatIdDeps
中;读取模块代码,通过es-module-lexer
将模块转换成AST,并赋值给exportsData
。查找有没有export * from xxx
形式的代码,如果有exportsData.hasReExports
设置成true
。最后将AST赋值给idToExports
和flatIdToExports
- 通过 ESbuild 打包所有预构建模块。并设置
bundle
为true
。从而实现上面说的将有许多内部模块的 ESM 依赖关系转换为单个模块 - 最后生成预建模块的信息
由此预构建结束。回到runOptimize
方法
const runOptimize = async () => {
if (config.cacheDir) {
server._isRunningOptimizer = true
try {
server._optimizeDepsMetadata = await optimizeDeps(config)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
}
预构建之后的返回值会挂载到server._optimizeDepsMetadata
上。
怎么注册新的依赖预编译函数
通过createMissingImporterRegisterFn
创建一个函数,并将这个函数挂载到server._registerMissingImport
上。这个函数的作用是注册新的依赖预编译
export function createMissingImporterRegisterFn(
server: ViteDevServer
): (id: string, resolved: string, ssr?: boolean) => void {
let knownOptimized = server._optimizeDepsMetadata!.optimized
let currentMissing: Record<string, string> = {}
let handle: NodeJS.Timeout
let pendingResolve: (() => void) | null = null
async function rerun(ssr: boolean | undefined) {}
return function registerMissingImport(}
}
当需要预编译新的模块时,调用这个返回函数registerMissingImport
return function registerMissingImport(
id: string,
resolved: string,
ssr?: boolean
) {
if (!knownOptimized[id]) {
// 收集需要预编译的模块
currentMissing[id] = resolved
if (handle) clearTimeout(handle)
handle = setTimeout(() => rerun(ssr), debounceMs)
server._pendingReload = new Promise((r) => {
pendingResolve = r
})
}
}
函数内,先将需要预编译的模块添加到currentMissing
中,然后调用rerun
函数
async function rerun(ssr: boolean | undefined) {
// 获取新的预编译模块
const newDeps = currentMissing
currentMissing = {}
// 合并新老的预编译模块
for (const id in knownOptimized) {
newDeps[id] = knownOptimized[id].src
}
try {
server._isRunningOptimizer = true
server._optimizeDepsMetadata = null
// 调用 optimizeDeps 函数,开始预编译过程
const newData = (server._optimizeDepsMetadata = await optimizeDeps(
server.config,
true, // 注意这里为 true,说明需要清空缓存重新预编译
false,
newDeps, // 传入了 newDeps
ssr
))
// 更新预编译模块列表
knownOptimized = newData!.optimized
} catch (e) {
} finally {
server._isRunningOptimizer = false
pendingResolve && pendingResolve()
server._pendingReload = pendingResolve = null
}
// 清空所有模块的 transformResult 属性
server.moduleGraph.invalidateAll()
// 通知客户端重新加载页面
server.ws.send({
type: 'full-reload',
path: '*',
})
}
上述代码中将新老预编译模块合并,然后调用optimizeDeps
函数重新预构建所有模块。需要注意的点是force
传入的是true
,表示清空缓存重新预编译。而且还传入了newDeps
,不会再次收集预构建列表,而是直接使用传入的newDeps
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
当新的预构建完成后,通知客户端重新加载页面。
小结
整个预编译流程如下

导入路径是怎么映射到缓存目录中
当被请求模块中导入了预构建模块时,在重写导入路径的时候会通过preAliasPlugin
插件获取并返回预构建后的路径。
看下preAliasPlugin
插件中这块逻辑
// preAliasPlugin 插件的 resolveId 内部
resolveId(id, importer, _, ssr) {
if (!ssr && bareImportRE.test(id)) {
return tryOptimizedResolve(id, server, importer);
}
}
调用tryOptimizedResolve
方法
// tryOptimizedResolve 内部
const cacheDir = server.config.cacheDir
const depData = server._optimizeDepsMetadata
if (!cacheDir || !depData) return
const getOptimizedUrl = (optimizedData: typeof depData.optimized[string]) => {
return (
optimizedData.file +
`?v=${depData.browserHash}${
optimizedData.needsInterop ? `&es-interop` : ``
}`
)
}
// check if id has been optimized
const isOptimized = depData.optimized[id]
if (isOptimized) {
return getOptimizedUrl(isOptimized)
}
可以看到,根据传入的id
从预构建列表中获取缓存文件的路径,并拼接v
参数;对于CommonJS 转成 ESM 的模块,会再拼接一个es-interop
参数。分析importAnalysis
插件时说过,对于有es-interop
参数的 URL 会在导入的地方重写导入逻辑。这也就解释了为什么 CommonJS 模块也可以通过 ESM 的方式引入。
转载自:https://juejin.cn/post/7047479540850884645