模块
现代 JavaScript 开发毋庸置疑会遇到代码量大和广泛使用第三方库的问题。解决这个问题的方案通常需要把代码拆分成很多部分,然后再通过某种方式将它们连接起来。
在 ECMAScript 6 模块规范出现之前,虽然浏览器原生不支持模块的行为,但迫切需要这样的行为。 ECMAScript 同样不支持模块,因此希望使用模块模式的库或代码库必须基于 JavaScript 的语法和词法特性“伪造”出类似模块的行为。
因为 JavaScript 是异步加载的解释型语言,所以得到广泛应用的各种模块实现也表现出不同的形态。 这些不同的形态决定了不同的结果,但最终它们都实现了经典的模块模式。
理解模块模式
将代码拆分成独立的块,然后再把这些块连接起来可以通过模块模式来实现。这种模式背后的思想很简单:把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,同时自行决定引入执行哪些外部代码。不同的实现和特 性让这些基本的概念变得有点复杂,但这个基本的思想是所有 JavaScript 模块系统的基础。
模块标识符
模块标识符是所有模块系统通用的概念。模块系统本质上是键/值实体,其中每个模块都有个可用于引用它的标识符。这个标识符在模拟模块的系统中可能是字符串,在原生实现的模块系统中可能是模块文件的实际路径。
有的模块系统支持明确声明模块的标识,还有的模块系统会隐式地使用文件名作为模块标识符。不管怎样,完善的模块系统一定不会存在模块标识冲突的问题,且系统中的任何模块都应该能够无歧义地引用其他模块。
将模块标识符解析为实际模块的过程要根据模块系统对标识符的实现。原生浏览器模块标识符必须提供实际 JavaScript 文件的路径。除了文件路径,Node.js 还会搜索 node_modules 目录,用标识符去匹配包含 index.js 的目录。
模块依赖
模块系统的核心是管理依赖。指定依赖的模块与周围的环境会达成一种契约。本地模块向模块系统声明一组外部模块(依赖),这些外部模块对于当前模块正常运行是必需的。模块系统检视这些依赖, 进而保证这些外部模块能够被加载并在本地模块运行时初始化所有依赖。
每个模块都会与某个唯一的标识符关联,该标识符可用于检索模块。这个标识符通常是 JavaScript 文件的路径,但在某些模块系统中,这个标识符也可以是在模块本身内部声明 的命名空间路径字符串。
模块加载
加载模块的概念派生自依赖契约。当一个外部模块被指定为依赖时,本地模块期望在执行它时,依 18 赖已准备好并已初始化。
在浏览器中,加载模块涉及几个步骤。加载模块涉及执行其中的代码,但必须是在所有依赖都加载并执行之后。如果浏览器没有收到依赖模块的代码,则必须发送请求并等待网络返回。收到模块代码之 19 后,浏览器必须确定刚收到的模块是否也有依赖。然后递归地评估并加载所有依赖,直到所有依赖模块都加载完成。只有整个依赖图都加载完成,才可以执行入口模块。
入口
相互依赖的模块必须指定一个模块作为入口(entry point),这也是代码执行的起点。这是理所当然的,因为 JavaScript 是顺序执行的,并且是单线程的,所以代码必须有执行的起点。入口模块也可能依赖其他模块,其他模块同样可能有自己的依赖。于是模块化 JavaScript 应用程序的所有模块会构成依赖图。
图中的箭头表示依赖方向:模块 A 依赖模块 B 和模块 C,模块 B 依赖模块 D 和模块 E,模块 C 依赖模块 E。因为模块必须在依赖加载完成后才能被加载,所以这个应用程序的入口模块 A 必须在应用程序的其他部分加载后才能执行。
在 JavaScript 中,“加载”的概念可以有多种实现方式。因为模块是作为包含将立即执行的 JavaScript 代码的文件实现的,所以一种可能是按照依赖图的要求依次请求各个脚本。 对于前面的应用程序来说,下面的脚本请求顺序能够满足依赖图的要求:
<script src="moduleE.js"></script>
<script src="moduleD.js"></script>
<script src="moduleC.js"></script>
<script src="moduleB.js"></script>
<script src="moduleA.js"></script>
模块加载是“阻塞的”,这意味着前置操作必须完成才能执行后续操作。每个模块在自 己的代码到达浏览器之后完成加载,此时其依赖已经加载并初始化。不过,这个策略存在一些性能和复杂性问题。为一个应用程序而按顺序加载五个 JavaScript 文件并不理想,并且手动管理正确的加载顺序也颇为棘手。
异步依赖
因为 JavaScript 可以异步执行,所以如果能按需加载就好了。换句话说,可以让 JavaScript 通知模块系统在必要时加载新模块,并在模块加载完成后提供回调。在代码层面,可以通过下面的伪代码来实现:
// 在模块A里面
load('moduleB').then(function (moduleB) {
moduleB.doStuff();
});
模块 A 的代码使用了 moduleB 标识符向模块系统请求加载模块 B,并以模块 B 作为参数调用回调。 模块 B 可能已加载完成,也可能必须重新请求和初始化,但这里的代码并不关心。这些事情都交给了模块加载器去负责。
如果重写前面的应用程序,只使用动态模块加载,那么使用一个 <script>
标签即可完成模块 A 的加载。模块 A 会按需请求模块文件,而不会生成必需的依赖列表。这样有几个好处,其中之一就是性能, 因为在页面加载时只需同步加载一个文件。
这些脚本也可以分离出来,比如给 <script>
标签应用 defer 或 async 属性,再加上能够识别异步脚本何时加载和初始化的逻辑。此行为将模拟在 ES6 模块规范中实现的行为,本章稍后会对此进行讨论。
动态依赖
有些模块系统要求开发者在模块开始列出所有依赖,而有些模块系统则允许开发者在程序结构中动态添加依赖。动态添加的依赖有别于模块开头列出的常规依赖,这些依赖必须在模块执行前加载完毕。
if (loadCondition) {
require('./moduleA');
}
在这个模块中,是否加载 moduleA 是运行时确定的。加载 moduleA 时可能是阻塞的,也可能导致执行,且只有模块加载后才会继续。无论怎样,模块内部的代码在 moduleA 加载前都不能执行,因为 moduleA 的存在是后续模块行为正确的关键。
动态依赖可以支持更复杂的依赖关系,但代价是增加了对模块进行静态分析的难度。
静态分析
模块中包含的发送到浏览器的 JavaScript 代码经常会被静态分析,分析工具会检查代码结构并在不实际执行代码的情况下推断其行为。对静态分析友好的模块系统可以让模块打包系统更容易将代码处理为较少的文件。它还将支持在智能编辑器里智能自动完成。
更复杂的模块行为,例如动态依赖,会导致静态分析更困难。不同的模块系统和模块加载器具有不同层次的复杂度。至于模块的依赖,额外的复杂度会导致相关工具更难预测模块在执行时到底需要哪些依赖。
循环依赖
要构建一个没有循环依赖的 JavaScript 应用程序几乎是不可能的,因此包括 CommonJS、AMD 和 ES6 在内的所有模块系统都支持循环依赖。在包含循环依赖的应用程序中,模块加载顺序可能会出人意料。不过,只要恰当地封装模块,使它们没有副作用,加载顺序就应该不会影响应用程序的运行。
凑合的模块系统
为按照模块模式提供必要的封装,ES6 之前的模块有时候会使用函数作用域和立即调用函数表达式 (IIFE,Immediately Invoked Function Expression)将模块定义封装在匿名闭包中。
(function () {
// 私有 Foo 模块的代码 console.log('bar');
})();
// bar
如果把这个模块的返回值赋给一个变量,那么实际上就为模块创建了命名空间:
var Foo = (function () {
console.log('bar'); // bar
})();
为了暴露公共 API,模块 IIFE 会返回一个对象,其属性就是模块命名空间中的公共成员:
var Foo = (function () {
return {
bar: 'baz',
baz: function () {
console.log(this.bar);
},
};
})();
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'
类似地,还有一种模式叫作“泄露模块模式”(revealing module pattern)。这种模式只返回一个对象, 其属性是私有数据和成员的引用:
var Foo = (function () {
var bar = 'baz';
var baz = function () {
console.log(bar);
};
return {
bar: bar,
baz: baz,
};
})();
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'
在模块内部也可以定义模块,这样可以实现命名空间嵌套:
var Foo = (function () {
return {
bar: 'baz',
};
})();
Foo.baz = (function () {
return {
qux: function () {
console.log('baz');
},
};
})();
console.log(Foo.bar); // 'baz'
Foo.baz.qux(); // 'baz'
为了让模块正确使用外部的值,可以将它们作为参数传给 IIFE:
var globalBar = 'baz';
var Foo = (function (bar) {
return {
bar: bar,
baz: function () {
console.log(bar);
},
};
})(globalBar);
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'
因为这里的模块实现其实就是在创建 JavaScript 对象的实例, 所以完全可以在定义之后再扩展模块:
// 原始的Foo
var Foo = (function (bar) {
var bar = 'baz';
return {
bar: bar,
};
})();
// 扩展Foo
var Foo = (function (FooModule) {
FooModule.baz = function () {
console.log(FooModule.bar);
};
return FooModule;
})(Foo);
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'
无论模块是否存在,配置模块扩展以执行扩展也很有用:
// 扩展 Foo 以增加新方法
var Foo = (function (FooModule) {
FooModule.baz = function () {
console.log(FooModule.bar);
};
return FooModule;
})(Foo || {});
// 扩展 Foo 以增加新数据
var Foo = (function (FooModule) {
FooModule.bar = 'baz';
return FooModule;
})(Foo || {});
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'
当然,自己动手写模块系统确实非常有意思,但实际开发中并不建议这么做,因为不够可靠。前面的例子除了使用恶意的 eval 之外并没有其他更好的动态加载依赖的方法。因此必须手动管理依赖和排序。要添加异步加载和循环依赖非常困难。最后,对这样的系统进行静态分析也是个问题。
使用 ES6 之前的模块加载器
在 ES6 原生支持模块之前,使用模块的 JavaScript 代码本质上是希望使用默认没有的语言特性。因此,必须按照符合某种规范的模块语法来编写代码,另外还需要单独的模块工具把这些模块语法与 JavaScript 运行时连接起来。这里的模块语法和连接方式有不同的表现形式,通常需要在浏览器中额外加载库或者在构建时完成预处理。
CommonJS
CommonJS 规范概述了同步声明依赖的模块定义。这个规范主要用于在服务器端实现模块化代码组织,但也可用于定义在浏览器中使用的模块依赖。CommonJS 模块语法不能在浏览器中直接运行。
CommonJS 模块定义需要使用 require()
指定依赖,而使用 exports 对象定义自己的公共 API。
const moduleB = require('./moduleB');
module.exports = {
stuff: moduleB.doStuff(),
};
使用模块定义的相对路径来指定自己对 moduleB 的依赖。什么是“模块定义”,以及如何将字符串解析为模块,完全取决于模块系统的实现。比如在 Node.js 中,模块标识符可能指向文件, 也可能指向包含 index.js 文件的目录。
请求模块会加载相应模块,而把模块赋值给变量也非常常见,但赋值给变量不是必需的。调用 require()
会触发模块的加载和执行过程。无论你是否将 require()
的结果赋值给一个变量,这个模块的代码都会被执行一次,并且模块的导出内容会被缓存起来。
无论一个模块在 require()
中被引用多少次,模块永远是单例。模块第一次加载后会被缓存,后续加载会取得缓存的模块。
// counter.js
let count = 0;
module.exports = {
increment() {
count++;
},
getCount() {
return count;
},
};
// main.js
const counter1 = require('./counter');
const counter2 = require('./counter');
counter1.increment();
console.log(counter1.getCount()); // 输出: 1
console.log(counter2.getCount()); // 输出: 1 (与 counter1 共享同一个状态)
在 CommonJS 中,require()
是一个同步操作。这意味着当你在代码中调用 require()
时,Node.js 会立即加载并执行目标模块的代码,并返回该模块的导出内容。因此,如果你在代码的某个条件块中调用 require()
,例如:
console.log('Before loading moduleA');
if (loadCondition) {
require('./moduleA'); // 在 loadCondition 为true时加载moduleA
}
console.log('After loading moduleA');
在 Node.js 中,require()
中的模块标识符可以是相对路径、绝对路径,也可以是模块名称。当使用相对路径时,Node.js 会从调用模块的文件位置开始解析路径,比如 ./moduleA 表示与当前文件同一目录下的 moduleA.js 文件。
require()
可以指向一个目录而不是一个具体的文件。如果你 require()
一个目录,Node.js 会尝试在该目录中查找 package.json 文件,并使用其中 main 字段指定的入口文件。如果没有 package.json,Node.js 会查找该目录下的 index.js 文件作为默认入口。
每个模块都有一个 module.exports 对象,这个对象代表模块对外暴露的接口。
// moduleA.js
module.exports.foo = function () {
console.log('Hello from moduleA');
};
module.exports
对象非常灵活,有多种使用方式。如果只想导出一个实体,可以直接给 moduleexports
赋值:
module.exports = 'foo';
// 导出多个值也很常见,可以使用对象字面量赋值或每个属性赋一次值来实现:
module.exports = {
a: 'A',
b: 'B',
};
// 使用 ES6 风格的类定义,不过 ES5 风格也兼容
class A {}
module.exports = A;
var A = require('./moduleA');
var a = new A();
// 也可以将类实例作为导出值:
class A {}
module.exports = new A();
CommonJS 依赖几个全局属性如 require
和 module.exports
。如果想在浏览器中使用 CommonJS 模块,就需要与其非原生的模块语法之间构筑“桥梁”。模块级代码与浏览器运行时之间也需要某种“屏障”,因为没有封装的 CommonJS 代码在浏览器中执行会创建全局变量。这显然与模块模式的初衷相悖。 21
常见的解决方案是提前把模块文件打包好,把全局属性转换为原生 JavaScript 结构,将模块代码封装在函数闭包中,最终只提供一个文件。为了以正确的顺序打包模块,需要事先生成全面的依赖图。
异步模块定义
CommonJS 以服务器端为目标环境,能够一次性把所有模块都加载到内存,而异步模块定义(AMD, Asynchronous Module Definition)的模块定义系统则以浏览器为目标执行环境,这需要考虑网络延迟的 23 问题。AMD 的一般策略是让模块声明自己的依赖,而运行在浏览器中的模块系统会按需获取依赖,并在依赖加载完成后立即 执行依赖它们的模块。
AMD 模块实现的核心是用函数包装模块定义。这样可以防止声明全局变量,并允许加载器库控制何时加载模块。包装函数也便于模块代码的移植,因为包装函数内部的所有模块代码使用的都是原生 JavaScript 结构。包装模块的函数是全局 define 的参数,它是由 AMD 加载器库的实现定义的。
AMD 模块可以使用字符串标识符指定自己的依赖,而 AMD 加载器会在所有依赖模块加载完毕后立即调用模块工厂函数。与 CommonJS 不同,AMD 支持可选地为模块指定字符串标识符。
// ID 为'moduleA'的模块定义。moduleA 依赖 moduleB,
// moduleB 会异步加载
define('moduleA', ['moduleB'], function (moduleB) {
return {
stuff: moduleB.doStuff(),
};
});