学完这篇,你能把 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 库交给打包器转换即可。