学完这篇,你能把 JavaScript 模块化的四次关键转折串成一条线,看懂老项目里的 IIFE/UMD 写法,也知道新项目为什么该直接上 ESM。
第一步:先搞清楚"没有模块"是什么样
早期 JS 只有全局作用域和函数作用域,所有变量都往 `window` 上挂:
var count = 0;
function add() { count++; }
两个文件都叫 `count`,后加载的覆盖前一个,还查不出是谁改的。这是所有模块方案的起点——怎么造一个私有作用域。
第二步:IIFE——用函数造私有空间
立即执行函数表达式(Immediately Invoked Function Expression)的核心就一句话:函数内部是独立作用域。
var Counter = (function () {
var count = 0; // 外部拿不到
return {
add: function () { count++; },
get: function () { return count; }
};
})();
Counter.add();
Counter.get(); // 1
Counter.count; // undefined
只把想暴露的挂到返回值上,其余全部封死。升级版是命名空间 + 依赖注入,把依赖从参数传进去,而不是直接用全局的 jQuery:
var MyApp = MyApp || {};
MyApp.utils = (function ($) {
return { empty: function (el) { return $(el).length === 0; } };
})(jQuery);
注意:IIFE 解决的是"变量不污染",没解决"依赖关系"。哪个文件必须先加载,全靠 HTML 里 `<script>` 的排列顺序,上线压缩合并时顺序一乱就报 undefined。
第三步:CommonJS——服务端先跑通
Node.js 2009 年选了 CommonJS,它是同步加载,因为服务端读文件是本地 IO,快。
// math.js
const add = (a, b) => a + b;
module.exports = { add };
// main.js
const { add } = require('./math');
`require` 可以在任意位置写(运行时解析),导出是值拷贝——`module.exports` 赋值那一刻的快照。
注意两个经典坑:一是 `exports = { add }` 会断开与 `module.exports` 的引用链,必须写 `module.exports = ...` 或 `exports.add = ...`;二是拷贝特性导致 `exports.a = 1` 之后在别处改 `a` 的值,已引入方看不到变化。
第四步:AMD 与 UMD——浏览器端的绕路
浏览器加载 `<script>` 是异步的,同步的 `require` 会卡死页面。于是有了 AMD(异步模块定义),代表实现是 RequireJS:
define(['jquery'], function ($) {
return { init: function () { /* ... */ } };
});
依赖前置声明,加载完再执行回调。写法啰嗦,但当年是浏览器端唯一成熟方案。
再往后是 UMD,一段胶水代码同时兼容 AMD、CommonJS 和全局变量,一个文件三种环境都能跑:
(function (root, factory) {
if (typeof define === 'function' && define.amd) {
define(['jquery'], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('jquery'));
} else {
root.MyLib = factory(root.jQuery);
}
})(typeof self !== 'undefined' ? self : this, function ($) {
return { /* 模块内容 */ };
});
注意:UMD 解决了兼容,没解决"静态分析"。打包工具无法在编译期确定依赖图,tree-shaking(无用代码删除)基本做不了。
第五步:ESM——把模块写进语言规范
ES2015 的 `import`/`export` 是语法层的模块,不是运行时函数。
// math.js
export const add = (a, b) => a + b;
export default function () { /* ... */ }
// main.js
import { add } from './math.js';
import('./heavy.js').then(m => m.run()); // 动态导入,按需加载
四个必须记住的差异:
- 静态结构:`import` 只能写在顶层,依赖图在解析阶段就能确定,才能 tree-shaking。
- Live binding:导出的是引用不是拷贝,导出方改了值,引入方同步看到。
- 严格模式 + 顶层 this 为 undefined,不用再写 `'use strict'`。
- 顶层 await 可用(Node 14.8+ / 现代浏览器)。
第六步:落地配置,把 ESM 用起来
浏览器端:
<script type="module" src="./main.js"></script>
Node 端二选一:`package.json` 加 `"type": "module"`,或者把文件后缀改成 `.mjs`。要保留 CommonJS 就用 `.cjs`。
注意三个高频报错:① 浏览器里 import 路径必须带 `.js` 后缀,省略会 404;② 裸模块名 `import _ from 'lodash'` 浏览器不认,得用 `<script type="importmap">` 或交给打包器;③ `file://` 打开 HTML 会因 CORS 报错,必须起本地服务器(`npx serve` 即可)。
互操作规则记住两条就够:ESM 里 `import` 一个 CommonJS 模块,默认导出就是 `module.exports`;CommonJS 里 `require` 一个 ESM 模块,Node 22+ 才支持,老版本直接报错。
注意循环依赖:CommonJS 拿到的是"执行到一半的部分导出",容易拿到 undefined;ESM 靠 live binding 处理得更好,但如果引用时机在初始化之前,会撞上 TDZ(暂时性死区)报错。
小结
- IIFE 解决作用域隔离,命名空间模式解决命名冲突,但依赖顺序靠人工维护。
- CommonJS 是服务端同步方案,导出值拷贝,注意 `exports` 与 `module.exports` 的区别。
- AMD 为浏览器异步加载而生,UMD 是三者兼容的胶水,代价是牺牲静态分析。
- ESM 把模块写进语言规范:静态结构、live binding、严格模式、支持顶层 await。
- 浏览器用 `<script type="module">`,Node 用 `"type": "module"` 或 `.mjs`;路径带后缀、裸模块要 importmap 或打包器、本地必须起服务器。
- 新项目直接 ESM,老 UMD 库交给打包器转换即可。