依赖预构建
Vite 是一个提倡 no-bundle
的构建工具,相比于传统的 Webpack,能做到开发时的模块按需编译,而不用先打包完再加载。我们所说的模块代码其实分为两部分,
- 一部分是源代码,也就是
业务代码
, - 另一部分是第三方依赖的代码,即
node_modules
中的代码。
所谓的no-bundle
只是对于源代码而言,对于第三方依赖而言,Vite 还是选择 bundle(打包),并且使用速度极快的打包器 Esbuild 来完成这一过程,达到秒级的依赖编译速度。
为什么需要预构建
1.CommonJS 和 UMD 兼容性:
在开发阶段中,Vite 的开发服务器将所有代码视为原生 ES 模块。因此,Vite 必须先将以 CommonJS 或 UMD 形式提供的依赖项转换为 ES 模块。
在转换 CommonJS 依赖项时,Vite 会进行智能导入分析,这样即使模块的导出是动态分配的(例如 React),具名导入(named imports)也能正常工作:
// 符合预期
import React, { useState } from 'react'
2.性能
为了提高后续页面的加载性能,Vite 将那些具有许多内部模块的 ESM 依赖项转换为单个模块。
有些包将它们的 ES 模块构建为许多单独的文件,彼此导入。例如,lodash-es 有超过 600 个内置模块!当我们执行 import { debounce } from 'lodash-es' 时,浏览器同时发出 600 多个 HTTP 请求!即使服务器能够轻松处理它们,但大量请求会导致浏览器端的网络拥塞,使页面加载变得明显缓慢。
通过将 lodash-es 预构建成单个模块,现在我们只需要一个HTTP请求!
注意
依赖预构建仅适用于开发模式,并使用 esbuild 将依赖项转换为 ES 模块。在生产构建中,将使用 @rollup/plugin-commonjs。
缓存
文件系统缓存
Vite 将预构建的依赖项缓存到 node_modules/.vite
中。这就是预构建产物文件存放的目录。在浏览器访问页面后,打开 Dev Tools
中的网络调试面板,你可以发现第三方包的引入路径已经被重写:
import React from "react";
// 路径被重写,定向到预构建产物文件中
import __vite__cjsImport1_react from "/node_modules/.vite/deps/react.js?v=74dec19c";
const StrictMode = __vite__cjsImport1_react["StrictMode"];
它会基于以下几个来源来决定是否需要重新运行预构建步骤:
- 包管理器的锁文件内容,例如
package-lock.json
,yarn.lock
,pnpm-lock.yaml
,或者bun.lockb
; 补丁文件夹的修改时间; vite.config.js
中的相关字段;NODE_ENV
的值。 只有在上述其中一项发生更改时,才需要重新运行预构建。
如果出于某些原因你想要强制 Vite 重新构建依赖项,你可以在启动开发服务器时指定 --force
选项,或手动删除 node_modules/.vite
缓存目录。
浏览器缓存
已预构建的依赖请求使用 HTTP 头 max-age=31536000
, immutable
进行强缓存,以提高开发期间页面重新加载的性能。一旦被缓存,这些请求将永远不会再次访问开发服务器。如果安装了不同版本的依赖项(这反映在包管理器的 lockfile 中),则会通过附加版本查询自动失效。如果你想通过本地编辑来调试依赖项,您可以:
- 通过浏览器开发工具的 Network 选项卡暂时禁用缓存;
- 重启 Vite 开发服务器指定
--force
选项,来重新构建依赖项; - 重新载入页面。
预构建配置
怎样通过 Vite 提供的配置项来定制预构建的过程。Vite 将预构建相关的配置项都集中在 optimizeDeps
属性上,我们来一一拆解这些子配置项背后的含义和应用场景。
入口文件——entries
第一个是参数是optimizeDeps.entries
,通过这个参数你可以自定义预构建的入口文件。
默认情况下,Vite 会抓取你的 index.html
来检测需要预构建的依赖项(忽略了node_modules
、build.outDir
、__tests__
和 coverage
)。如果指定了 build.rollupOptions.input
,Vite 将转而去抓取这些入口点。
如果这两者都不合你意,则可以使用此选项指定自定义条目——该值需要遵循 tinyglobby 模式 ,或者是相对于 Vite 项目根目录的匹配模式数组。当显式声明了 optimizeDeps.entries
时默认只有 node_modules
和 build.outDir
文件夹会被忽略。如果还需忽略其他文件夹,你可以在模式列表中使用以 ! 为前缀的、用来匹配忽略项的模式。如果你不想忽略 node_modules
和 build.outDir
,你可以选择直接使用字符串路径(不使用 tinyglobby 模式)
// vite.config.ts
{
optimizeDeps: {
// 为一个字符串数组
entries: ["./src/main.vue"];
}
}
当然,entries 配置tinyglobby模式,非常灵活,如:
// 将所有的 .vue 文件作为扫描入口
entries: ["**/*.vue"];
不光是.vue
文件,Vite 同时还支持各种格式的入口,包括: html
、svelte
、astro
、js
、jsx
、ts
和tsx
。可以看到,只要可能存在import
语句的地方,Vite 都可以解析,并通过内置的扫描机制搜集到项目中用到的依赖,通用性很强。
添加一些依赖——include
除了 entries
,include
也是一个很常用的配置,它决定了可以强制预构建的依赖项,使用方式很简单:
// vite.config.ts
optimizeDeps: {
// 配置为一个字符串数组,将 `lodash-es` 和 `vue`两个包强制进行预构建
include: ["lodash-es", "vue"];
}
它在使用上并不难,真正难的地方在于,如何找到合适它的使用场景。前文中我们提到,Vite 会根据应用入口(entries
)自动搜集依赖,然后进行预构建,这是不是说明 Vite 可以百分百准确地搜集到所有的依赖呢?事实上并不是,某些情况下 Vite 默认的扫描行为并不完全可靠,这就需要联合配置include
来达到完美的预构建效果了。接下来,我们好好梳理一下到底有哪些需要配置include
的场景。
场景一: 动态 import
在某些动态 import 的场景下,由于 Vite 天然按需加载的特性,经常会导致某些依赖只能在运行时被识别出来。
// src/locales/zh_CN.js
import objectAssign from "object-assign";
console.log(objectAssign);
// main.tsx
const importModule = (m) => import(`./locales/${m}.ts`);
importModule("zh_CN");
在这个例子中,动态 import 的路径只有运行时才能确定,无法在预构建阶段被扫描出来。
Vite 运行时发现了新的依赖,随之重新进行依赖预构建,并刷新页面。这个过程也叫二次预构建。在一些比较复杂的项目中,这个过程会执行很多次。然而,二次预构建的成本也比较大。我们不仅需要把预构建的流程重新运行一遍,还得重新刷新页面,并且需要重新请求所有的模块。尤其是在大型项目中,这个过程会严重拖慢应用的加载速度!因此,我们要尽力避免运行时的二次预构建。具体怎么做呢?你可以通过include
参数提前声明需要按需加载的依赖:
// vite.config.ts
{
optimizeDeps: {
include: [
// 按需加载的依赖都可以声明到这个数组里
"object-assign",
];
}
}
场景二: 某些包被手动 exclude
exclude
是optimizeDeps
中的另一个配置项,与include
相对,用于将某些依赖从预构建的过程中排除。不过这个配置并不常用,也不推荐大家使用。如果真遇到了要在预构建中排除某个包的情况,需要注意它所依赖的包
是否具有 ESM 格式
自定义 Esbuild 行为
Vite 提供了esbuildOptions
参数来让我们自定义 Esbuild 本身的配置,常用的场景是加入一些 Esbuild 插件:
// vite.config.ts
{
optimizeDeps: {
esbuildOptions: {
plugins: [
// 加入 Esbuild 插件
];
}
}
}
这个配置主要是处理一些特殊情况,如某个第三方包本身的代码出现问题了。由于我们无法保证第三方包的代码质量,在某些情况下我们会遇到莫名的第三方库报错。我举一个常见的案例——react-virtualized
库。这个库被许多组件库用到,但它的 ESM 格式产物有明显的问题,在 Vite 进行预构建的时候会直接抛出这个错误。原因是这个库的 ES 产物莫名其妙多出了一行无用的代码。其实我们并不需要这行代码,但它却导致 Esbuild 预构建的时候直接报错退出了。那这一类的问题如何解决呢?
1. 改第三方库代码
首先,我们能想到的思路是直接修改第三方库的代码,不过这会带来团队协作的问题,你的改动需要同步到团队所有成员,比较麻烦。
好在,我们可以使用patch-package
这个库来解决这类问题。一方面,它能记录第三方库代码的改动,另一方面也能将改动同步到团队每个成员。
根目录会多出patches
目录记录第三方包内容的更改,随后我们在package.json
的scripts
中增加如下内容:
pnpm i @milahu/patch-package -D
{
"scripts": {
// 省略其它 script
"postinstall": "patch-package"
}
}
这样一来,每次安装依赖的时候都会通过 postinstall
脚本自动应用 patches 的修改,解决了团队协作的问题。
注意
要改动的包在 package.json 中必须声明确定的版本,不能有 ~
或者 ^
的前缀。
2. 加入 Esbuild 插件
第二种方式是通过 Esbuild 插件修改指定模块的内容。
// vite.config.ts
const esbuildPatchPlugin = {
name: "react-virtualized-patch",
setup(build) {
build.onLoad(
{
filter:
/react-virtualized\/dist\/es\/WindowScroller\/utils\/onScroll.js$/,
},
async (args) => {
const text = await fs.promises.readFile(args.path, "utf8");
return {
contents: text.replace(
'import { bpfrpt_proptype_WindowScroller } from "../WindowScroller.js";',
""
),
};
}
);
},
};
// 插件加入 Vite 预构建配置
{
optimizeDeps: {
esbuildOptions: {
plugins: [esbuildPatchPlugin];
}
}
}
代码
调用路径概览
关键文件是:
📁 vite/src/node/server/index.ts
createServer() -> _createServer()
const config = await resolveConfig(inlineConfig, 'serve')
resolveConfig() -> resolveEnvironmentOptions() --dev--> resolveDevEnvironmentOptions()
--> createEnvironment() --> defaultCreateClientDevEnvironment() --> new DevEnvironment()
this.depsOptimizer = (
optimizeDeps.noDiscovery
? createExplicitDepsOptimizer
: createDepsOptimizer
)(this)
--> createDepsOptimizer() --> runOptimizeDeps()
关键文件是:
📁 vite/src/node/optimizer/optimizer.ts
// 收集依赖
discoverProjectDependencies()
//
runOptimizer()-->runOptimizeDeps()
export function runOptimizeDeps(
environment: Environment,
depsInfo: Record<string, OptimizedDepInfo>,
): {
cancel: () => Promise<void>
result: Promise<DepOptimizationResult>
} {
const optimizerContext = { cancelled: false }
// 获取缓存目录路径
const depsCacheDir = getDepsCacheDir(environment)
const processingCacheDir = getProcessingDepsCacheDir(environment)
// 创建一个临时目录,用来先写入预构建产物,防止直接污染缓存目录
fs.mkdirSync(processingCacheDir, { recursive: true })
// 在临时目录中创建 package.json 设置 type: "module",提示 Node 用 ES Module 方式处理
fs.writeFileSync(
path.resolve(processingCacheDir, 'package.json'),
`{\n "type": "module"\n}\n`,
)
// 初始化元数据结构(包含 hash、依赖信息等)
const metadata = initDepsOptimizerMetadata(environment)
// 计算浏览器用来校验缓存的 hash
metadata.browserHash = getOptimizedBrowserHash(
metadata.hash,
depsFromOptimizedDepInfo(depsInfo),
)
const qualifiedIds = Object.keys(depsInfo)
// 清理与提交状态控制
let cleaned = false
let committed = false
const cleanUp = () => {
// 如果已经提交则不再清理,防止误删
if (!cleaned && !committed) {
cleaned = true
// 删除临时目录(无需 await,后台处理即可)
fs.rmSync(processingCacheDir, { recursive: true, force: true })
}
}
// 最终的返回结果结构
const successfulResult: DepOptimizationResult = {
metadata,
cancel: cleanUp,
commit: async () => {
if (cleaned) throw new Error('本次优化已被取消,无法提交')
committed = true
// 将 metadata 写入临时目录
fs.writeFileSync(
path.join(processingCacheDir, METADATA_FILENAME),
stringifyDepsOptimizerMetadata(metadata, depsCacheDir),
)
// 将临时目录原子性地替换到正式缓存目录,避免中间状态不一致
const temporaryPath = depsCacheDir + getTempSuffix()
const depsCacheDirPresent = fs.existsSync(depsCacheDir)
if (isWindows) {
// Windows 下使用 safer rename
if (depsCacheDirPresent) await safeRename(depsCacheDir, temporaryPath)
await safeRename(processingCacheDir, depsCacheDir)
} else {
// 非 Windows 使用 renameSync
if (depsCacheDirPresent) fs.renameSync(depsCacheDir, temporaryPath)
fs.renameSync(processingCacheDir, depsCacheDir)
}
// 后台删除旧目录
if (depsCacheDirPresent) {
fsp.rm(temporaryPath, { recursive: true, force: true })
}
},
}
// 没有依赖可优化,仍然清空旧目录
if (!qualifiedIds.length) {
return {
cancel: async () => cleanUp(),
result: Promise.resolve(successfulResult),
}
}
const cancelledResult: DepOptimizationResult = {
metadata,
commit: async () => cleanUp(),
cancel: cleanUp,
}
const start = performance.now()
// 准备 esbuild 的运行上下文和构建计划
const preparedRun = prepareEsbuildOptimizerRun(
environment,
depsInfo,
processingCacheDir,
optimizerContext,
)
// 执行构建
const runResult = preparedRun.then(({ context, idToExports }) => {
function disposeContext() {
return context?.dispose().catch((e) => {
environment.logger.error('释放 esbuild context 失败', { error: e })
})
}
// 构建被取消或 context 不存在
if (!context || optimizerContext.cancelled) {
disposeContext()
return cancelledResult
}
// 使用 esbuild 的 rebuild 方法进行实际预构建
return context
.rebuild()
.then((result) => {
const meta = result.metafile!
// 将 esbuild 输出路径映射回缓存结构
for (const id in depsInfo) {
const output = esbuildOutputFromId(
meta.outputs,
id,
processingCacheDir,
)
const { exportsData, ...info } = depsInfo[id]
addOptimizedDepInfo(metadata, 'optimized', {
...info,
fileHash: getHash(metadata.hash + depsInfo[id].file + JSON.stringify(output.imports)),
browserHash: metadata.browserHash,
needsInterop: needsInterop(environment, id, idToExports[id], output),
})
}
// 检查是否还有其他 chunk 产物写入 metadata
for (const o of Object.keys(meta.outputs)) {
if (!jsMapExtensionRE.test(o)) {
const id = path
.relative(path.relative(process.cwd(), processingCacheDir), o)
.replace(jsExtensionRE, '')
const file = getOptimizedDepPath(environment, id)
if (!findOptimizedDepInfoInRecord(metadata.optimized, dep => dep.file === file)) {
addOptimizedDepInfo(metadata, 'chunks', {
id,
file,
needsInterop: false,
browserHash: metadata.browserHash,
})
}
} else {
// 移除空的 source map 引用(修复 Firefox 警告)
const output = meta.outputs[o]
if (output.bytes === 93) {
const jsMapPath = path.resolve(o)
const jsPath = jsMapPath.slice(0, -4)
if (fs.existsSync(jsPath) && fs.existsSync(jsMapPath)) {
const map = JSON.parse(fs.readFileSync(jsMapPath, 'utf-8'))
if (map.sources.length === 0) {
const js = fs.readFileSync(jsPath, 'utf-8')
fs.writeFileSync(jsPath, js.slice(0, js.lastIndexOf('//# sourceMappingURL=')))
}
}
}
}
}
debug?.(`依赖预构建耗时 ${(performance.now() - start).toFixed(2)}ms`)
return successfulResult
})
.catch((e) => {
// 构建取消是正常现象,返回空结果即可
if (e.errors && e.message.includes('The build was canceled')) {
return cancelledResult
}
throw e
})
.finally(() => disposeContext())
})
runResult.catch(() => cleanUp())
return {
async cancel() {
optimizerContext.cancelled = true
const { context } = await preparedRun
await context?.cancel()
cleanUp()
},
result: runResult,
}
}