问题场景
"为什么 Node 里用 require,前端用 import?""module.exports 和 export default 混用为什么报错?""import 明明写在文件中间,为什么说它是静态的?""我明明没用到某个导出,为什么 bundle 里还在?"------这些都是模块生态的"玄学"。
模块化(Module System)+ 打包器(Bundler)是现代前端工程的基石。不理解它们,你写的 import 就是"能用但说不清",遇到双模块混用、循环依赖、tree-shaking 失效就抓瞎。本文把它们讲透,并给出工程化的实际取舍。
原理深入
1. 两种核心模块规范:CJS 与 ESM
CJS(CommonJS)------ Node 的传统标准,同步、运行时:
js
// a.js (CJS)
const x = require('./b'); // 同步 require
module.exports = { x }; // 导出
ESM(ES Module)------ 语言标准,异步、静态:
js
// a.mjs (ESM)
import x from './b.mjs'; // 静态 import(必须在顶层)
export default x; // 默认导出
export const y = 1; // 命名导出
核心区别速查:
| CJS | ESM | |
|---|---|---|
| 加载时机 | 运行时(同步) | 编译期(静态) |
| 依赖写法 | require() 运行时解析 |
import 静态顶层 |
| 导出写法 | module.exports |
export default / export |
| tree-shaking | 难(运行时确定) | 天然支持(静态) |
| live binding | 无(值快照) | 有(实时绑定) |
| 使用场景 | Node 脚本、老库 | 浏览器、现代 Node |
关键差异点详解:
差异 1:静态 vs 动态。 ESM 的 import/export 必须写在顶层(不能用 if/循环包裹),因为引擎在编译阶段就要确定依赖图 ;CJS 的 require 是函数调用,运行时才执行,位置随意。
js
// ❌ ESM 不能条件导入
if (cond) import x from './x'; // 语法错误
// ✅ CJS 可以(但要小心)
if (cond) { const x = require('./x'); }
差异 2:live binding vs 快照。 ESM 是"实时绑定"------导入方看到的是最新的导出值 ;CJS 是"值快照"------require 后改动不影响已有引用。
js
// ESM:live binding
// lib.mjs
export let count = 0;
setTimeout(() => count = 99); // 0.1 秒后
// app.mjs
import { count } from './lib.mjs';
console.log(count); // 0(第一次)
setTimeout(() => console.log(count), 200); // 99(live,看到最新)
2. 打包器到底在干什么(Vite/Webpack/Rollup)
浏览器原生不认识 require,import 也要经转换/合并。打包器负责:
- 解析依赖图 :从入口文件出发,把
import/require拉成一张依赖树。 - 转换语法:ESM/TS/JSX → 浏览器能跑的 JS(babel/esbuild/rollup)。
- 打包合并 :把所有模块拷贝进一个(或几个)bundle,用"模块注册表"模拟模块系统。
- 优化:tree-shaking(删没用导出)、代码分割(code-split)、压缩、hash。
text
入口.js ─→ 依赖图 ─→ 转换 → 打包 → dist/bundle.js
├ import a ─┐
├ import b ─┼→ 全部合并(模块注册表)
└ import c ─┘
核心概念:模块注册表(模拟 require/import)
打包后的 bundle 里,每个模块被包进一个函数,靠注册表互相引用:
js
// 打包后的简化形态
const modules = {
'./a': (module, exports, require) => { /* a 的代码 */ },
'./b': (module, exports, require) => { /* b 的代码 */ },
};
function require(id) { return modules[id](); } // 模拟加载
3. tree-shaking:ESM 之所以能删代码
因为 ESM 静态,打包器能精确知道"这个模块只用了导出 A,没用 B",于是 B 被标记为 dead code 删掉:
js
// utils.js
export function used() { return 1; }
export function unused() { return new Array(1e6); } // 没人用
// app.js
import { used } from './utils';
console.log(used());
// 打包后:unused 被 tree-shake 掉,bundle 更小
让 tree-shaking 生效的条件:
- 用 ESM(
import/export),别用module.exports(无法静态分析)。 - 别写有副作用 的模块(顶层
console.log、改全局、调用 API)。 - 别用会绕过静态分析的解构方式(有些写法会阻止摇树)。
- 注意打包器配置(
sideEffects: false声明无副作用模块)。
4. 循环依赖:CJS vs ESM 差异
- CJS :
require时若模块还在加载中(循环),拿到的是未完成的 module.exports 快照 ,可能得到undefined。 - ESM :live binding,循环依赖时用的是最终值,但要小心"暂时性死区"(TDZ)------导入未初始化的变量会报错。
js
// CJS 循环依赖经典坑
// a.js
const b = require('./b');
console.log(b); // 可能 undefined(b 还没完全加载)
// 缓解:把公共依赖抽取成独立模块,避免环;或在 require 后访问属性而非顶层解构
console.log(b.someProp); // 若 b 模块后面才赋值 someProp,这里仍可能 undefined
根治循环依赖:把公共依赖抽取成独立模块,消除环;或在依赖方向设计上避免双向引用。
5. 工程化实践:Vite / 双模块混用
- 现代项目一般用 Vite(ESM 优先)+ 构建时降级。
- 混用报错:某库只支持 CJS(
module.exports),你在 ESM 项目里import它,打包器会做 interop(转换),但有些边界(default导入拿不到)会踩坑。 .mjs(ESM)/.cjs(CJS)显式声明文件类型,避免歧义。- 用
package.json的type: "module"标识项目为 ESM。
实战代码:手写一个极简打包器(理解核心)
js
// 极简 bundler:把 ESM import 的模块合并成一个可运行的 bundle
const fs = require('fs');
const path = require('path');
function getCode(file) { return fs.readFileSync(file, 'utf8'); }
function bundle(entry) {
const modules = {}; // id -> 包装函数
const queue = [entry];
// 1. 广度优先收集依赖图
while (queue.length) {
const id = queue.shift();
const code = getCode(id);
const deps = [...code.matchAll(/import .* from ['"](.+?)['"]/g)].map((m) => m[1]);
modules[id] = { code, deps };
deps.forEach((d) => queue.push(path.resolve(path.dirname(id), d)));
}
// 2. 生成模块注册表字符串(示意:把 import 转成 require 风格)
const entries = Object.entries(modules).map(
([id, m]) =>
`"${id}": function(module, exports, require) { ${m.code.replace(
/import .* from ['"](.+?)['"]/g,
(_, p) => `const dep = require('${path.resolve(path.dirname(id), p)}')`
)} }`
);
return `const modules = { ${entries.join(',')} }; console.log('bundle 生成');`;
}
console.log(bundle(path.resolve('entry.js')));
这是极简示意,真实打包器(Rollup/esbuild)会做 AST 解析、tree-shaking、作用域分析,但核心"收集依赖图 + 合并 + 模拟模块系统"就是这个思路。
要点总结
- CJS :
require+module.exports,同步运行时;难静态分析、值快照。 - ESM :
import+export,异步静态、语言标准;天然 tree-shaking、live binding。 - 打包器:收集依赖图 → 转换 → 合并 → 优化(tree-shake/code-split/压缩)。
- tree-shaking 依赖静态 ESM :用
import/export,避免副作用模块,配sideEffects: false。 - 循环依赖:CJS 拿快照可能 undefined;ESM live binding 较稳但注意 TDZ;最好抽公共依赖消除环。
- 双模块混用报错:多半是库只支持 CJS 或文件类型(
.mjs/.cjs)歧义,用type: "module"规避。 - 排查依赖:
npm ls查依赖树、bundle-buddy/source-map-explorer看 bundle 内容、tree-shaking 不生效先查副作用。
一句话:CJS 运行时同步、ESM 编译期静态------打包器把 ESM 拍平成浏览器能跑的模块注册表,tree-shaking 就是吃到"静态"红利。搞懂这套,工程化底层不再神秘。