Skip to content
webpack 与 Vite 的核心概念:模块、依赖与打包
webpack 与 Vite 对模块的处理方式体现了两种不同的构建思路:构建时全量打包和开发时按需编译。要理解这两种模型,需要先回到模块化的两个基础标准:CommonJS 与 ECMAScript Modules。
模块标准:CommonJS 与 ESM
CommonJS(CJS)是 Node.js 默认模块系统,使用 require() 加载模块,用 module.exports 导出值。模块的解析和加载发生在代码运行时,require 可以出现在条件分支中,路径也可以动态拼接。这一动态特性让 CJS 很灵活,但构建工具很难在运行前静态分析出完整的依赖关系。
js
// math.cjs
const add = (a, b) => a + b;
module.exports = { add };
// app.cjs
const { add } = require('./math.cjs');
console.log(add(1, 2));CJS 模块在首次加载后会被缓存:多次 require 同一个模块时,后续调用都返回缓存中的模块对象,初始化代码只执行一次。([5])这一行为在遇到循环引用时尤为关键。
ESM(ECMAScript Modules)是语言标准,使用 import 和 export 语法。其设计目标之一是静态可分析:import 必须出现在模块顶层,不能放入条件分支或函数内部。这让打包工具能够在不执行代码的情况下,通过解析 import 语句构建出完整的模块依赖图。
js
// math.mjs
export const add = (a, b) => a + b;
// app.mjs
import { add } from './math.mjs';
console.log(add(1, 2));浏览器原生支持 ESM,只需在 <script> 标签上添加 type="module"。Node.js 从 12 版本开始也支持 ESM,但要求文件扩展名为 .mjs 或在 package.json 中配置 "type": "module"。
两种标准在运行时最根本的差别:CJS 模块同步加载,遇到 require 立即加载并执行被依赖的模块;ESM 模块的依赖关系在解析阶段就确定,模块执行被延迟到所有依赖解析完成后,默认行为是异步的。
依赖图:打包工具的全局索引
模块之间的引用关系构成一张有向图:模块 A 引用模块 B 和 C,模块 B 又引用模块 D……这张图就是依赖图。打包工具的后续处理——合并、拆包、Tree Shaking、代码分割——都依赖对依赖图的精确掌握。
webpack 和 Vite 构建依赖图的方式有本质区别。
webpack 在构建阶段,从配置指定的入口文件出发,递归解析所有 import/require 语句,期间通过 loader 将非 JavaScript 资源也转成模块,最终形成一个固定的依赖图。这张图在构建完成后就不再存在。
Vite 的开发模式不提前构建完整的依赖图。它启动一个开发服务器,浏览器请求哪个模块,服务器才去编译哪个模块。浏览器解析返回的 ESM 代码时又会发现新的 import,于是发起新的请求。依赖图是通过浏览器与服务器之间的请求/响应逐步“生长”出来的。本质上,Vite 把依赖图的一部分工作交给了浏览器:浏览器根据 ESM 的 import 语句发送请求,这些请求本身就是依赖信号的天然载体。Vite 服务端只需响应这些请求,按需提供编译后的模块。
webpack 的打包流程:构建时的依赖解析
webpack 的工作可以概括为三个步骤:从入口开始构建依赖图;通过 loader 将各种文件都变成模块;最后把模块图输出为一个或多个 bundle。
入口解析与递归遍历
webpack 从入口模块(例如 src/index.js)开始,解析其源代码,找出其中的 import 或 require 声明。每找到一个依赖,webpack 就在文件系统中定位对应的文件(模块解析),然后把该文件当作新的模块,继续解析它的依赖。这一递归过程结束时,所有从入口可达的模块都会进入依赖图。
js
// src/index.js
import { render } from './app';
import './style.css';
render();处理上述入口时,webpack 会解析到两个依赖:./app.js 和 ./style.css。./app.js 是普通的 JavaScript 模块,webpack 可以原生处理;./style.css 则需要 loader 介入。解析过程不断向下递归,直到所有分支都触及。
loader:将非 JS 资源变为模块
webpack 本身只理解 JavaScript 和 JSON 文件。对于 .css、.png、.ts 等文件,需要通过 loader 将其转换成 JavaScript 模块。([2])一个 loader 本质上是一个接收源文件内容并返回转换后内容的函数。多个 loader 可以组成链,依次处理同一个文件,执行顺序是从右到左(或从下到上)。
例如,style.css 经过 css-loader 和 style-loader 的处理后,会变成一个在运行时把 CSS 注入到 DOM 的 JavaScript 模块。从依赖图的角度看,这个 CSS 文件最终也是一个具有模块 ID 的普通模块,可以被其他模块引用。
打包输出:模块合并为 bundle
依赖图构建完成后,webpack 按照一定规则将所有模块合并成一个或多个输出文件,每个输出文件就是一个 bundle。webpack 为每个模块分配一个 ID,并用一个运行时函数(webpack runtime)包裹所有模块代码。该运行时负责管理模块的加载、缓存和执行顺序。
输出结果是一个自执行函数,传入所有模块的映射表。浏览器加载这个 bundle 后,webpack runtime 根据入口模块 ID 从映射表中取出代码并执行,执行中若遇到依赖,再到映射表中查找对应的模块。整个过程不依赖浏览器的原生模块加载能力。
Vite 的按需服务:浏览器如何驱动模块加载
Vite 的开发模式放弃了“先打包再服务”的思路,转而启动一个基于原生 ESM 的开发服务器。
按需编译未打包的 ESM
浏览器访问 Vite 开发页面时,入口 HTML 包含类似下边的 script 标签:
html
<script type="module" src="/src/main.ts"></script>浏览器识别 type="module" 后,发起对 /src/main.ts 的网络请求。Vite 服务器收到请求,发现这是一个 TypeScript 文件,便使用内置的 esbuild 移除类型注解,转换成浏览器可理解的 JavaScript,直接返回。
返回的代码中,import 语句保持现代浏览器的写法:
js
import { createApp } from '/node_modules/.vite/deps/vue.js?v=hash';
import App from '/src/App.vue';浏览器继续解析这些 import,发起新的请求,Vite 服务器再依次响应。这里不存在提前打包的 bundle——每个模块都是独立文件,由浏览器按需获取。
模块请求与依赖图的运行时构建
这种模式使 Vite 的开发冷启动极快,因为它不需要先构建整个依赖图再打包。Vite 的依赖图更接近一个“请求-响应”日志:服务器记录哪些模块被请求,这些模块之间又通过 import 关系形成怎样的引用路径。
热更新也因此受益。当一个文件变更时,Vite 只需让浏览器重新请求变更的模块以及受影响的上游模块,无需重新计算完整的依赖图或重新打包。
依赖预构建:为什么不是所有 ESM 都一样
既然浏览器原生支持 ESM,为什么 Vite 不把所有 npm 包直接当 ESM 传给浏览器?问题出在 CommonJS 兼容性和网络请求效率两个方面。
将 CommonJS 依赖转换为 ESM
大量 npm 包仍然以 CommonJS 格式发布。在 Node.js 环境下这没问题,但浏览器不认识 require 和 module.exports。如果 Vite 直接把这些包暴露给浏览器,运行时会直接报错。
Vite 在开发服务器启动时,会扫描项目的 node_modules 依赖,使用 esbuild 把 CommonJS 格式的入口文件快速转换成 ESM。转换后的结果缓存在 node_modules/.vite 目录下,当浏览器请求这些依赖时,得到的就是可直接使用的 ESM 格式代码。([8])
js
// 一个 CommonJS 包转换前
module.exports = function() { ... }
// 转换后(大致效果)
export default function() { ... }合并内部模块请求,减少请求瀑布
一个包内部通常由大量小型模块组成(例如 lodash 有数百个文件)。如果每个内部模块都产生一个单独的浏览器请求,页面加载时就会出现大量网络往返,严重拖慢性能——这就是请求瀑布。
Vite 的依赖预构建会将一个包的众多内部模块打包成一个 ESM 文件,同时保留明确的导出入口。这样浏览器只需一次请求就能获取整个包,避免了内部模块间的级联请求。预构建仅针对 node_modules 中的依赖,开发者自己写的代码仍保持按需编译的原始形态。
Bundle 与 Chunk:打包产物的组织形态
在打包工具的输出中,bundle 和 chunk 是经常出现但容易混淆的两个概念。
在 webpack 的语境下,chunk 是打包过程中的中间概念:表示一组通过依赖关系组织在一起的模块。代码拆分就是在依赖图中切出若干个 chunk。然后 webpack 把每个 chunk 包装成一个独立文件——这个文件就是 bundle。([1])可以近似理解为:chunk 是逻辑分组,bundle 是物理输出。
如果项目没有手动进行代码拆分,入口 chunk 和 bundle 通常一一对应。一旦使用了动态 import(),就会产生额外的 chunk,进而生成新的 bundle。
代码拆分能够显著减小首次加载的 JavaScript 体积。典型的做法是动态 import:
js
// modal 逻辑不在首屏加载,点击时才加载
button.addEventListener('click', async () => {
const { showModal } = await import('./modal.js');
showModal();
});webpack 处理这段代码时,会在依赖图中识别 import('./modal.js') 为一个异步拆分点,将它及其独有依赖放入一个单独 chunk。最终输出时,除了入口 bundle,还会有一个额外的 bundle(如 modal.chunk.js),该 bundle 在按钮点击前不会被下载和执行。
Vite 的生产构建使用 Rollup,同样支持动态 import。Rollup 也会将动态导入的模块拆成独立 chunk,生成对应的输出文件。区别在于,Vite 开发模式下根本不拆 bundle——每个模块都是独立文件,动态 import 直接由浏览器原生的异步模块加载完成,无需额外的运行时管理。
在用于部署的构建阶段,webpack 和 Vite(通过 Rollup)都会将所有模块打包成优化后的 bundle。两者产物形态相似:都是已编译、压缩过、拆分为入口 chunk 和异步 chunk 的静态文件集合。不同之处在于,Vite 的 Rollup 构建天然更贴近 ESM 生态,输出体积通常更小;webpack 拥有更长的优化历史,插件生态在控制产物方面更加丰富。([9])
示例:观察两种工具的模块处理行为
webpack 构建产物中的模块包裹
假设入口文件 index.js 只有一行依赖:
js
import { add } from './math';
console.log(add(1, 2));webpack 输出的 bundle 简化后大致如下(development 模式):
js
// webpackBootstrap
(function(modules) {
// 模块缓存与 require 函数定义
function __webpack_require__(moduleId) {
// ...
}
// 启动入口模块
return __webpack_require__("./src/index.js");
})({
"./src/index.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
var _math__WEBPACK_IMPORTED_MODULE_0__ = __webpack_require__("./src/math.js");
console.log(Object(_math__WEBPACK_IMPORTED_MODULE_0__.add)(1, 2));
}),
"./src/math.js": (function(module, __webpack_exports__, __webpack_require__) {
"use strict";
__webpack_exports__.add = (a, b) => a + b;
})
});所有模块都被包裹在一个立即执行函数内,通过 __webpack_require__ 模拟模块加载。浏览器只需执行这一个 bundle,不依赖原生 ESM。
Vite 开发服务器中的原生 ESM 请求
同一个项目在 Vite 开发服务器下,浏览器获取到的 index.js 可能是:
js
import { add } from '/src/math.js';
console.log(add(1, 2));几乎就是源码本身。浏览器接着会自动请求 /src/math.js,Vite 返回:
js
export const add = (a, b) => a + b;网络面板会清晰显示两个独立的 JS 请求。模块间的依赖关系由浏览器原生处理,Vite 只是把源文件原样(或经过简单编译)暴露出去。
两种模型的对比:打包 vs 按需编译
webpack 的模型是“构建时打包”:在文件系统层面提前遍历整个依赖图,loader、plugin 和优化都在这一阶段完成,最后输出一组自包含的 bundle。优点是输出产物体积可控,不受网络请求数影响;代价是冷启动和重建耗时随项目规模增长,依赖图越复杂越慢。
Vite 的开发模型是“按需编译”:服务器不做全量打包,浏览器驱动模块请求,编译只发生在请求的瞬间。冷启动几乎为常量时间,热更新也极快。代价是开发模式下的网络请求数量显著增加,对未经过预构建的深层模块可能产生请求瀑布。Vite 通过依赖预构建来缓解这一问题,将请求数控制在一个较低水平。
这两个模型并非互相替代的关系,它们对应了不同历史时期的问题:在浏览器普遍不支持 ESM 的年代,webpack 的全量打包几乎是唯一选择;当原生 ESM 可用后,Vite 有机会把更多责任交还给浏览器,从而获取启动速度上的优势。
注意点
循环引用与模块执行顺序
两个模块互相引用对方时形成循环引用。CJS 和 ESM 对此的处理行为非常不同。
CommonJS 模块在执行过程中可能遇到一个未执行完的模块引用。当 a.js 执行到 require('./b.js') 时,会转去执行 b.js;如果 b.js 中又 require('./a.js'),此时 a.js 还未执行完,但它的 module.exports 已经存在(可能不完整)。Node.js 会将 a.js 当前时刻的导出对象直接返回给 b.js,从而避免无限递归。这会导致模块可能拿到一个未完全初始化的值。([6])
js
// a.cjs
console.log('a running');
exports.done = false;
const b = require('./b.cjs');
console.log('in a, b.done =', b.done);
exports.done = true;
console.log('a done');
// b.cjs
console.log('b running');
exports.done = false;
const a = require('./a.cjs');
console.log('in b, a.done =', a.done);
exports.done = true;
console.log('b done');执行 node a.cjs 会输出:
a running
b running
in b, a.done = false
b done
in a, b.done = true
a done可以看到,b.js 里拿到的 a.done 是 false,因为那时 a.js 还没有执行到对 exports.done 的第二次赋值。
ESM 对循环引用的处理更严格。它在解析阶段就会检测循环依赖,但不立即执行模块,而是先建立所有模块的映射,再按依赖顺序“链接”导入/导出绑定。如果某个模块导出了一个变量,其他模块 import 的是这个变量的实时绑定(live binding)。若循环依赖导致一个模块使用了另一个模块尚未初始化的导出,访问时会得到 undefined,某些情况下还会直接报错。
ESM 与 CommonJS 的互操作边界
绝大多数打包工具都允许在一个项目中混用 ESM 和 CommonJS 语法,但互操作存在一些临界情况,尤其在默认导出和命名导出上。
Node.js 要求 ESM 文件不能直接 require,CJS 文件也不能使用 import 语句(在 .cjs 或未声明 type: module 的文件中),这是一种纯净边界。而在 webpack 和 Vite/Rollup 中,由于将所有模块统一处理,默认提供了互操作层:CJS 模块的 module.exports 被映射为 ESM 的 default 导出;反过来,ESM 模块的 export default 通常映射为 CJS 的整模块导出对象。
当用 import 引入一个 CJS 模块时,如果该模块没有标记 __esModule,打包工具可能将其所有导出包裹为 default,导致解构导入失败。因此像 lodash 这类 CJS 库在 ESM 环境中使用时,常用完整写法:
js
import _ from 'lodash'; // 正确,获取 default 导出
// 而不是 import { debounce } from 'lodash';许多现代工具链会通过分析自动处理这类兼容,问题主要出现在更老旧的包上。
