Appearance
14. 预构建与缓存目录
在上一章,我们像一位侦察兵,通过静态分析扫描出了项目中所有需要被优化的裸模块依赖,并拿到了一份清晰的“目标清单”。现在,是时候让“重装部队”——预构建流程——登场了。
这个过程的核心目标非常明确:将上一阶段发现的几十甚至上百个零散的第三方库(比如 react, lodash, dayjs 等),打包成少数几个高度优化的 JavaScript 文件。
14.1. 为什么需要“预构建”?
你可能会问,既然浏览器已经原生支持 ES Module,我们为什么还要多此一举去“预构建”这些依赖呢?这主要有两个原因:
解决性能瓶颈:想象一下,一个像
lodash-es这样的库,内部可能包含了数百个独立的模块文件。如果你在代码中只用到了debounce这一个函数,浏览器在加载时,会发起一个import { debounce } from 'lodash-es'的请求。服务器首先返回lodash-es的入口文件,然后浏览器解析它,发现它又export * from './utils/debounce.js',于是再发起对debounce.js的请求... 如此往复,形成一个巨大的“请求瀑布流”。一个大型项目可能有成百上千个这样的依赖,开发服务器在启动时会面临数千个并发请求的压力,导致页面加载极其缓慢,甚至可能使浏览器崩溃。预构建将这些零散的模块打包成一个或少数几个文件,将成百上千次 HTTP 请求压缩为一两次,从根本上解决了这个问题。统一模块格式 (CJS to ESM):尽管 ESM 已成为标准,但许多历史悠久的库仍然以 CommonJS (CJS) 格式发布。浏览器是无法直接理解
require()和module.exports的。预构建流程会借助 esbuild 的力量,将这些 CJS 模块智能地转换为与浏览器兼容的 ESM 格式,让你可以在代码中无缝地使用import语法。
这个过程,就好比你要做一顿丰盛的大餐(运行你的应用)。你可以等到开饭时,才开始逐一去菜市场买菜、洗菜、切菜(浏览器按需请求原生 ESM 模块),这样做虽然灵活,但效率极低。而预构建,则像是提前进行的“备菜”(Mise en Place)。你提前把所有需要的食材(第三方依赖)都买好、处理好、打包好(用 esbuild 打包成单个文件),等到真正做饭时,直接取用即可,速度自然飞快。
14.2. Vite 的预构建流程
Vite 的预构建大本营位于 packages/vite/src/node/optimizer/index.ts。其核心是 runOptimize 函数,它 orchestrates 整个流程。
整个流程可以简化为以下几个步骤:
- 收集依赖:首先,它会执行我们在上一章讨论过的
scan过程,得到所有需要预构建的依赖deps。 - 调用 esbuild:然后,Vite 将这份依赖列表作为入口(
entryPoints),直接传递给 esbuild。esbuild 是一个用 Go 编写的极速打包工具,它会从这些入口出发,抓取所有相关的代码,将它们打包、转换,并输出到指定的目录。 - 生成元数据:打包完成后,Vite 会在缓存目录中生成一个
_metadata.json文件。这个文件至关重要,它记录了本次预构建的所有信息,包括每个依赖被打包后的出口路径、以及原始的package.json中的hash值等。这个hash是实现智能缓存的关键。
让我们看一段 runOptimize 内部的简化版伪代码,来理解其核心逻辑:
javascript
// packages/vite/src/node/optimizer/index.ts (简化版)
async function runOptimize(config, deps) {
const cacheDir = config.optimizerCacheDir; // 通常是 node_modules/.vite
// 1. 定义 esbuild 构建上下文
const context = await esbuild.context({
entryPoints: Object.keys(deps), // ['react', 'react-dom', ...]
bundle: true,
format: 'esm',
splitting: true, // 允许代码分割,优化产物
outdir: cacheDir, // 输出到缓存目录
// ... 其他 esbuild 配置
});
// 2. 执行构建
await context.rebuild();
// 3. 生成元数据文件
const metadata = {
hash: getDepHash(config), // 根据 lockfile, package.json 等生成哈希
optimized: {},
};
for (const dep in deps) {
metadata.optimized[dep] = {
file: path.resolve(cacheDir, dep + '.js'), // 记录打包后的文件路径
src: deps[dep], // 记录原始文件路径
};
}
// 将 metadata 写入 _metadata.json
await fs.writeFile(
path.join(cacheDir, '_metadata.json'),
JSON.stringify(metadata, null, 2)
);
}14.3. 智能的缓存策略
预构建虽然快,但如果每次启动服务器都重新构建一次,那也相当耗时。Vite 的聪明之处在于其“非必要,勿重复”的缓存策略。
Vite 如何判断是否需要重新构建呢?答案就在 _metadata.json 文件和一系列的依赖描述文件中。
当你启动 vite 时,它会执行以下检查:
- 计算新的依赖哈希 (Dep Hash):Vite 会根据当前项目的
package-lock.json、yarn.lock或pnpm-lock.yaml文件的内容,结合vite.config.js中与依赖优化相关的配置,计算出一个全新的哈希值。 - 对比哈希值:它会读取
node_modules/.vite/_metadata.json文件中存储的旧hash值。 - 决策:
- 如果新旧哈希值一致,并且
_metadata.json文件存在,Vite 会得出结论:依赖没有发生任何变化。于是它会完全跳过预构建过程,直接使用缓存目录中的文件。这使得后续的冷启动速度极快,几乎是瞬时的。 - 如果哈希值不一致(比如你
npm install了一个新包,或者升级/删除了一个旧包),或者缓存目录不存在,Vite 就会认为缓存已“失效”,从而触发一次全新的runOptimize预构建流程。
- 如果新旧哈希值一致,并且
这种基于哈希的缓存机制,确保了只有在依赖真正发生变化时,才会执行耗时的预构建操作,极大地提升了日常开发的效率。
14.4. mini-vite 的实现
现在,让我们在 mini-vite 中实现一个简化版的预构建与缓存功能。我们将创建一个 preBundle 函数,它接收依赖列表,并使用 esbuild 进行打包。
javascript
// src/optimizer.js
import { build } from 'esbuild';
import path from 'path';
import { promises as fs } from 'fs';
/**
* 执行预构建
* @param {string[]} deps - 需要预构建的依赖列表
* @param {object} config - 项目配置
*/
export async function preBundle(deps, config) {
const cacheDir = config.cacheDir; // e.g., 'g:/projects/io-books/CoderBooks/mini-vite/node_modules/.mini-vite'
const metadataPath = path.join(cacheDir, '_metadata.json');
// 1. 检查缓存是否有效 (简化版:仅检查 metadata 文件是否存在)
try {
const metadata = JSON.parse(await fs.readFile(metadataPath, 'utf-8'));
// 在一个完整的实现中,这里应该有哈希比较
console.log('缓存有效,跳过预构建。');
return;
} catch (e) {
// 缓存不存在或无效,继续执行
}
// 2. 执行 esbuild 打包
await build({
entryPoints: deps,
bundle: true,
format: 'esm',
splitting: true,
outdir: cacheDir,
write: true, // 确保产物写入磁盘
});
// 3. 创建并写入元数据
const metadata = {
// 简化版:用时间戳作为哈希
hash: Date.now().toString(),
dependencies: deps,
};
await fs.mkdir(cacheDir, { recursive: true });
await fs.writeFile(metadataPath, JSON.stringify(metadata));
console.log('预构建完成,并已生成缓存。');
}在这个简化版中,我们省略了复杂的哈希计算,仅通过检查 _metadata.json 是否存在来判断缓存有效性。但在实际场景中,一个可靠的哈希是必不可少的。
14.5. 深入理解:esbuild 的 splitting 选项
在上面的代码中,我们使用了 splitting: true 选项。这是什么意思呢?
当多个入口依赖(如 react 和 react-dom)共享一些公共代码时,esbuild 的 splitting 选项会将这些公共代码提取到单独的 chunk 中,而不是在每个依赖的产物中重复打包。
javascript
// 示例:如果 react 和 react-dom 都依赖一个内部的 scheduler 模块
// 开启 splitting 后,产物结构可能是:
// node_modules/.vite/
// react.js → import { ... } from './chunk-abc123.js'
// react-dom.js → import { ... } from './chunk-abc123.js'
// chunk-abc123.js → 共享的 scheduler 代码这种代码分割策略可以:
- 减少总体产物体积(避免重复)
- 提高浏览器缓存命中率(公共代码单独缓存)
14.6. 常见配置选项
在实际项目中,你可能需要使用以下配置来控制预构建行为:
javascript
// vite.config.js
export default {
optimizeDeps: {
// 强制包含某些依赖(即使没有被扫描到)
// 适用于动态导入、条件导入的依赖
include: ['lodash-es', 'vue'],
// 排除某些依赖(不进行预构建)
// 适用于已经是有效 ESM 且没有 CommonJS 依赖的包
exclude: ['my-esm-only-package'],
// 强制重新预构建(忽略缓存)
// 当你怀疑缓存出问题时使用
force: true,
// 自定义 esbuild 配置
esbuildOptions: {
// 例如:添加 loader 处理特殊文件
loader: {
'.txt': 'text'
}
}
}
}什么时候使用 exclude?
如果一个依赖满足以下条件,可以考虑排除它:
- 纯 ESM 格式(没有 CommonJS 导出)
- 没有依赖任何 CommonJS 包
- 模块数量较少(不会造成请求瀑布)
什么时候使用 include?
- 依赖通过动态
import()加载 - 依赖在条件分支中导入
- 依赖由另一个依赖内部引用(链式依赖)
14.7. CommonJS 到 ESM 的转换
预构建的另一个关键任务是将 CommonJS 模块转换为 ESM。esbuild 在这方面做了大量工作:
javascript
// 原始 CommonJS 代码(node_modules/some-cjs-lib/index.js)
const utils = require('./utils');
module.exports = {
foo: utils.bar
};
// esbuild 转换后的 ESM 代码
import utils from './utils.js';
var some_cjs_lib_default = {
foo: utils.bar
};
export { some_cjs_lib_default as default };但并非所有 CommonJS 都能完美转换。以下情况可能导致问题:
javascript
// 动态 require(无法静态分析)
const lib = require(process.env.LIB_NAME);
// 条件导出
if (condition) {
module.exports = { a: 1 };
} else {
module.exports = { b: 2 };
}当遇到这些情况时,Vite 会给出警告,你可能需要手动处理或寻找替代方案。
通过本章的学习,我们不仅理解了 Vite 依赖优化的"第二阶段"——预构建,还亲手实现了一个迷你版。现在,我们的 mini-vite 已经具备了处理复杂依赖、提升加载性能的核心能力。
在下一章,我们将探讨预构建中的一些高级优化策略和边界情况,让你的知识体系更加完善。