我们知道,首次执行 vite
时,服务启动后会对 node_modules 模块和配置 optimizeDeps 的目标进行预构建。本节我们就去探索预构建的流程。
按照惯例,先准备好一个例子。本文我们用 vue 的模板去初始化 DEMO:
pnpm create vite my-vue-app -- --template vue
项目创建好之后,我们再安装 lodash-es 这个包,去研究 vite 是如何将几百个文件打包成一个文件的:
pnpm add lodash-es -P
DEMO 代码量比较多,这里就不贴代码了,嫌麻烦的童鞋可以 fork Github repository[1] 。
当我们服务启动之后,除了会调用插件容器的 buildStart 钩子,还会执行预构建 runOptimize:
// ...
const listen = httpServer.listen.bind(httpServer)
httpServer.listen = (async (port: number, ...args: any[]) => {
if (!isOptimized) {
try {
// 插件容器初始化
await container.buildStart({})
// 预构建
await runOptimize()
isOptimized = true
} catch (e) {
httpServer.emit('error', e)
return
}
}
return listen(port, ...args)
}) as any
// ...
const runOptimize = async () => {
server._isRunningOptimizer = true
try {
// 依赖预构建
server._optimizeDepsMetadata = await optimizeDeps(
config,
config.server.force || server._forceOptimizeOnRestart
)
} finally {
server._isRunningOptimizer = false
}
server._registerMissingImport = createMissingImporterRegisterFn(server)
}
入口处将配置 config 和是否强制缓存的标记(通过 --force 传入或者调用 restart API)传到 optimizeDeps。optimizeDeps 逻辑比较长,我们先通过流程图对整个流程有底之后,再按照功能模块去阅读源码。
简述一下整个预构建流程:
首先会去查找缓存目录(默认是 node_modules/.vite)下的 _metadata.json 文件;然后找到当前项目依赖信息(xxx-lock 文件)拼接上部分配置后做哈希编码,最后对比缓存目录下的 hash 值是否与编码后的 hash 值一致,一致并且没有开启 force 就直接返回预构建信息,结束整个流程;
如果开启了 force 或者项目依赖有变化的情况,先保证缓存目录干净(node_modules/.vite 下没有多余文件),在 node_modules/.vite/package.json 文件写入 type: module
配置。这就是为什么 vite 会将预构建产物视为 ESM 的原因。
分析入口,依次查看是否存在 optimizeDeps.entries、build.rollupOptions.input、*.html,匹配到就通过 dev-scan 的插件寻找需要预构建的依赖,输出 deps 和 missing,并重新做 hash 编码;
最后使用 es-module-lexer[2] 对 deps 模块进行模块化分析,拿到分析结果做预构建。构建结果将合并内部模块、转换 CommonJS 依赖。最后更新 data.optimizeDeps 并将结果写入到缓存文件。
全流程上我们已经清楚了,接下来我们就深入上述流程图中绿色方块(逻辑复杂)的代码。因为步骤之间的代码关联比较少,在分析下面逻辑时会截取片段代码
export async function optimizeDeps(
config: ResolvedConfig,
force = config.server.force,
asCommand = false,
newDeps?: Record, // missing imports encountered after server has started
ssr?: boolean
): Promise {
// ...
// 缓存文件信息
const dataPath = path.join(cacheDir, '_metadata.json')
// 获取依赖的hash,这里的依赖是 lock 文件、以及 config 的部分信息
const mainHash = getDepHash(root, config)
// 定义预编译优化的元数据
const data: DepOptimizationMetadata = {
hash: mainHash,
browserHash: mainHash,
optimized: {}
}
// 不强制刷新
if (!force) {
let prevData: DepOptimizationMetadata | undefined
try {
// 读取 metadata 信息
prevData = JSON.parse(fs.readFileSync(dataPath, 'utf-8'))
} catch (e) {}
// hash is consistent, no need to re-bundle
if (prevData && prevData.hash === data.hash) {
log('Hash is consistent. Skipping. Use --force to override.')
return prevData
}
}
// 存在缓存目录,清空目录
if (fs.existsSync(cacheDir)) {
emptyDir(cacheDir)
} else {
// 创建多层级缓存目录
fs.mkdirSync(cacheDir, { recursive: true })
}
// 缓存目录的模块被识别成 ESM
writeFile(
path.resolve(cacheDir, 'package.json'),
JSON.stringify({ type: 'module' })
)
// ...
}
// 所有可能的依赖 lock 文件,分别对应 npm、yarn、pnpm 的包管理
const lockfileFormats = ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml']
/**
* 获取依赖的 hash 值
*
* @param {string} root 根目录
* @param {ResolvedConfig} config 服务配置信息
* @return {*} {string}
*/
function getDepHash(root: string, config: ResolvedConfig): string {
// 获取 lock 文件的内容
let content = lookupFile(root, lockfileFormats) || ''
// 同时也将跟部分会影响依赖的 config 的配置一起加入到计算 hash 值
content += JSON.stringify(
{
mode: config.mode,
root: config.root,
resolve: config.resolve,
assetsInclude: config.assetsInclude,
plugins: config.plugins.map((p) => p.name),
optimizeDeps: {
include: config.optimizeDeps?.include,
exclude: config.optimizeDeps?.exclude
}
},
(_, value) => {
// 常见的坑:https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/JSON/stringify
if (typeof value === 'function' || value instanceof RegExp) {
return value.toString()
}
return value
}
)
// 通过 crypto 的 createHash 进行 hash 加密
return createHash('sha256').update(content).digest('hex').substring(0, 8)
}
上述代码先去 cacheDir 目录下获取 _metadata.json 的信息,然后计算当前依赖的 hash 值,计算过程主要是通过 xxx-lock 文件,结合 config 中跟依赖相关的部分配置去计算 hash 值。最后判断如果服务没有开启 force (即刷新缓存的参数)时,去读取缓存元信息文件中的 hash 值,结果相同就直接返回缓存元信息文件即 _metadata.json 的内容;
否则就判断是否存在 cacheDir(默认情况下是 node_modules/.vite),存在就清空目录文件,不存在就创建缓存目录;最后在缓存目录下创建 package.json 文件并写入 type: module 信息,这就是为什么预构建后的依赖会被识别成 ESM 的原因。
在开启了 force 参数或者依赖前后的 hash 值不相同时,就会去扫描并分析依赖,这就进入下一个阶段。
// ... 接上述代码
let deps: Record, missing: Record
// 没有新的依赖的情况,扫描并预构建全部的 import
if (!newDeps) {
;({ deps, missing } = await scanImports(config))
} else {
deps = newDeps
missing = {}
}
// update browser hash
data.browserHash = createHash('sha256')
.update(data.hash + JSON.stringify(deps))
.digest('hex')
.substring(0, 8)
// 遗漏的包
const missingIds = Object.keys(missing)
if (missingIds.length) {
throw new Error(
`The following dependencies are imported but could not be resolved:\n\n ${missingIds
.map(
(id) =>
`${colors.cyan(id)} ${colors.white(
colors.dim(`(imported by ${missing[id]})`)
)}`
)
.join(`\n `)}\n\nAre they installed?`
)
}
// 获取 optimizeDeps?.include 配置
const include = config.optimizeDeps?.include
if (include) {
// 创建模块解析器
const resolve = config.createResolver({ asSrc: false })
for (const id of include) {
// normalize 'foo >bar` as 'foo > bar' to prevent same id being added
// and for pretty printing
const normalizedId = normalizeId(id)
if (!deps[normalizedId]) {
const entry = await resolve(id)
if (entry) {
deps[normalizedId] = entry
} else {
throw new Error(
`Failed to resolve force included dependency: ${colors.cyan(id)}`
)
}
}
}
}
const qualifiedIds = Object.keys(deps)
if (!qualifiedIds.length) {
writeFile(dataPath, JSON.stringify(data, null, 2))
log(`No dependencies to bundle. Skipping.\n\n\n`)
return data
}
const total = qualifiedIds.length
// pre-bundling 的列表最多展示 5 项
const maxListed = 5
// 列表数量
const listed = Math.min(total, maxListed)
// 剩余的数量
const extra = Math.max(0, total - maxListed)
// 预编译依赖的信息
const depsString = colors.yellow(
qualifiedIds.slice(0, listed).join(`\n `) +
(extra > 0 ? `\n (...and ${extra} more)` : ``)
)
// CLI 下才需要打印
if (!asCommand) {
if (!newDeps) {
// This is auto run on server start - let the user know that we are
// pre-optimizing deps
logger.info(colors.green(`Pre-bundling dependencies:\n ${depsString}`))
logger.info(
`(this will be run only when your dependencies or config have changed)`
)
}
} else {
logger.info(colors.green(`Optimizing dependencies:\n ${depsString}`))
}
// ...
上述代码很长,关键都在 scanImports 函数,这个涉及到 esbuild 插件和 API,我们待会拎出来分析。其他部分的代码我们通过一张流程图来讲解:
开始通过 scanImports 找到全部入口并扫描全部的依赖做预构建;返回 deps 依赖列表、missings 丢失的依赖列表;
基于 deps 做 hash 编码,编码结果赋给 data.browserHash,这个结果就是浏览器发起这些资源的 hash 参数;
对于使用了 node_modules 下没有定义的包,会发出错误信息,并终止服务;举个例子,我引入 abcd
包:
import { createApp } from 'vue'
// 引用一个不存在的包
import getABCD from 'abcd'
import App from './App.vue'
import '../lib/index'
const s = getABCD('abc')
console.log(s)
createApp(App).mount('#app')
然后执行 dev:
将 vite.config.ts 中的 optimizeDeps.include[3] 数组中的值添加到 deps 中,也举个例子:
// vite.config.js
import { defineConfig } from 'vite'
import path from 'path'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
optimizeDeps: {
include: [
path.resolve(process.cwd(), './lib/index.js')
]
}
})
// ./lib/index.js 文件
import { sayHello } from './foo'
sayHello()
// ./lib/foo.js
export function sayHello () {
console.log('hello vite prebundling')
}
上述代码我们将 ./lib/index.js 这个文件添加到预构建的 include 配置中,lib 下的两个文件内容也已经明确了。接下来执行 dev 后,我们从终端上就可以看到这个结果:
我们的 lib/index.js 已经被添加到预构建列表。最后再看一下 node_modules/.vite,有一个 _Users_yjcjour_Documents_code_vite_examples_vue-demo_lib_index_js.js
文件,并且已经被构建,还有 sourcemap 文件,这就是 optimizeDeps.include[4] 的作用。具体如何构建这个文件的我们在 导出分析 去梳理。
最后根据 deps 的长度去计算命令行中显示的预构建信息,并打印。
上述整个流程逻辑比较简单,就梳理一个主流程并实际展示了部分配置的作用。还有一个关键的环节我们略过了——scanImports。
/**
* 扫描全部引入
* @param {ResolvedConfig} config
*/
export async function scanImports(config: ResolvedConfig): Promise<{
deps: Record
missing: Record
}> {
const start = performance.now()
let entries: string[] = []
// 预构建自定义条目
const explicitEntryPatterns = config.optimizeDeps.entries
// rollup 入口点
const buildInput = config.build.rollupOptions?.input
// 自定义条目优先级最高
if (explicitEntryPatterns) {
entries = await globEntries(explicitEntryPatterns, config)
// 其次是 rollup 的 build 入口
} else if (buildInput) {
const resolvePath = (p: string) => path.resolve(config.root, p)
// 字符串,转成数组
if (typeof buildInput === 'string') {
entries = [resolvePath(buildInput)]
// 数组,遍历输出路径
} else if (Array.isArray(buildInput)) {
entries = buildInput.map(resolvePath)
// 对象,返回对象的value数组
} else if (isObject(buildInput)) {
entries = Object.values(buildInput).map(resolvePath)
} else {
throw new Error('invalid rollupOptions.input value.')
}
// 默认情况下,Vite 会抓取你的 index.html 来检测需要预构建的依赖项
} else {
entries = await globEntries('**/*.html', config)
}
// 合法的入口文件只能是存在的 js、html、vue、svelte、astro 文件
entries = entries.filter(
(entry) =>
(JS_TYPES_RE.test(entry) || htmlTypesRE.test(entry)) &&
fs.existsSync(entry)
)
// 找不到需要预构建的入口
if (!entries.length) {
if (!explicitEntryPatterns && !config.optimizeDeps.include) {
config.logger.warn(
colors.yellow(
'(!) Could not auto-determine entry point from rollupOptions or html files ' +
'and there are no explicit optimizeDeps.include patterns. ' +
'Skipping dependency pre-bundling.'
)
)
}
return { deps: {}, missing: {} }
} else {
debug(`Crawling dependencies using entries:\n ${entries.join('\n ')}`)
}
// 依赖
const deps: Record = {}
// 缺失的依赖
const missing: Record = {}
// 创建插件容器,为什么这里需要单独创建一个插件容器?而不是使用 createServer 时创建的那个
const container = await createPluginContainer(config)
// 创建 esbuild 扫描的插件
const plugin = esbuildScanPlugin(config, container, deps, missing, entries)
// 外部传入的 esbuild 配置
const { plugins = [], ...esbuildOptions } =
config.optimizeDeps?.esbuildOptions ?? {}
// 遍历所有入口全部进行预构建
await Promise.all(
entries.map((entry) =>
build({
absWorkingDir: process.cwd(),
write: false,
entryPoints: [entry],
bundle: true,
format: 'esm',
logLevel: 'error',
plugins: [...plugins, plugin],
...esbuildOptions
})
)
)
debug(`Scan completed in ${(performance.now() - start).toFixed(2)}ms:`, deps)
return {
deps,
missing
}
}
扫描入口先从 optimizeDeps.entries 获取;如果没有就去获取 build.rollupOptions.input 配置,处理了 input 的字符串、数组、对象形式;如果都没有,就默认寻找 html 文件。然后传入 deps、missing 调用 esbuildScanPlugin 函数生成扫描插件,并从 optimizeDeps.esbuildOptions 获取外部定义的 esbuild 配置,最后调用 esbuild.build API 进行构建。整个流程汇总成一张图如下:
重点来了,使用 vite:dep-scan 插件扫描依赖,并将在 node_modules 中的依赖定义在 deps
对象中,缺失的依赖定义在 missing
中。接着我们就进入该插件内部,一起学习 esbuild 插件机制:
// 匹配 html
这段代码会被解析成import '/src/main.js'
,这就会进入下一个 resolve、load 过程。在进入 JS_TYPE 的解析之前,有一个全匹配 resolver 先提出来:build.onResolve( { filter: /.*/ }, async ({ path: id, importer }) => { console.log('all resloved --------------->', id) // 使用 vite 解析器来支持 url 和省略的扩展 const resolved = await resolve(id, importer) if (resolved) { // 外部依赖 if (shouldExternalizeDep(resolved, id)) { return externalUnlessEntry({ path: id }) } // 扩展匹配上了 htmlTypesRE,就将其归类于 html const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined return { path: path.resolve(cleanUrl(resolved)), namespace } } else { // resolve failed... probably unsupported type return externalUnlessEntry({ path: id }) } } ) build.onLoad({ filter: JS_TYPES_RE }, ({ path: id }) => { console.log('js load --------------->', id) // 获取文件的后缀扩展 let ext = path.extname(id).slice(1) // 如果是 mjs,将 loader 重置成 js if (ext === 'mjs') ext = 'js' let contents = fs.readFileSync(id, 'utf-8') // 通过 esbuild.jsxInject 来自动为每一个被 ESbuild 转换的文件注入 JSX helper if (ext.endsWith('x') && config.esbuild && config.esbuild.jsxInject) { contents = config.esbuild.jsxInject + `\n` + contents } // import.meta.glob 的处理 if (contents.includes('import.meta.glob')) { return transformGlob(contents, id, config.root, ext as Loader).then( (contents) => ({ loader: ext as Loader, contents }) ) } return { loader: ext as Loader, contents } })
/.*/ 全匹配的 resolver 通过 resolve 函数获取完整地依赖路径,比如这里的 /src/main.js 就会被转成完整的绝对路径。然后再来到 JS_TYPES_RE 的 loader,最终输出文件内容 contents 和对应的解析器。/src/main.js 文件内容:
import { createApp } from 'vue' import App from './App.vue' import '../lib/index' createApp(App).mount('#app')
接着就会处理
vue
、./App.vue
、../lib/index
3个依赖。对于
vue
依赖,会跟 /^[\w@][^:]/[6] 匹配。// bare imports: record and externalize ---------------------------------- build.onResolve( { // avoid matching windows volume filter: /^[\w@][^:]/ }, async ({ path: id, importer }) => { console.log('bare imports --------------->', id) // 首先判断是否在外部入口列表中 if (moduleListContains(exclude, id)) { return externalUnlessEntry({ path: id }) } // 缓存,对于在node_modules或者optimizeDeps.include中已经处理过的依赖,直接返回 if (depImports[id]) { return externalUnlessEntry({ path: id }) } const resolved = await resolve(id, importer) if (resolved) { // 对于一些特殊的场景,也会将其视作 external 排除掉 if (shouldExternalizeDep(resolved, id)) { return externalUnlessEntry({ path: id }) } // 判断是否在node_modules或者optimizeDeps.include中的依赖 if (resolved.includes('node_modules') || include?.includes(id)) { // 存到depImports中,这个对象就是外部传进来的deps,最后会写入到缓存文件中的对象 // 如果是这类依赖,直接将其视作为“外部依赖”,不在进行接下来的resolve、load if (OPTIMIZABLE_ENTRY_RE.test(resolved)) { depImports[id] = resolved } return externalUnlessEntry({ path: id }) } else { const namespace = htmlTypesRE.test(resolved) ? 'html' : undefined // linked package, keep crawling return { path: path.resolve(resolved), namespace } } } else { missing[id] = normalizePath(importer) } } )
经过上述解析,vue 依赖会被视作 external dep,并将它缓存到 depImports,因为对于 node_modules 或者 optimizeDeps.include 中的依赖,只需抓取一次即可。
对于 ./App.vue,首先会进入 html type resolve,然后进入 html load load 的回调中。跟 index.html 不同的时,此时是 .vue 文件,在执行
match = regex.exec(raw)
的时候匹配到没有 src 路径,会进入 else 逻辑。将 App.vue 中的 script 内容提取出来,存到 localScripts 中。最终生成的对象:
localScripts = { '/Users/yjcjour/Documents/code/vite/examples/vue-demo/src/App.vue': { loader: 'js', // vue 中