学完这篇,你能理清 JavaScript 模块化从「手写 IIFE 防全局污染」到「语言标准 ESM」的完整演进脉络,知道每种方案解决什么问题、为什么被淘汰,以及在老项目和新项目里该怎么选。
模块化要解决的问题始终是一个:怎么把代码切成互不干扰的块,又能让它们互相引用。JavaScript 诞生时连 import 都没有,所以这条路是社区一步步趟出来的。
第一步:IIFE —— 没有模块系统时手搓一个
2000 年代中期的做法:用立即执行函数造一个私有作用域,只把要暴露的东西 return 出去。
var Counter = (function () {
var count = 0; // 私有
function inc() { count++; }
return {
add: function () { inc(); return count; }
};
})();
Counter.add(); // 1
console.log(window.count); // undefined,没污染全局
配合命名空间(var App = {}; App.utils = {...})就是当年的「模块化」。
注意:IIFE 前面那行如果没写分号,而下一行以 ( 或 [ 开头,前面语句会被当成函数调用。老代码里 ;(function(){})() 开头那个分号就是防这个坑。
缺点很明显:依赖关系全靠人肉顺序排 <script> 标签,加载顺序错了就 undefined。
第二步:CommonJS —— 把模块带进 Node
2009 年 CommonJS 规范出现,Node.js 直接采用,成为服务端的事实标准。
// math.js
const fs = require('fs');
module.exports = { add: (a, b) => a + b };
// main.js
const { add } = require('./math');
核心机制:require 是同步执行的,读文件、包一层函数 (exports, require, module, __filename, __dirname) 然后立即运行,结果塞进 require.cache,所以同一个模块只会执行一次。
注意:同步加载在服务器本地磁盘上没问题,浏览器里要走网络,同步 require 会卡死页面——这就是 CommonJS 没能在浏览器普及的根本原因。另外 module.exports 是值拷贝,导出后原模块内部变量再变,外面拿到的不会跟着变。
第三步:AMD —— 为浏览器而生的异步加载
RequireJS 提出的 Asynchronous Module Definition,依赖前置、异步加载,浏览器友好。
// 定义
define(['jquery', './util'], function ($, util) {
return { init: function () { /* ... */ } };
});
// 使用
require(['./main'], function (main) { main.init(); });
回调保证了依赖先加载完再执行,<script> 动态插入,不阻塞渲染。
注意:AMD 是「依赖前置 + 提前执行」,模块列出什么就先加载什么,哪怕某个分支根本走不到。文件碎、请求多也是它被诟病的点,后来靠打包工具才缓解。
第四步:CMD —— 就近依赖的国产方案
阿里的 SeaJS(玉伯)提出 CMD 规范,写法更贴近 CommonJS 的直觉。
define(function (require, exports, module) {
var $ = require('jquery'); // 用到才写,位置随意
if (location.hash) {
var hash = require('./hash'); // 分支里的依赖
}
exports.init = function () {};
});
和 AMD 的关键差别:依赖就近书写、延迟执行。SeaJS 用正则扫描 require(...) 字符串预加载,真正执行到那行才去取模块。
注意:靠 Function.prototype.toString() 正则扫描依赖,遇到变量拼接的 require(path) 就扫不出来,得手动 require.async。SeaJS 后来停止维护,新项目不要再用。
顺带提一句 UMD:它是一段「IIFE 包装 + 环境嗅探」的样板代码,同时兼容 AMD、CommonJS 和全局变量,是那个年代的通用兼容层,如今逐渐被 ESM 取代。
第五步:ESM —— 语言层面的最终答案
ES2015 把 import / export 写进了语言标准。
// math.js
export const add = (a, b) => a + b;
export default function () {}
// main.js
import fn, { add } from './math.js';
浏览器里这样用:
<script type="module" src="./main.js"></script>
type="module" 带来几个隐含行为:自动 defer、模块只执行一次、顶层 this 是 undefined、自动严格模式、跨域走 CORS。
Node.js 里用 .mjs 后缀,或在 package.json 里加 "type": "module":
{ "type": "module" }
注意:直接双击 HTML 用 file:// 打开,ESM 会报 CORS 错误,必须起个 HTTP 服务(npx serve 或 python -m http.server)。
ESM 和 CommonJS 的本质差异:
- 静态结构:
import 必须写在顶层,打包工具才能在编译期分析出依赖树做 tree-shaking;CommonJS 的 require 是运行时函数调用,做不到。
- 值引用:ESM 导出的是活绑定,模块内部变量变了外面能读到新值;CommonJS 是导出那一刻的值拷贝。
- 循环依赖:CommonJS 拿到的是没执行完的
exports 空壳;ESM 靠声明提升,函数声明通常还能正常用。
第六步:互操作时别踩这些坑
在 CommonJS 文件里 require 一个 ESM 包,Node 早期会直接抛 ERR_REQUIRE_ESM,现在的高版本才逐步支持,稳妥做法是用动态 import()(返回 Promise)。
反过来,import 一个 CommonJS 模块时,module.exports 会挂在 default 上,具名导入靠 Node 的静态分析猜,猜不出来就报错——写成 import pkg from 'cjs-pkg' 最保险。
现代工程的常规做法:源码统一 ESM,交给 Vite / Rollup / esbuild 打包成目标环境能跑的产物,配置文件用 .mjs 或 .cjs 明确指定类型。
小结
- IIFE 解决「全局污染」,没解决「依赖管理」,靠
<script> 顺序硬撑。
- CommonJS 同步
require,为 Node 而生,浏览器不适用,导出是值拷贝。
- AMD 依赖前置 + 异步加载,适合浏览器;CMD 就近依赖 + 延迟执行,SeaJS 已停维护。
- ESM 是语言标准:静态可分析、活绑定、支持 tree-shaking,浏览器加
type="module",Node 用 "type": "module" 或 .mjs。
- 新项目一律 ESM;维护老项目时先看清它是
require 还是 import,互操作优先用 import() 动态加载。