JavaScript 模块打包是前端工程化的核心环节。它的本质不是把多个 JS 文件简单拼接,而是在构建阶段分析 import、export、require 等语句,建立模块依赖图,再生成一个或多个可在目标环境中执行的脚本。理解这一过程,有助于开发者更好地配置构建工具,也能在排查产物问题时拥有更清晰的思路。

一、模块打包要解决的核心问题
在没有模块化的年代,页面通常依赖多个 <script> 标签加载 JS。脚本之间通过全局变量通信,很容易出现命名冲突。如果文件较多,还必须严格保证加载顺序,例如 jQuery 必须先于业务脚本加载。这样的维护成本会随着项目规模增长快速上升。
CommonJS 在 Node.js 中带来了 require 与 module.exports,让每个文件拥有独立作用域;ES Modules 则进一步标准化了 import 与 export,并具备静态分析能力。浏览器原生支持 ESM 后,虽然可以直接使用模块,但大量小模块会产生过多网络请求,而且旧浏览器兼容、依赖解析、按需加载等问题仍然存在。打包工具正是为了解决这些问题而出现的。
打包器需要完成三件基础工作:从入口文件开始收集依赖,给每个模块分配唯一 ID,再生成一个运行时来模拟模块加载。这个运行时通常包含一个 require 或 __webpack_require__ 函数,负责按 ID 查找模块、缓存导出结果并处理循环依赖。
二、极简打包器的实现过程
要实现模块打包,可以先忽略代码压缩和转译,聚焦依赖图构建与产物生成。以下示例使用 Node.js 读取文件,借助 @babel/parser 解析 AST,从中提取 import 声明。每个模块返回唯一 id、文件路径、依赖数组和原始代码。
const fs = require('fs');
const path = require('path');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
let moduleId = 0;
function createAsset(filename) {
const content = fs.readFileSync(filename, 'utf-8');
const ast = parser.parse(content, { sourceType: 'module' });
const deps = [];
traverse(ast, {
ImportDeclaration(pathInfo) {
deps.push(pathInfo.node.source.value);
}
});
return {
id: moduleId++,
filename: filename,
deps: deps,
code: content
};
}
function createGraph(entry) {
const mainAsset = createAsset(entry);
const queue = [mainAsset];
for (const asset of queue) {
asset.mapping = {};
const dirname = path.dirname(asset.filename);
asset.deps.forEach(function (relativePath) {
const absolutePath = path.join(dirname, relativePath);
const child = createAsset(absolutePath);
asset.mapping[relativePath] = child.id;
queue.push(child);
});
}
return queue;
}
const graph = createGraph('./src/index.js');
console.log(graph);
createAsset 通过 AST 找到所有静态 import,createGraph 递归遍历依赖并将相对路径映射成模块 ID。只要入口文件完整,最终 queue 就是完整的依赖图。然而这些模块源码仍然保留 import 语法,浏览器无法直接执行,所以下一步需要把 ESM 转换成 CommonJS 风格的函数包装。
function bundle(graph) {
let modules = '';
graph.forEach(function (mod) {
modules += mod.id + ': function (require, module, exports) { ' + mod.code + ' },';
});
const result = '(function (modules) { ' +
'var installedModules = {}; ' +
'function require(id) { ' +
'if (installedModules[id]) { return installedModules[id].exports; } ' +
'var module = { exports: {} }; ' +
'modules[id](require, module, module.exports); ' +
'installedModules[id] = module; ' +
'return module.exports; ' +
'} ' +
'return require(0); ' +
'})({' + modules + '})';
return result;
}
const graph = createGraph('./src/index.js');
const output = bundle(graph);
console.log(output);
上面的 bundle 函数把所有模块包装成带 require、module、exports 参数的函数,再放入立即执行函数中。运行时 require 会先检查缓存,没有才执行模块函数,这解决了循环依赖时无限递归的问题。生成产物后,入口模块的 ID 固定为 0,从 require(0) 开始执行。
当然,真正可用的打包器还要对源码进行转换。Webpack 会使用 Babel 或 swc 将 ESM 转换成兼容函数,把 import 替换成对 require 的调用;Rollup 则保留 ESM 结构,在最后阶段合并同一作用域。实现核心逻辑的目的,是让人理解打包并不是黑盒,而是依赖分析、模块包装、运行时加载三部分组合。
三、Webpack 与 Rollup 的实现差异
Webpack 强调对任意资源的模块化处理。它不仅打包 JS,还能通过 loader 处理 CSS、图片、字体,通过 plugin 扩展构建流程。其产物体积通常较大,因为运行时里包含模块注册、缓存、代码分割、动态加载等完整机制。以代码分割为例,Webpack 会把动态 import() 单独拆成 chunk,在运行时通过 JSONP 或 importScripts 异步加载。
Rollup 则更偏向 ESM 的标准打包。它在构建时对模块进行静态分析,能够直接识别未使用的导出并执行 Tree Shaking。由于没有复杂运行时,Rollup 生成的库文件往往更小、结构更清晰。例如一个只导出 add 函数和 unused 函数的模块,如果入口只用 add,Rollup 可以在最终产物中直接删除 unused 函数,甚至合并模块作用域。
二者差异还体现在循环依赖处理上。Webpack 使用模块缓存避免重复执行;Rollup 在打包时如果发现循环依赖,需要开发者通过函数改写等方式处理,否则生成的代码可能出现暂时性死区。因此,应用项目通常选择 Webpack 或 Vite 底层的 Rollup 做构建,而库开发更偏好 Rollup 的原生 ESM 输出。
JavaScript模块打包Webpack模块打包原理修改时间:2026-08-19 19:58:33