Skip to content

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)格式。

javascript
// 转换前(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):

javascript
// 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 模块:

javascript
var __commonJS = (cb, mod) => function __require2() {
  return mod || (
    0, 
    cb[__getOwnPropNames(cb)[0]]((mod = { exports: {} }).exports, mod)
  ), 
  mod.exports;
};

工作原理:

  1. 延迟执行:返回一个函数,只有被调用时才执行模块代码
  2. 模块缓存:使用闭包变量 mod 缓存执行结果
  3. exports 模拟:创建 { exports: {} } 对象,模拟 CommonJS 的 exports
  4. 执行并返回:执行回调函数 cb,返回 mod.exports

使用示例:

javascript
// 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 的导出格式:

javascript
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 模块

javascript
// 原始代码
module.exports = function React() { /* ... */ };

// 转换后
export default function React() { /* ... */ };

情况 2:Babel 转换的 ESM 模块

javascript
// 原始代码(已被 Babel 转换)
exports.__esModule = true;
exports.default = React;
exports.useState = useState;

// 转换后(保持原有结构)
export default React;
export { useState };

情况 3:混合导出

javascript
// 原始代码
module.exports = MyFunction;
module.exports.utils = { /* ... */ };

// 转换后
export default MyFunction;
export const utils = { /* ... */ };

2.4 转换模式实战分析

模式 A:纯 ESM 模块(无需转换)

clsx 为例,它本身就是 ESM 模块:

javascript
// 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:包装原始代码

javascript
// 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 入口

javascript
// react.js
import { require_react_development } from "./chunk-2KTPFGKL.js";
import "./chunk-HM4MQYWN.js";

// 调用包装函数,获取 module.exports 并作为 default 导出
export default require_react_development();

步骤 3:使用

javascript
// 用户代码
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)

模块代码只在首次被引用时才执行:

javascript
// 定义时不执行
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)

通过闭包变量实现单例模式:

javascript
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); // true

3.3 循环依赖处理

通过提前创建对象引用,支持循环依赖:

javascript
// a.js
const b = require('./b');
module.exports = { name: 'A', b };

// b.js
const a = require('./a');
module.exports = { name: 'B', a };

转换后:

javascript
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 导出格式兼容

支持多种导出方式的自动转换:

javascript
// 方式 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:

javascript
// 多个模块共享同一个工具 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 在打包时会移除未使用的代码:

javascript
// 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,实现持久化缓存:

javascript
// package.json 或依赖版本改变时,hash 会变化
chunk-2KTPFGKL.js  // React 18.3.1
↓ 升级到 18.4.0
chunk-9XYWQ8ML.js  // React 18.4.0(新 hash)

HTTP 响应头:

http
Cache-Control: max-age=31536000, immutable

浏览器可以安全地永久缓存这些文件。

五、预构建流程

5.1 触发时机

Vite 在以下情况会执行预构建:

  1. 首次启动node_modules/.vite 目录不存在
  2. 依赖变化package.jsonyarn.lock 发生改变
  3. 配置变化vite.config.js 中的 optimizeDeps 配置改变
  4. 手动触发:运行 vite --force

5.2 构建流程

1. 扫描入口文件

2. 分析依赖图谱

3. 识别需要预构建的依赖

4. 使用 esbuild 打包
   ├─ 转换 CommonJS → ESM
   ├─ 合并碎片模块
   └─ 生成 chunk 文件

5. 写入 .vite/deps/

6. 生成 _metadata.json

5.3 元数据文件

_metadata.json 记录依赖信息:

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 会重写模块导入路径:

javascript
// 源代码
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 中可以自定义预构建行为:

javascript
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 如何清除预构建缓存?

bash
# 方法 1:删除目录
rm -rf node_modules/.vite

# 方法 2:强制重新构建
vite --force

# 方法 3:代码中
import { build } from 'vite';
await build({ force: true });

7.3 动态导入是否需要预构建?

静态导入(需要):

javascript
import React from 'react'; // ✅ 预构建

动态导入(不需要):

javascript
const React = await import('react'); // ❌ 不预构建,运行时加载

八、总结

Vite 的预构建机制是一个精巧的设计,它通过以下技术实现了 CommonJS 到 ESM 的无缝转换:

核心技术

  1. 函数包装__commonJS 包装器模拟 CommonJS 环境
  2. 延迟执行:只在需要时才执行模块代码
  3. 模块缓存:通过闭包实现单例模式
  4. 导出转换__toESM 智能处理多种导出格式
  5. 循环依赖:提前创建对象引用避免死锁

性能优势

  • 模块合并:减少 HTTP 请求(600+ → 1)
  • 强缓存:基于 hash 的持久化缓存
  • Tree Shaking:移除未使用代码
  • 并行加载:共享 chunk 可并行请求

开发体验

  • 零配置:自动检测并转换依赖
  • 快速启动:esbuild 极速构建
  • 热更新友好:依赖不变时无需重新构建
  • 完全兼容:支持 CommonJS、ESM、UMD 等各种格式

这种转换层设计既保证了与 Node.js 生态的兼容性,又充分利用了现代浏览器对 ESM 的原生支持,是 Vite 能够实现"快速冷启动"的关键技术之一。


参考资料

Released under the MIT License.