模块化演进史:从 IIFE / CommonJS / AMD 到 ES Modules(含 Tree Shaking 原理)
目标:把“为什么会这样设计、现在应该怎么写、旧项目如何迁移”一次说清。你将获得一套可操作的迁移清单与代码模板。
0. 大图先立:四代模块化的一句话印象
时代 | 代表 | 运行环境 | 特征 | 痛点 |
---|---|---|---|---|
IIFE | 立即执行函数 | 浏览器 | 用函数形成私有作用域,避免全局变量冲突 | 无依赖管理、可维护性差 |
CommonJS | require /module.exports | Node | 同步加载、按执行时解析 | 浏览器不可用、难以摇树(动态依赖) |
AMD | define /require | 浏览器 | 异步加载、声明依赖 | 模板冗长、生态式微 |
ESM | import /export | 浏览器 & Node | 静态依赖图、原生标准、支持 Tree Shaking | 与 CJS 互操作有边界,需要理解打包器行为 |
1. IIFE(Immediately Invoked Function Expression)
1.1 核心写法
<script>
(function(global){const _cache = {};function add(a,b){ return a+b; }global.math = { add };
})(window);
</script><script>
console.log(window.math.add(1,2));
</script>
1.2 要点与局限
通过函数立即执行 + 形参承载“模块导出”(挂
window
)。没有标准的依赖声明与模块边界,资源顺序强耦合、可维护性差。
现在仅用于极少量“无需构建的小脚本”或“兼容极老旧环境”。
2. CommonJS(CJS):Node 的事实标准
2.1 写法与特性
// add.js
function add(a, b){ return a+b; }
module.exports = add;// index.js
const add = require('./add'); // 同步解析与执行
console.log(add(1,2));
同步加载:面向服务器端,磁盘读取几乎“常量时间”,同步合理。
依赖是运行时决定:
require(someVar)
可能是动态值。导出可变:
module.exports = obj
或exports.foo = …
。
2.2 痛点
浏览器不支持,需要打包器把
require
“编译掉”。因为依赖是动态可变,打包器很难做强确定的静态分析,Tree Shaking 效果很差(通常按整包引入)。
3. AMD:浏览器时代的异步模块
3.1 写法
// add.js
define(function(){return function add(a,b){ return a+b; };
});// index.js
require(['./add'], function(add){console.log(add(1,2));
});
适应浏览器网络环境,异步拉取依赖。
模块定义 冗长、社区转向 ESM 后逐渐式微(历史项目仍有存量)。
4. UMD:一次发布同时兼容 CJS/AMD/全局变量
4.1 模板(简化版)
(function (root, factory) {if (typeof define === 'function' && define.amd) {define([], factory); // AMD} else if (typeof module === 'object' && module.exports) {module.exports = factory(); // CJS} else {root.MyLib = factory(); // 浏览器全局}
}(this, function () {function add(a,b){ return a+b; }return { add };
}));
产物形态,不是源代码写法。
现在更推荐直接产出 ESM + CJS 双包,UMD 主要为旧环境兜底。
5. ES Modules(ESM):现在与未来
5.1 语法与特性
// math.js
export function add(a,b){ return a+b; }
export const PI = 3.14159;
export default function square(x){ return x*x; }// index.mjs / 浏览器 <script type="module">
import square, { add, PI } from './math.js';
console.log(add(1,2), square(3), PI);
静态依赖图:
import
/export
必须在顶层,编译阶段即可解析依赖。只读绑定:导出的是绑定而非值拷贝,有助于优化器与工具链推理。
原生支持:现代浏览器
<script type="module">
、Node 也支持(.mjs
或package.json: { "type": "module" }
)。
5.2 浏览器直用
<script type="module">import { add } from './math.js';console.log(add(1,2));
</script>
模块脚本天然
defer
,按依赖并行加载;跨域需要 CORS 头。支持
import()
动态分包加载(代码分割)。
5.3 Node 下的 ESM
两种开启方式:
1)文件后缀.mjs
;
2)package.json
设"type": "module"
。CJS ↔ ESM 互操作要点(常见坑):
在 ESM 中不能直接
require
,需要:import { createRequire } from 'node:module'; const require = createRequire(import.meta.url); const pkg = require('./pkg.json');
ESM
import
CJS:大多数情况下 CJS 的module.exports
会变成 ESM 的default
。// CJS: module.exports = fn import fn from './legacy.cjs'; // OK
CJS
require
ESM:不被允许(动态);应改写为import()
或拆分产物。
5.4 package.json 现代导出
{"name": "lib","type": "module","exports": {".": {"import": "./dist/index.esm.js", // ESM"require": "./dist/index.cjs" // CJS},"./feature": {"import": "./dist/feature.esm.js","require": "./dist/feature.cjs"}},"types": "./dist/index.d.ts"
}
使用
exports
显式声明入口,避免深路径耦合。现代打包器与 Node 都优先解析
exports
字段。
6. Tree Shaking 原理:为什么 ESM 能“摇掉”没用的代码?
6.1 静态分析 + DCE(Dead Code Elimination)
前提:打包器在构建阶段就能准确知道“导入了哪些符号”“哪些导出未被使用”。
ESM 静态:
import
/export
语义固定、顶层出现 → 可进行精确的依赖图构建。CJS 动态:
require(expr)
与对module.exports
的任意赋值让静态分析困难 → 很难做到细粒度摇树。
6.2 影响摇树的关键因素
副作用(Side Effects)
例如模块顶层执行
console.log
、修改全局、给原型打补丁,这些不能被删除。包的作者需在
package.json
声明:
{ "sideEffects": false }
或:
{ "sideEffects": ["*.css", "polyfills/*.js"] }
表示哪些文件保留副作用、其他可安全摇掉。
纯函数与可推导性
访问动态属性、
with
、eval
等都会降低优化空间。getters/setters 的副作用也会阻碍摇树。
最小可分割单元
导出应按功能颗粒拆分,避免“大对象聚合导出”导致整包被保留:
// 反例:坏的聚合导出 export default { add, sub, mul, div }; // 正例:按需导出 export { add, sub, mul, div };
6.3 打包器如何做
Rollup:以 ESM 为一等公民,Tree Shaking 表现最好。
Webpack:
mode=production
+optimization.usedExports
+Terser
DCE;结合sideEffects
更稳。esbuild / Vite:以 ESM 为核心,默认开启摇树,速度快。
7. 迁移与落地:从 CJS/AMD 到 ESM 的路线图
7.1 旧项目迁移清单(渐进式)
源代码层:优先把内部模块改为 ESM(
export
/import
),必要时保留 CJS 入口做过渡。打包配置:Rollup/Vite 优先;Webpack 需开启:
// webpack.config.js module.exports = {mode: 'production',optimization: {usedExports: true, // 标记可摇树符号concatenateModules: true, // 作用域提升sideEffects: true // 配合 package.json} }
package.json:添加
{"type": "module","sideEffects": false,"exports": {".": { "import": "./dist/index.esm.js", "require": "./dist/index.cjs" }} }
互操作:
ESM 中引入 CJS → 默认拿
default
;CJS 引入 ESM → 用
import()
或拆分产物。
单测与工具链:Jest 28+、Vitest 对 ESM 友好;Babel/TS 配置
module: esnext
,交给打包器处理模块。
7.2 包作者的产物策略(库/组件库)
同时产出 ESM + CJS(双入口),并配
types
。按需导出,避免默认导出聚合对象。
声明
sideEffects
,并把有副作用的文件列入白名单。提供
exports
子路径,鼓励精确导入:import { Button } from 'ui-kit/feature'; // 代替 'ui-kit'
8. 代码示例:一眼看懂“可摇 vs 难摇”
8.1 可摇(ESM + 纯导出)
// math.js
export function add(a,b){ return a+b; }
export function sub(a,b){ return a-b; }
// index.js
import { add } from './math.js'; // 只用 add
console.log(add(1,2)); // 构建后 sub 被剔除
8.2 难摇(聚合默认导出 + 动态属性)
// anti-math.js
const api = { add(a,b){return a+b;}, sub(a,b){return a-b;} };
export default api;
// 使用方:
import math from './anti-math.js';
console.log(math.add(1,2));
// 打包器往往无法证明“访问不到 sub”,容易整包保留
8.3 顶层副作用阻碍摇树
// side.js
console.log('init'); // 顶层副作用
export const x = 1;
// 即使不使用 x,包含本文件的分包也很难被删除
9. 实战 Tips & 避坑清单
浏览器原生 ESM
注意 CORS;跨域需正确的
Access-Control-Allow-Origin
。预加载:
<link rel="modulepreload" href="/entry.js">
改善瀑布。
Node 与 ESM 的路径解析
ESM 必须写显式扩展名(
.js/.mjs/.cjs
),或通过exports
定义;__dirname/__filename
在 ESM 不存在,用:import { fileURLToPath } from 'node:url'; import { dirname } from 'node:path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename);
TypeScript
tsconfig.json
建议:{"module": "esnext","moduleResolution": "bundler", // 或 node16"target": "ES2020" }
交给打包器处理模块格式;库产物用
tsup/rollup
产 ESM+CJS。
动态
import()
的价值代码分割、懒加载、条件加载(例如路由级分包、可视区内组件)。
支持
try/catch
,错误处理友好。
面向库的稳定性
不要在顶层做环境探测带副作用(如把东西挂到
window
)。尽量无副作用:更易摇树、缓存可预测、测试更简单。
10. 一页速查:现在新项目怎么选?
浏览器/Web 前端:ESM 源码 + Vite/Rollup 构建;按需
import()
;严格声明sideEffects
。Node 服务/工具:源代码用 ESM;包产物提供 ESM + CJS;
exports
定义好入口。类库发布:
入口:
exports.import
→ ESM,exports.require
→ CJS;类型:提供
types
;Tree Shaking:纯导出 +
sideEffects:false
;Demo 与文档:示例全部用 ESM。
写在最后:
IIFE/AMD 是历史的过渡;CJS 在 Node 依然大量存在,但ESM 才是统一的未来。
Tree Shaking 本质依赖“静态依赖图 + 无副作用”,语义正确比工具更重要。
把“ESM 源码 + 双入口产物 + sideEffects 声明 + exports 子路径”变成你的默认工程模板,团队就自然拥有可维护、可优化的现代模块化基础设施。