模块化萌芽:为什么需要模块化
说实话,我刚入行那会儿,写前端代码可没现在这么讲究。
一个 HTML 文件,里面塞一堆 <script> 标签。JS 代码从上往下写,全局变量满天飞。那时候觉得,能跑就行呗。
直到有一天,我接手了一个老项目。打开控制台,满屏的 Uncaught ReferenceError。我花了整整一个下午,才找到问题根源——两个不同文件里,都定义了一个叫 data 的变量。一个存用户信息,一个存配置数据。结果后加载的,把前一个覆盖了。
嗯,这就是典型的全局变量污染。
全局变量污染:一个真实的噩梦
你想想看,在浏览器里,所有 <script> 标签加载的 JS 文件,都共享同一个全局作用域——window。
这就好比一个公共厨房。每个人都可以往冰箱里放东西,但没人贴标签。时间一长,你根本分不清哪个鸡蛋是谁的。
- 命名冲突:不同文件定义了同名变量,后定义的覆盖先定义的
- 依赖关系不明确:你不知道某个全局变量是谁定义的、在哪里定义的
- 难以维护:改一个全局变量,可能影响整个应用
- 不利于团队协作:每个人都要小心翼翼,生怕踩到别人的变量
我在项目中遇到过最离谱的一次,是第三方库和业务代码都定义了一个叫 _ 的变量。第三方库是 lodash,业务代码是某个数据对象。结果 lodash 的方法全失效了,页面直接白屏。排查了俩小时,最后发现是加载顺序的问题。
IIFE:模块化的第一步
怎么解决全局变量污染?
聪明的前辈们想出了一个办法——IIFE(Immediately Invoked Function Expression),也就是立即执行函数。
说白了,就是创建一个函数,然后立刻执行它。函数内部有自己的作用域,变量不会泄露到全局。
// 传统方式:污染全局
var counter = 0;
function increment() {
counter++;
}
// IIFE 方式:隔离作用域
(function() {
var counter = 0;
function increment() {
counter++;
}
// 如果需要暴露,可以挂到 window 上
window.myModule = {
increment: increment,
getCount: function() { return counter; }
};
})();
你看,IIFE 的核心思想就两点:
- 隔离:函数内部变量不会污染全局
- 暴露:通过
window对象,只暴露必要的接口
(function(){})() 和 (function(){}())。前者更常见,后者是 Crockford 推荐的风格。我一般用前者,因为可读性更好。
但 IIFE 也有局限。你想想看,如果模块 A 依赖模块 B,你得手动保证加载顺序。而且,模块之间的依赖关系,全靠注释说明,代码里根本看不出来。
模块化规范初探:CommonJS / AMD / CMD
IIFE 解决了作用域问题,但没解决依赖管理问题。于是,社区开始探索更规范的模块化方案。
我整理了一下,主要有三大流派:
| 规范 | 诞生背景 | 核心特点 | 典型代表 |
|---|---|---|---|
| CommonJS | 服务端(Node.js) | 同步加载、require / module.exports |
Node.js |
| AMD | 浏览器端 | 异步加载、define / require |
RequireJS |
| CMD | 浏览器端 | 就近依赖、延迟执行 | Sea.js |
CommonJS:服务端的优雅方案
CommonJS 是 Node.js 默认的模块规范。它的语法很直观:
// math.js
const add = (a, b) => a + b;
const subtract = (a, b) => a - b;
module.exports = { add, subtract };
// main.js
const math = require('./math.js');
console.log(math.add(2, 3)); // 5
为什么 CommonJS 在浏览器里行不通?
因为它是同步加载的。在服务端,文件都在本地磁盘上,读取很快。但在浏览器里,文件需要通过网络请求加载,同步加载会阻塞页面渲染。
我曾经在早期的一个 Electron 项目里用过 CommonJS,因为 Electron 的渲染进程有 Node 环境。但后来迁移到纯浏览器环境时,发现根本跑不了。嗯,这就是教训。
AMD:浏览器端的异步方案
AMD(Asynchronous Module Definition)就是为了解决浏览器异步加载问题而生的。
// 定义模块
define('math', ['dependency'], function(dep) {
const add = (a, b) => a + b;
return { add };
});
// 加载模块
require(['math'], function(math) {
console.log(math.add(2, 3));
});
AMD 的特点是:
- 依赖前置:在
define时就把所有依赖声明清楚 - 异步加载:所有模块通过异步方式加载,不阻塞页面
- 回调机制:模块加载完成后,通过回调函数执行
define 和 require。而且,如果依赖链很深,回调嵌套会非常难看。说白了,就是「回调地狱」的另一种形式。
CMD:另一种思路
CMD(Common Module Definition)是 Sea.js 推广的规范。它和 AMD 最大的区别是:就近依赖。
// CMD 写法
define(function(require, exports, module) {
// 需要时才加载
var math = require('./math');
console.log(math.add(2, 3));
});
CMD 的特点是:
- 延迟执行:模块在
require时才执行 - 就近依赖:用到哪个模块,就在哪里
require - 更接近 CommonJS:写法上更自然
我个人觉得,CMD 的写法更符合直觉。但可惜的是,Sea.js 最终没有像 RequireJS 那样普及。原因嘛,我觉得是生态问题——AMD 有 RequireJS 这个强大的工具,而 CMD 的 Sea.js 在工具链上稍逊一筹。
模块化规范对比总结
说了这么多,我画个图帮你理清思路:
你看,从无模块化到 CommonJS、AMD、CMD,再到最终的 ES Modules,这条路走了将近十年。每个方案都有它的历史背景和适用场景。
- 如果你在写 Node.js 应用,用 CommonJS(或者 ES Modules,Node.js 14+ 支持)
- 如果你在维护老项目,可能还会遇到 AMD/CMD 的代码
- 如果你在写新项目,直接用 ES Modules(import/export)
- 理解 IIFE 的原理,它依然是很多打包工具的基础
说实话,现在回头看这些模块化方案,就像看手机发展史一样。从大哥大到翻盖机,再到智能手机。每个阶段都有它的合理性,也都有它的局限性。
下一章,我会带你看看 ES Modules 是怎么统一江湖的。以及,为什么说它是「模块化的终极形态」。