模块化萌芽:为什么需要模块化

说实话,我刚入行那会儿,写前端代码可没现在这么讲究。

一个 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 对象,只暴露必要的接口
我个人习惯:IIFE 的括号写法有两种——(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 时就把所有依赖声明清楚
  • 异步加载:所有模块通过异步方式加载,不阻塞页面
  • 回调机制:模块加载完成后,通过回调函数执行
我曾经踩过的坑:AMD 的依赖前置写法,让代码变得很啰嗦。每个模块都要写一堆 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 在工具链上稍逊一筹。

模块化规范对比总结

说了这么多,我画个图帮你理清思路:

模块化规范演进 无模块化 全局变量 + IIFE CommonJS 同步加载 / Node.js AMD 异步加载 / RequireJS CMD 就近依赖 / Sea.js 最终:ES Modules 统一标准 / import / export

你看,从无模块化到 CommonJS、AMD、CMD,再到最终的 ES Modules,这条路走了将近十年。每个方案都有它的历史背景和适用场景。

我的建议:
  • 如果你在写 Node.js 应用,用 CommonJS(或者 ES Modules,Node.js 14+ 支持)
  • 如果你在维护老项目,可能还会遇到 AMD/CMD 的代码
  • 如果你在写新项目,直接用 ES Modules(import/export)
  • 理解 IIFE 的原理,它依然是很多打包工具的基础

说实话,现在回头看这些模块化方案,就像看手机发展史一样。从大哥大到翻盖机,再到智能手机。每个阶段都有它的合理性,也都有它的局限性。

下一章,我会带你看看 ES Modules 是怎么统一江湖的。以及,为什么说它是「模块化的终极形态」。