前端知库

前端模块化经历了哪些阶段?IIFE、CommonJS、AMD、UMD 和 ES Modules 有什么区别?

早期靠 script 标签和全局变量,后来用 IIFE 包一层隔离作用域;Node.js 采用同步 require 的 CommonJS,浏览器端出现异步加载的 AMD;UMD 用一段判断同时兼容 AMD、CommonJS 和全局变量;最后语言标准给出了 ES Modules。

参考答案

简答

最早的页面里,一个功能写在 a.js,另一个写在 b.js,用几个 <script> 标签按顺序引入。所有顶层变量都挂在全局,两个文件都声明了 utils 就会互相覆盖,依赖顺序也只能靠人记。于是大家先用 IIFE,也就是立即执行的函数,把代码包起来,只往全局暴露一个命名空间对象。

后来出现了真正的模块规范。CommonJS 用 require 和 module.exports,require 是同步的,适合从本地磁盘读文件的 Node.js。浏览器要从网络下载文件,同步加载会卡住页面,所以有了 AMD,用 define(依赖数组, 工厂函数) 声明依赖、异步加载,代表实现是 RequireJS。

一个库想同时给这几种环境用,就套一层 UMD:先看有没有带 amd 标记的 define,有就按 AMD 注册;再看有没有 module.exports,有就按 CommonJS 导出;都没有就挂到全局对象上。

最后 ES2015 在语言层面定义了 ES Modules,用 import 和 export 静态声明依赖,浏览器用 <script type="module"> 原生支持,Node.js 也支持,现在新代码一般都写 ESM。要注意 UMD 和 AMD 现在多见于老库和用 <script> 直接引入的场景。

没有模块时遇到了什么问题

浏览器最初只有 <script> 标签。几个脚本的顶层变量共享同一个全局作用域,所以后加载的文件可以悄悄覆盖前面的同名变量。依赖关系也没有地方声明,b.js 用到了 a.js 里的函数,就只能保证 a.js 的标签写在前面。

IIFE 是第一步改进。它把整个文件包进一个函数并马上执行,函数里的变量就成了局部变量,只把需要公开的部分挂到一个全局对象上:

JavaScript
var MyApp = MyApp || {};
MyApp.utils = (function () {
  var cache = {}; // 外面访问不到
  return {
    get: function (key) { return cache[key]; },
  };
})();

这样全局只多了一个 MyApp,私有变量靠闭包藏了起来。可是它没有解决依赖管理,加载顺序还是靠人排。

CommonJS 和 AMD 为什么会分成两套

CommonJS 社区设计模块格式时,目标是浏览器之外的 JavaScript 环境。RequireJS 的《Why AMD?》里写到,CommonJS 没有完全照顾浏览器“天生异步”的特点,在浏览器里用要么靠服务端转换,要么用 XHR 下载文本再解析。

Node.js 采用了 CommonJS。Node.js 文档说明,每个模块在执行前会被包进一个函数 (function(exports, require, module, __filename, __dirname) { ... }),所以模块里的顶层变量是局部的,require 读到哪一行就同步加载哪个文件。读本地磁盘很快,同步没有问题。

浏览器端走了另一条路。AMD 要求先把依赖写成数组,加载器异步下载这些文件,全部就绪后再调用工厂函数:

JavaScript
define(['jquery', './api'], function ($, api) {
  return { render: function () { /* ... */ } };
});

依赖提前声明,加载器就能并行下载、按依赖顺序执行,不会阻塞页面。代价是写法比 CommonJS 啰嗦。

UMD 是怎样判断当前环境的

UMD 不是新的模块系统,它是一段包装代码,让同一个文件在 AMD、CommonJS 和普通 <script> 里都能用。umdjs 仓库的 returnExports.js 模板按这个顺序判断:typeof define === 'function' && define.amd 成立就调用 define;否则 typeof module === 'object' && module.exports 成立就赋值给 module.exports;否则挂到 root 上,root 在浏览器里取 self,取不到再用 this。

下面把这段判断写成函数,传入不同的“环境”对象,看三条分支分别走到哪里:

JavaScript
// 把 UMD 的判断写成函数,传入不同的“环境”来模拟三种情况
function umd(env, factory) {
  if (typeof env.define === 'function' && env.define.amd) {
    env.define([], factory);
    return 'AMD';
  } else if (typeof env.module === 'object' && env.module.exports) {
    env.module.exports = factory();
    return 'CommonJS';
  } else {
    env.root.myLib = factory();
    return '全局变量';
  }
}

const factory = () => ({ version: '1.0.0' });

// 1. AMD 环境:存在带 amd 标记的 define
const registered = [];
const define = (deps, f) => registered.push(f());
define.amd = {};
console.log(umd({ define }, factory), registered[0].version); // AMD 1.0.0

// 2. CommonJS 环境:存在 module.exports
const fakeModule = { exports: {} };
console.log(umd({ module: fakeModule }, factory), fakeModule.exports.version); // CommonJS 1.0.0

// 3. 浏览器 script 标签:两者都没有,挂到全局对象上
const fakeWindow = {};
console.log(umd({ root: fakeWindow }, factory), fakeWindow.myLib.version); // 全局变量 1.0.0

为什么不只判断 define 是不是函数?因为页面上别的脚本也可能定义一个叫 define 的全局函数,多检查一个 define.amd 标记,才能确认它真的是 AMD 加载器。还要注意,模板注释说明 CommonJS 分支依赖 module.exports,只适用于像 Node.js 这样支持它的环境,不适用于严格只有 exports 的 CommonJS 实现。

ES Modules 解决了什么

前面几种方案都是社区约定,靠函数调用在运行时拼出模块关系。ES Modules 是语言本身的语法,import 和 export 只能写在模块顶层,引擎在执行前就能分析出完整的依赖图。打包工具因此能做 Tree shaking,删掉没被用到的导出。

浏览器里用 <script type="module"> 加载 ESM,Node.js 里用 .mjs 或 package.json 的 "type": "module"。ESM 和 CommonJS 在加载时机、导出绑定上的具体区别,见《ES Modules 和 CommonJS 有哪些关键区别?》。

常见追问

现在还需要关心 AMD 和 UMD 吗?

写新代码一般不用。可是有些老库只发布了 UMD 包,或者需要通过 CDN 用 <script> 直接引入,这时会遇到。

IIFE 现在还有用吗?

有。不经过模块系统、直接用 <script> 引入的脚本,仍然可以用 IIFE 包住整段代码,避免变量泄漏到全局。