Vite 预构建机制深度解析
前言
在使用 Vite 开发时,你可能注意到项目根目录下会生成一个 node_modules/.vite 目录,里面包含了各种预构建的依赖文件。本文将深入探讨 Vite 预构建的工作原理,特别是它如何实现 CommonJS 到 ESM 的通用转换。
一、什么是 Vite 预构建?
1.1 .vite/deps/ 目录的作用
Vite 在首次启动时会对项目依赖进行预构建,生成的文件存储在 node_modules/.vite/deps/ 目录中。这个过程主要完成三件事:
1. CommonJS 到 ESM 的转换
使用 esbuild 将 CommonJS 格式的模块转换为浏览器可直接使用的 ESM(ES Module)格式。
// 转换前(CommonJS)
module.exports = { foo: 'bar' };
// 转换后(ESM)
export default { foo: 'bar' };2. 性能优化 - 模块合并
将碎片化的模块合并成少量文件,减少 HTTP 请求次数。
例如 lodash-es 包含 600+ 个小文件,预构建后会合并为 1 个文件:
lodash-es/
├── add.js
├── chunk.js
├── ... (600+ files)
└── zipWith.js
↓ 预构建后
.vite/deps/
└── lodash-es.js (单个文件)3. 强缓存策略
通过文件名 hash 实现依赖不变时的缓存复用:
.vite/deps/
├── react.js # ESM 入口
├── chunk-2KTPFGKL.js # React 源码(带 hash)
└── _metadata.json # 依赖元信息当 package.json 中的依赖版本不变时,浏览器可以直接使用缓存,无需重新请求。
1.2 预构建文件结构
一个典型的预构建目录结构如下:
.vite/deps/
├── _metadata.json # 依赖元数据
├── chunk-HM4MQYWN.js # 共享转换工具函数
├── chunk-2KTPFGKL.js # React 源码
├── chunk-L6ZUANEN.js # 其他共享代码
├── react.js # React ESM 入口
├── react-dom.js # React DOM ESM 入口
├── clsx.js # 纯 ESM 模块
└── package.json # 标记为 ESM 包二、CommonJS 到 ESM 的转换层实现
2.1 核心转换工具函数
Vite 使用 esbuild 生成一组转换工具函数,存储在共享的 chunk 文件中(如 chunk-HM4MQYWN.js):
// chunk-HM4MQYWN.js
var __create = Object.create;
var __defProp = Object.defineProperty;
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
var __getOwnPropNames = Object.getOwnPropertyNames;
var __getProtoOf = Object.getPrototypeOf;
var __hasOwnProp = Object.prototype.hasOwnProperty;
// 1. CommonJS 模块包装器
var __commonJS = (cb, mod) => function __require2() {
return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
};
// 2. CommonJS 到 ESM 转换
var __toESM = (mod, isNodeMode, target) => (
target = mod != null ? __create(__getProtoOf(mod)) : {},
__copyProps(
isNodeMode || !mod || !mod.__esModule ?
__defProp(target, "default", { value: mod, enumerable: true }) :
target,
mod
)
);
// 3. 批量导出
var __export = (target, all) => {
for (var name in all)
__defProp(target, name, { get: all[name], enumerable: true });
};
// 4. 动态 require 兼容
var __require = ((x) => typeof require !== "undefined" ? require :
typeof Proxy !== "undefined" ? new Proxy(x, {
get: (a, b) => (typeof require !== "undefined" ? require : a)[b]
}) : x)(function(x) {
if (typeof require !== "undefined")
return require.apply(this, arguments);
throw Error('Dynamic require of "' + x + '" is not supported');
});
// 5. 属性复制
var __copyProps = (to, from, except, desc) => {
if (from && typeof from === "object" || typeof from === "function") {
for (let key of __getOwnPropNames(from))
if (!__hasOwnProp.call(to, key) && key !== except)
__defProp(to, key, {
get: () => from[key],
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
});
}
return to;
};
export {
__require,
__commonJS,
__export,
__toESM
};2.2 __commonJS - 模块包装器详解
这是转换层的核心函数,用于包装 CommonJS 模块:
var __commonJS = (cb, mod) => function __require2() {
return mod || (
0,
cb[__getOwnPropNames(cb)[0]]((mod = { exports: {} }).exports, mod)
),
mod.exports;
};工作原理:
- 延迟执行:返回一个函数,只有被调用时才执行模块代码
- 模块缓存:使用闭包变量
mod缓存执行结果 - exports 模拟:创建
{ exports: {} }对象,模拟 CommonJS 的exports - 执行并返回:执行回调函数
cb,返回mod.exports
使用示例:
// React 的 CommonJS 源码被包装
var require_react_development = __commonJS({
"node_modules/react/cjs/react.development.js"(exports, module) {
"use strict";
// 原始 CommonJS 代码
var ReactVersion = "18.3.1";
// ...
module.exports = {
createElement: createElement,
useState: useState,
// ...
};
}
});
// 第一次调用:执行模块,返回并缓存结果
const react1 = require_react_development(); // 执行模块代码
// 第二次调用:直接返回缓存结果
const react2 = require_react_development(); // 返回 mod.exports
console.log(react1 === react2); // true(同一个对象)2.3 __toESM - 导出格式转换
将 CommonJS 的 module.exports 转换为 ESM 的导出格式:
var __toESM = (mod, isNodeMode, target) => (
target = mod != null ? __create(__getProtoOf(mod)) : {},
__copyProps(
// 关键判断:检测 __esModule 标记
isNodeMode || !mod || !mod.__esModule ?
__defProp(target, "default", { value: mod, enumerable: true }) :
target,
mod
)
);转换逻辑:
情况 1:纯 CommonJS 模块
// 原始代码
module.exports = function React() { /* ... */ };
// 转换后
export default function React() { /* ... */ };情况 2:Babel 转换的 ESM 模块
// 原始代码(已被 Babel 转换)
exports.__esModule = true;
exports.default = React;
exports.useState = useState;
// 转换后(保持原有结构)
export default React;
export { useState };情况 3:混合导出
// 原始代码
module.exports = MyFunction;
module.exports.utils = { /* ... */ };
// 转换后
export default MyFunction;
export const utils = { /* ... */ };2.4 转换模式实战分析
模式 A:纯 ESM 模块(无需转换)
以 clsx 为例,它本身就是 ESM 模块:
// clsx.js
import "./chunk-HM4MQYWN.js"; // 导入工具函数(可能用不到)
// 原始 ESM 代码,保持不变
function r(e) {
var t, f, n = "";
if ("string" == typeof e || "number" == typeof e)
n += e;
else if ("object" == typeof e)
if (Array.isArray(e))
for (t = 0; t < e.length; t++)
e[t] && (f = r(e[t])) && (n && (n += " "), n += f);
else
for (t in e)
e[t] && (n && (n += " "), n += t);
return n;
}
function clsx() {
for (var e, t, f = 0, n = ""; f < arguments.length; )
(e = arguments[f++]) && (t = r(e)) && (n && (n += " "), n += t);
return n;
}
export { clsx, clsx as default };模式 B:CommonJS 模块(需要转换)
以 react 为例:
步骤 1:包装原始代码
// chunk-2KTPFGKL.js
import { __commonJS } from "./chunk-HM4MQYWN.js";
var require_react_development = __commonJS({
"node_modules/react/cjs/react.development.js"(exports, module) {
"use strict";
// 原始的 CommonJS 代码完全保留
var ReactVersion = "18.3.1";
var REACT_ELEMENT_TYPE = Symbol.for("react.element");
// ... 数千行代码 ...
// 最终导出
module.exports = {
createElement: createElement,
useState: useState,
useEffect: useEffect,
// ...
};
}
});
export { require_react_development };步骤 2:创建 ESM 入口
// react.js
import { require_react_development } from "./chunk-2KTPFGKL.js";
import "./chunk-HM4MQYWN.js";
// 调用包装函数,获取 module.exports 并作为 default 导出
export default require_react_development();步骤 3:使用
// 用户代码
import React from 'react'; // Vite 重写为 '/.vite/deps/react.js'
// 实际执行流程:
// 1. 加载 react.js
// 2. 执行 require_react_development()
// 3. 首次执行:运行原始 CommonJS 代码,缓存结果
// 4. 返回 module.exports 作为 default 导出三、转换层的关键技术点
3.1 延迟执行(Lazy Evaluation)
模块代码只在首次被引用时才执行:
// 定义时不执行
var require_react = __commonJS({
"react.js"(exports, module) {
console.log("React 模块执行"); // 这里不会立即执行
module.exports = { /* ... */ };
}
});
// 调用时才执行
import React from './react.js';
// export default require_react(); ← 这里才打印 "React 模块执行"3.2 模块缓存(Module Cache)
通过闭包变量实现单例模式:
var __commonJS = (cb, mod) => function __require2() {
// mod 在闭包中持久化
return mod || (
// 首次调用:执行模块并缓存
(mod = { exports: {} }),
cb(mod.exports, mod),
mod.exports
);
};
// 示例
var require_lodash = __commonJS({ /* ... */ });
const lodash1 = require_lodash(); // 执行模块
const lodash2 = require_lodash(); // 返回缓存
console.log(lodash1 === lodash2); // true3.3 循环依赖处理
通过提前创建对象引用,支持循环依赖:
// a.js
const b = require('./b');
module.exports = { name: 'A', b };
// b.js
const a = require('./a');
module.exports = { name: 'B', a };转换后:
var require_a = __commonJS({
"a.js"(exports, module) {
const b = require_b();
module.exports = { name: 'A', b };
}
});
var require_b = __commonJS({
"b.js"(exports, module) {
const a = require_a(); // ← 此时 a 的 mod 已存在(虽然未执行完)
module.exports = { name: 'B', a };
}
});
// 关键:mod 对象在执行前就创建了
var __commonJS = (cb, mod) => function __require2() {
return mod || (
(mod = { exports: {} }), // ← 先创建引用
cb(mod.exports, mod), // ← 再执行代码
mod.exports
);
};3.4 导出格式兼容
支持多种导出方式的自动转换:
// 方式 1:直接赋值
module.exports = function() {};
// 转换为: export default function() {};
// 方式 2:属性赋值
exports.foo = 1;
exports.bar = 2;
// 转换为: export { foo, bar };
// 方式 3:混合方式
module.exports = MainFunction;
module.exports.helper = helperFunction;
// 转换为:
// export default MainFunction;
// export const helper = helperFunction;
// 方式 4:Babel 转换的 ESM
exports.__esModule = true;
exports.default = Component;
// 转换为: export default Component;四、性能优化策略
4.1 模块合并
优化前:
node_modules/
└── react/
├── index.js
├── cjs/
│ ├── react.development.js (100 KB)
│ └── react.production.min.js (10 KB)
└── jsx-runtime.js每次导入需要多个 HTTP 请求。
优化后:
.vite/deps/
├── react.js (151 B,入口文件)
└── chunk-2KTPFGKL.js (76 KB,合并后的源码)只需 2 个 HTTP 请求,且可以并行加载。
4.2 共享 Chunk
将通用工具函数提取到共享 chunk:
// 多个模块共享同一个工具 chunk
import { __commonJS } from "./chunk-HM4MQYWN.js"; // react 使用
import { __commonJS } from "./chunk-HM4MQYWN.js"; // react-dom 使用
import { __commonJS } from "./chunk-HM4MQYWN.js"; // lodash 使用浏览器只需加载一次 chunk-HM4MQYWN.js,后续直接使用缓存。
4.3 Tree Shaking
esbuild 在打包时会移除未使用的代码:
// lodash-es 源码
export function add(a, b) { return a + b; }
export function subtract(a, b) { return a - b; }
export function multiply(a, b) { return a * b; }
// ... 数百个函数
// 用户代码
import { add } from 'lodash-es';
// 预构建后只包含 add 函数及其依赖4.4 强缓存策略
文件名包含内容 hash,实现持久化缓存:
// package.json 或依赖版本改变时,hash 会变化
chunk-2KTPFGKL.js // React 18.3.1
↓ 升级到 18.4.0
chunk-9XYWQ8ML.js // React 18.4.0(新 hash)HTTP 响应头:
Cache-Control: max-age=31536000, immutable浏览器可以安全地永久缓存这些文件。
五、预构建流程
5.1 触发时机
Vite 在以下情况会执行预构建:
- 首次启动:
node_modules/.vite目录不存在 - 依赖变化:
package.json或yarn.lock发生改变 - 配置变化:
vite.config.js中的optimizeDeps配置改变 - 手动触发:运行
vite --force
5.2 构建流程
1. 扫描入口文件
↓
2. 分析依赖图谱
↓
3. 识别需要预构建的依赖
↓
4. 使用 esbuild 打包
├─ 转换 CommonJS → ESM
├─ 合并碎片模块
└─ 生成 chunk 文件
↓
5. 写入 .vite/deps/
↓
6. 生成 _metadata.json5.3 元数据文件
_metadata.json 记录依赖信息:
{
"hash": "a1b2c3d4",
"browserHash": "e5f6g7h8",
"optimized": {
"react": {
"src": "../../react/index.js",
"file": "react.js",
"fileHash": "i9j0k1l2",
"needsInterop": true
},
"react-dom": {
"src": "../../react-dom/index.js",
"file": "react-dom.js",
"fileHash": "m3n4o5p6",
"needsInterop": true
}
}
}六、实际应用示例
6.1 导入重写
开发时,Vite 会重写模块导入路径:
// 源代码
import React from 'react';
import ReactDOM from 'react-dom/client';
// 浏览器实际加载(经 Vite 重写)
import React from '/.vite/deps/react.js';
import ReactDOM from '/.vite/deps/react-dom_client.js';6.2 开发者工具中的视图
打开浏览器开发者工具 → Network:
Name Size Time
/.vite/deps/chunk-HM4MQYWN.js 1.88 KB 12ms (from disk cache)
/.vite/deps/chunk-2KTPFGKL.js 75.96 KB 45ms (from disk cache)
/.vite/deps/react.js 151 B 8ms (from disk cache)所有依赖都走强缓存,加载速度极快。
6.3 配置优化选项
在 vite.config.js 中可以自定义预构建行为:
export default {
optimizeDeps: {
// 强制包含某些依赖
include: ['lodash-es', 'axios'],
// 排除某些依赖(不预构建)
exclude: ['@my-local-package'],
// esbuild 选项
esbuildOptions: {
target: 'es2020',
supported: {
'top-level-await': true
}
},
// 禁用依赖发现
entries: ['src/main.ts'],
// 强制重新预构建
force: false
}
};七、常见问题
7.1 为什么预构建后文件更大?
原因:
- 包含完整的源码(开发模式)
- 添加了 sourcemap
- 转换层工具函数的开销
生产环境: 使用 Rollup 打包,会进行深度优化和压缩。
7.2 如何清除预构建缓存?
# 方法 1:删除目录
rm -rf node_modules/.vite
# 方法 2:强制重新构建
vite --force
# 方法 3:代码中
import { build } from 'vite';
await build({ force: true });7.3 动态导入是否需要预构建?
静态导入(需要):
import React from 'react'; // ✅ 预构建动态导入(不需要):
const React = await import('react'); // ❌ 不预构建,运行时加载八、总结
Vite 的预构建机制是一个精巧的设计,它通过以下技术实现了 CommonJS 到 ESM 的无缝转换:
核心技术
- 函数包装:
__commonJS包装器模拟 CommonJS 环境 - 延迟执行:只在需要时才执行模块代码
- 模块缓存:通过闭包实现单例模式
- 导出转换:
__toESM智能处理多种导出格式 - 循环依赖:提前创建对象引用避免死锁
性能优势
- 模块合并:减少 HTTP 请求(600+ → 1)
- 强缓存:基于 hash 的持久化缓存
- Tree Shaking:移除未使用代码
- 并行加载:共享 chunk 可并行请求
开发体验
- 零配置:自动检测并转换依赖
- 快速启动:esbuild 极速构建
- 热更新友好:依赖不变时无需重新构建
- 完全兼容:支持 CommonJS、ESM、UMD 等各种格式
这种转换层设计既保证了与 Node.js 生态的兼容性,又充分利用了现代浏览器对 ESM 的原生支持,是 Vite 能够实现"快速冷启动"的关键技术之一。