26.1 理解模块模式

将代码拆分成独立的块,然后再把这些块连接起来可以通过模块模式来实现。
这种模式背后的思想很简单:把逻辑分块,各自封装,相互独立,每个块自行决定对外暴露什么,同时自行决定引入执行哪些外部代码。

26.1.1 模块标识符

模块标识符是所有模块系统通用的概念。
模块系统本质上是键/值实体,其中每个模块都有个可用于引用它的标识符。
这个标识符在模拟模块的系统中可能是字符串,在原生实现的模块系统中可能是模块文件的实际路径。
将模块标识符解析为实际模块的过程要根据模块系统对标识符的实现。

26.1.2 模块依赖

模块系统的核心是管理依赖。
指定依赖的模块与周围的环境会达成一种契约。
本地模块向模块系统声明一组外部模块(依赖),这些外部模块对于当前模块正常运行是必需的。
模块系统检视这些依赖,进而保证这些外部模块能够被加载并在本地模块运行时初始化所有依赖。

26.1.3 模块加载

加载模块的概念派生自依赖契约。
当一个外部模块被指定为依赖时,本地模块期望在执行它时,依赖已准备好并已初始化。
在浏览器中,加载模块涉及几个步骤:
加载模块涉及执行其中的代码,但必须是在所有依赖都加载并执行之后。
如果浏览器没有收到依赖模块的代码,则必须发送请求并等待网络返回。
收到模块代码之后,浏览器必须确定刚收到的模块是否也有依赖。
然后递归地评估并加载所有依赖,直到所有依赖模块都加载完成。
只有整个依赖图都加载完成,才可以执行入口模块。

26.1.4 入口

相互依赖的模块必须指定一个模块作为入口(entry point),这也是代码执行的起点。
因为JavaScript是顺序执行的,并且是单线程的,所以代码必须有执行的起点。
入口模块也可能依赖其他模块,其他模块同样可能有自己的依赖。
于是模块化JavaScript应用程序的所有模块会构成依赖图。
可以通过有向图来表示应用程序中各模块的依赖关系。
image.png
图中的箭头表示依赖方向:模块A依赖模块B和模块C,模块B依赖模块D和模块E,模块C依赖模块E。
因为模块必须在依赖加载完成后才能被加载,所以这个应用程序的入口模块A,必须在应用程序的其他部分加载后才能执行。
在JavaScript中,“加载”的概念可以有多种实现方式。
因为模块是作为包含将立即执行的JavaScript代码的文件实现的,所以一种可能是按照依赖图的要求,依次请求各个脚本。
对于前面的应用程序来说,下面的脚本请求顺序能够满足依赖图的要求:

  1. <script src="modulerE.js"></script>
  2. <script src="modulerD.js"></script>
  3. <script src="modulerC.js"></script>
  4. <script src="modulerB.js"></script>
  5. <script src="modulerA.js"></script>

模块加载是“阻塞的”,这意味着前置操作必须完成才能执行后续操作。每个模块在自己的代码到达浏览器之后完成加载,此时其依赖已经加载并初始化。

26.1.5 异步依赖

因为JavaScript可以异步执行,所以如果能按需加载就好了。
换句话说,可以让JavaScript通知模块系统在必要时加载新模块,并在模块加载完成后提供回调。
在代码层面,可以通过下面的伪代码来实现:

  1. // 在模块A里面
  2. load('moduleB').then(function(moduleB) {
  3. moduleB.doStuff();
  4. });

模块A的代码使用了moduleB标识符向模块系统请求加载模块B,并以模块B作为参数调用回调。
模块B可能已加载完成,也可能必须重新请求和初始化,但这里的代码并不关心。这些事情都交给了模块加载器去负责。

26.1.6 动态依赖

动态添加的依赖有别于模块开头列出的常规依赖,这些依赖必须在模块执行前加载完毕。
下面是动态依赖加载的例子:

  1. if (loadCondition) {
  2. require('./moduleA');
  3. }

在这个模块中,是否加载moduleA是运行时确定的。
加载moduleA时可能是阻塞的,也可能导致执行,且只有模块加载后才会继续。
无论怎样,模块内部的代码在moduleA加载前都不能执行,因为moduleA的存在是后续模块行为正确的关键。
动态依赖可以支持更复杂的依赖关系,但代价是增加了对模块进行静态分析的难度。

26.1.7 静态分析

模块中包含的发送到浏览器的JavaScript代码经常会被静态分析,分析工具会检查代码结构,并在不实际执行代码的情况下推断其行为。
对静态分析友好的模块系统,可以让模块打包系统更容易将代码处理为较少的文件。它还将支持在智能编辑器里智能自动完成。
更复杂的模块行为,例如动态依赖,会导致静态分析更困难。
不同的模块系统和模块加载器具有不同层次的复杂度。
至于模块的依赖,额外的复杂度会导致相关工具更难预测模块在执行时到底需要哪些依赖。

26.1.8 循环依赖

包括CommonJS、AMD和ES6在内的所有模块系统都支持循环依赖。
在包含循环依赖的应用程序中,模块加载顺序可能会出人意料。但只要恰当地封装模块,使它们没有副作用,加载顺序就应该不会影响应用程序的运行。

26.2 凑合的模块系统

为按照模块模式提供必要的封装,ES6之前的模块有时会使用函数作用域,和立即调用函数表达式(IIFE, Immediately Invoked Function Expression),将模块定义封装在匿名闭包中。模块定义是立即执行的。

  1. (function() {
  2. // 私有Foo模块的代码
  3. console.log('bar')
  4. })();
  5. // bar

如果把这个模块的返回值赋给一个变量,那么实际上就为模块创建了命名空间。

  1. var Foo = (function() {
  2. console.log('bar')
  3. })();
  4. // bar

为了暴露公共API,模块IIFE会返回一个对象,其属性就是模块命名空间中的公共成员。

  1. var Foo = (function() {
  2. return {
  3. bar: 'baz',
  4. baz: function() {
  5. console.log(this.bar);
  6. }
  7. };
  8. })();
  9. console.log(Foo.bar); // baz
  10. Foo.baz(); // baz

类似地,还有一种模式叫作“泄露模块模式”(revealing module pattern)。
这种模式只返回一个对象,其属性是私有数据和成员的引用。

  1. var Foo = (function() {
  2. var bar = 'baz';
  3. var baz = function() {
  4. console.log(bar);
  5. };
  6. return {
  7. bar: bar,
  8. baz: baz
  9. };
  10. })();
  11. console.log(Foo.bar); // baz
  12. Foo.baz(); // baz

在模块内部也可以定义模块,这样可以实现命名空间嵌套。

  1. var Foo = (function() {
  2. return {
  3. bar: 'baz'
  4. };
  5. })();
  6. Foo.baz = (function() {
  7. return {
  8. qux: function() {
  9. console.log('baz');
  10. }
  11. };
  12. })();
  13. console.log(Foo.bar); // baz
  14. Foo.baz.qux(); // baz

为了让模块正确使用外部的值,可以将它们作为参数传给IIFE.

  1. var globalBar = 'baz';
  2. var Foo = (function(bar) {
  3. return {
  4. bar: bar,
  5. baz: function() {
  6. console.log(bar);
  7. }
  8. };
  9. })(globalBar);
  10. console.log(Foo.bar); // baz
  11. Foo.baz(); // baz

因为这里的模块实现其实就是在创建JavaScript对象的实例,所以完全可以在定义之后再扩展模块:

  1. // 原始的Foo
  2. var Foo = (function(bar) {
  3. var bar = 'baz';
  4. return {
  5. bar: bar
  6. };
  7. })();
  8. // 扩展Foo
  9. var Foo = (function() {
  10. FooModule.baz = function() {
  11. console.log(FooModule.bar);
  12. }
  13. return FooModule;
  14. })(Foo);
  15. console.log(Foo.bar); // baz
  16. Foo.baz(); // baz

自己动手写模块系统确实非常有意思,但实际开发中并不建议这么做,因为不够可靠。
前面的例子除了使用恶意的eval之外,并没有其他更好的动态加载依赖的方法。
因此必须手动管理依赖和排序。
要添加异步加载和循环依赖非常困难。最后,对这样的系统进行静态分析也是个问题。

26.3 使用ES6之前的模块加载器

在ES6原生支持模块之前,使用模块的JavaScript代码本质上是:希望使用默认没有的语言特性。
因此,必须按照符合某种规范的模块语法来编写代码,另外还需要单独的模块工具,把这些模块语法与JavaScript运行时连接起来。这里的模块语法和连接方式有不同的表现形式,通常需要在浏览器中额外加载库,或者在构建时完成预处理。

26.3.1 CommonJS

CommonJS规范概述了同步声明依赖的模块定义。这个规范主要用于在服务器端实现模块化代码组织,但也可用于定义在浏览器中使用的模块依赖。CommonJS模块语法不能在浏览器中直接运行。
CommonJS模块定义需要使用require()指定依赖,而使用exports对象定义自己的公共API。
无论一个模块在require()中被引用多少次,模块永远是单例。
模块第一次加载后会被缓存,后续加载会取得缓存的模块(如下代码所示)。模块加载顺序由依赖图决定。
在CommonJS中,模块加载是模块系统执行的同步操作。
module.exports对象非常灵活,有多种使用方式。如果只想导出一个实体,可以直接给module. exports赋值。
导出多个值也很常见,可以使用对象字面量赋值或每个属性赋一次值来实现。
模块的一个主要用途是托管类定义。
CommonJS依赖几个全局属性如require和module.exports。如果想在浏览器中使用CommonJS模块,就需要与其非原生的模块语法之间构筑“桥梁”。
常见的解决方案是提前把模块文件打包好,把全局属性转换为原生JavaScript结构,将模块代码封装在函数闭包中,最终只提供一个文件。为了以正确的顺序打包模块,需要事先生成全面的依赖图。

26.3.2 异步模块定义

CommonJS以服务器端为目标环境,能够一次性把所有模块都加载到内存;
异步模块定义(AMD, Asynchronous Module Definition)的模块定义系统,则以浏览器为目标执行环境,这需要考虑网络延迟的问题。
AMD的一般策略是:让模块声明自己的依赖,而运行在浏览器中的模块系统会按需获取依赖,并在依赖加载完成后立即执行依赖它们的模块。
AMD模块实现的核心是:用函数包装模块定义。这样可以防止声明全局变量,并允许加载器库控制何时加载模块。
包装函数也便于模块代码的移植,因为包装函数内部的所有模块代码使用的都是原生JavaScript结构。
包装模块的函数是全局define的参数,它是由AMD加载器库的实现定义的。
AMD模块可用字符串标识符指定自己的依赖,而AMD加载器会在所有依赖模块加载完毕后,立即调用模块工厂函数。与CommonJS不同,AMD支持可选地为模块指定字符串标识符。
AMD也支持require和exports对象,通过它们可以在AMD模块工厂函数内部定义CommonJS风格的模块。这样可以像请求模块一样请求它们,但AMD加载器会将它们识别为原生AMD结构,而不是模块定义。

26.3.3 通用模块定义

为了统一CommonJS和AMD生态系统,通用模块定义(UMD, UniversalModule Definition)规范应运而生。UMD可用于创建这两个系统都可以使用的模块代码。
本质上,UMD定义的模块会在启动时检测要使用哪个模块系统,然后进行适当配置,并把所有逻辑包装在一个立即调用的函数表达式(IIFE)中。
虽然这种组合并不完美,但在很多场景下足以实现两个生态的共存。
不应该期望手写这个包装函数,它应该由构建工具自动生成。开发者只需专注于模块的内由容,而不必关心这些样板代码。

26.3.4 模块加载器终将没落

CommonJS与AMD之间的冲突正是我们现在享用的ECMAScript 6模块规范诞生的温床。