第26章 模块
第26章 模块
编写现代JavaScript将使用大型代码库并使用第三方资源。最终将使用分解为不同部分并以某种方式将它们连接在一起的代码。
理解模块模式
将代码拆分为独立的部分并将这些部分连接在一起可以通过模块模式健壮地实现。
模块标识符
所有模块系统共通的概念是模块标识符。模块系统本质上是键值实体,每个模块都有一个用于引用它的标识符。在模拟模块系统时,该标识符有时是一个字符串,在本地实现模块系统时,可能是模块文件的实际路径。某些模块系统允许显式声明模块的标识符,而其他系统将隐式使用文件名作为模块标识。无论哪种情况,一个结构良好的模块系统都不会出现模块身份冲突,任何模块都应该能够毫无歧义地引用系统中的其他模块。
模块标识符如何解析为实际模块取决于特定模块系统中的标识符实现。在浏览器中,本机模块标识符必须提供指向实际 JavaScript 文件的路径。除了文件路径外,Node.js 还会在 node_modules
目录中搜索模块匹配,并且可以将标识符匹配到包含 index.js
的目录。
模块依赖
在考虑如何管理依赖关系时,模块系统的核心功能开始发挥作用。指定依赖项的模块与其周围环境签订契约。本地模块向模块系统声明一个外部模块列表——“dependencies”——它知道存在并且是本地模块正常运行所必需的。模块系统检查依赖关系,然后确保在本地模块执行时这些模块将被加载和初始化。
每个模块还与一些唯一令牌相关联,可用于获取模块。通常情况下,这是 JavaScript 文件的路径,但在某些模块系统中,也可以是在模块本身内声明的命名空间路径字符串。
模块加载
“加载”模块的概念源自依赖契约的要求。当一个外部模块被指定为依赖时,本地模块期望在执行时该依赖已准备好并被初始化。在浏览器环境中,加载一个模块涉及几个组件。加载模块需要执行其中的代码,但要在所有依赖项先加载并执行之后才能开始。如果模块有尚未发送到浏览器的代码作为依赖项,则必须通过网络请求和传递。一旦浏览器接收到代码负载,浏览器必须确定新加载的外部模块是否有自己的依赖项,它会递归评估这些依赖项并依次加载它们,直到所有依赖模块都加载完毕。只有当整个依赖图加载完成后,入口模块才能开始执行。
入口点
相互依赖的模块网络必须将单个模块指定为“入口点”,执行路径将从此处开始。这应该很有意义,因为 JavaScript 执行环境是串行执行和单线程的,所以代码必须从某个地方开始。此入口点模块可能具有模块依赖项,而其中一些依赖项又将具有其他依赖项。这样做的最终效果是模块化 JavaScript 应用程序的所有模块将形成一个依赖关系图。
应用程序中模块之间的依赖关系可以表示为有向图。如下图所示的依赖关系图代表一个虚构的应用程序:
箭头表示模块依赖的流向:模块A依赖模块B和模块C,模块B依赖模块D和模块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代码指导模块系统加载新模块,并在模块准备好后将模块提供给回调。在代码层面,用于此的伪代码可能如下所示:
// moduleA definition
load('moduleB').then(function(moduleB) {
moduleB.doStuff();
});
模块A的代码使用模块B令牌来请求模块系统加载模块B,并在回调中使用模块B。模块B可能已经被加载,或者它可能需要重新请求和初始化,但这段代码并不关心这些细节,这些责任被委托给模块加载器。
如果要重新设计之前的应用程序以仅使用程序化模块加载,可以使用单个 <script>
标签用于模块A加载,并根据需要请求模块文件,而无需生成有序的依赖关系列表。这样做有很多好处,其中之一是性能,因为页面加载时只需要同步加载一个文件。
也可以将这些脚本分开,将 defer
或 async
特性应用于 <script>
标签,并包含能够识别异步脚本何时加载和初始化的逻辑。这种行为将模拟ES6模块规范中的内容,这将在本章稍后介绍。
程序化依赖
一些模块系统要求在模块开头指定所有依赖项,但也有一些模块系统允许在程序结构中动态添加依赖。这与在模块开头列出的常规依赖项不同,所有这些依赖项都需要在模块开始执行之前加载。
下面是一个程序化依赖加载的例子:
if (loadCondition) {
require('./moduleA');
}
在这个模块中,仅在运行时确定是否加载了moduleA。moduleA的加载可能会阻塞,或者它可能会导致执行,且仅在加载模块后继续。无论哪种方式,在加载moduleA之前,此模块内的执行都无法继续,因为假设moduleA的存在对后续模块行为至关重要。
程序化依赖允许更复杂的依赖利用,但需要付出代价——它使模块的静态分析更加困难。
静态分析
构建并交付给浏览器的JavaScript通常会受到静态分析的影响。在静态分析中,工具将检查代码结构并推断它在不执行程序运行的情况下的行为方式。一个对静态分析友好的模块系统将允许模块捆绑系统,以便在弄清楚如何将代码组合到更少的文件中时更轻松。它还能够在智能编辑器中执行智能自动完成。
更复杂的模块行为,例如程序依赖,会使静态分析更加困难。不同的模块系统和模块加载器将提供不同程度的复杂性。关于模块的依赖项,额外的复杂性将使工具更难以准确预测模块在执行时需要哪些依赖项。
循环依赖
构建一个没有依赖循环的JavaScript应用程序几乎是不可能的,因此包括CommonJS、AMD和ES6在内的所有模块系统都支持循环依赖。在具有依赖循环的应用程序中,加载模块的顺序可能不是预期的。但是,如果以没有副作用的方式正确构建了模块,则加载顺序不应损害整个应用程序。
在以下模块中(使用与模块无关的伪代码),任何模块都可以用作入口点模块:
require('./moduleD');
require('./moduleB');
console.log('moduleA');
require('./moduleA');
require('./moduleC');
console.log('moduleB');
require('./moduleB');
require('./moduleD');
console.log('moduleC');
require('./moduleA');
require('./moduleC');
console.log('moduleD');
更改用作主模块的模块将更改依赖项加载顺序。如果先加载moduleA,它将打印以下内容,指示模块加载完成的绝对顺序:
moduleB
moduleC
moduleD
moduleA
加载顺序可以用下图中的依赖关系图进行可视化,其中加载器将执行依赖项的深度优先加载:
改进模块系统
为了提供模块模式所需的封装,ES6之前的模块有时使用函数作用域并立即调用函数表达式(IIFE)将模块定义包装在匿名闭包中。模块定义立即执行,如下所示:
(function() {
// private Foo module code
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对象的实例,所以完全有可能在模块定义之后对其进行扩展:
// Original Foo
var Foo = (function(bar) {
var bar = 'baz';
return {
bar: bar
};
})();
// Augment Foo
var Foo = (function(FooModule) {
FooModule.baz = function() {
console.log(FooModule.bar);
};
return FooModule;
})(Foo);
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'
无论模块是否存在,配置模块增强以执行增强也很有用:
// Augment Foo to add alert method
var Foo = (function(FooModule) {
FooModule.baz = function() {
console.log(FooModule.bar);
};
return FooModule;
})(Foo || {});
// Augment Foo to add data
var Foo = (function(FooModule) {
FooModule.bar = 'baz';
return FooModule;
})(Foo || {});
console.log(Foo.bar); // 'baz'
Foo.baz(); // 'baz'
正如您可能怀疑的那样,设计自己的模块系统是一项有趣的练习,但不建议在实际使用中使用,因为结果很脆弱。除了使用恶意的eval之外,前面的示例没有以编程方式加载依赖项的好方法。依赖项必须手动管理和排序。添加异步加载和循环依赖非常困难。最后,在这样的系统上执行静态分析将非常困难。
使用ES6模块
ECMAScript 6(ES6)最重要的介绍之一是模块规范。该规范在许多方面比其前身模块加载器更简单,并且本机浏览器支持意味着不需要加载器库和其他预处理。在很多方面,ES6模块系统将AMD和CommonJS的最佳特性统一到一个规范中。
模块标记和定义
ECMAScript 6模块作为JavaScript的整体块存在。带有 type="module"
的脚本标记将向浏览器发出信号,表明相关代码应作为模块执行,而不是作为传统脚本执行。模块可以内联或在外部文件中定义:
<script type="module">
// module code
</script>
<script type="module" src="path/to/myModule.js"></script>
尽管它们的处理方式与传统加载的JavaScript文件不同,但JavaScript模块文件没有特殊的内容类型。与传统的脚本不同,所有模块都将像 <script defer>
一样的顺序执行。当 <script type="module">
解析后,将立即开始下载模块文件。但是执行会延迟到文档被完全解析。这适用于内联模块和在外部文件中定义的模块。<script type="module">
中代码出现在页面中的顺序即为其执行顺序。同 <script defer>
的情况一样,改变模块标签的位置,无论在 <head>
或 <body>
中,仅会控制文件何时加载,而不是模块何时加载。
以下是内联模块的执行顺序:
<!-- Executes 2nd -->
<script type="module"></script>
<!-- Executes 3rd -->
<script type="module"></script>
<!-- Executes 1st -->
<script></script>
也可以向模块标签添加 async
属性。这样做的效果是双重的:模块执行的顺序不再与页面上脚本标签的顺序绑定,模块也不会在开始执行之前等待文档完成解析。入口模块仍必须等待其依赖项加载。
与 <script type="module">
标签关联的ES6模块被认为是模块图的入口模块。一个页面可以有多少个入口模块没有限制,模块重叠没有限制。无论一个模块在页面中加载多少次,无论加载方式如何,它都只会加载一次,如下所示:
<!-- moduleA will only load a single time on this page -->
<script type="module">
import './moduleA.js'
</script>
<script type="module">
import './moduleA.js'
</script>
<script type="module" src="./moduleA.js"></script>
<script type="module" src="./moduleA.js"></script>
无法使用 import
将内联定义的模块加载到其他模块中。只能使用 import
加载从外部文件加载的模块。因此,内联模块仅用作入口点模块。
模块加载
ECMAScript 6模块的独特之处在于它们既可以由浏览器本地加载,也可以与第三方加载器和构建工具结合使用。一些浏览器本身仍然不支持ES6模块,因此可能需要第三方工具。在许多情况下,第三方工具实际上可能更受欢迎。
提供完整ECMAScript 6模块支持的浏览器将能够从顶级模块加载整个依赖关系图,并且它将异步加载。浏览器将解释入口模块,识别其依赖关系,并发出对其依赖模块的请求。当这些文件通过网络返回时,浏览器将解析它们的内容,识别它们的依赖关系,如果这些二阶依赖关系尚未加载,它们会发出更多请求。这个递归的异步过程将一直持续到整个应用程序的依赖图被解析。一旦解决了依赖关系,应用程序就可以开始正式加载了。
这个过程与AMD风格的模块加载非常相似。模块文件按需加载,连续的模块文件请求被每个依赖模块文件的网络延迟延后。也就是说,如果入口 moduleA
依赖于 moduleB
,而 moduleB
依赖于 moduleC
,则浏览器将不知道要发送对 C
的请求,直到对 B
的请求先完成。这种加载方式效率很高,不需要外部工具,但加载具有深度依赖关系图的大型应用程序可能需要很长时间。
模块行为
ECMAScript 6模块借鉴了CommonJS和AMD前辈的许多优秀特性。仅举几例:
- 模块代码仅在加载完成才执行。
- 一个模块只会被加载一次。
- 模块是单例的。
- 模块可以定义一个公共接口,其他模块可以观察和交互。
- 模块可以请求加载其他模块。
- 支持循环依赖。
ES6模块系统还引入了新的行为:
- ES6模块默认以严格模式执行。
- ES6模块不共享全局命名空间。
- 模块顶层的
this
值是undefined
。 var
声明的变量不会被添加到window
对象中。- ES6模块被异步加载和执行。
此处描述的ECMAScript 6模块的行为由浏览器的运行时有条件地强制执行。当JavaScript文件与 type="module"
关联或使用 import
语句加载时将被指定为模块。
模块导出
ES6模块公开的导出系统与CommonJS非常相似。export
关键字用于控制模块的哪些部分对外部模块可见。ES6模块中有两种类型的导出:命名导出和默认导出。不同类型的导出意味着它们的导入方式不同——这将在下一节中介绍。
export
关键字用于将值声明为命名导出。导出必须发生在模块的顶层;它们不能嵌套在块内:
// Allowed
export ...
// Disallowed
if (condition) {
export ...
}
导出值对模块内的JavaScript执行没有直接影响,因此export
语句相对于导出内容的位置没有限制。export
甚至可能在要导出的值前面:
// Allowed
const foo = 'foo';
export { foo };
// Allowed
export const foo = 'foo';
// Allowed, but avoid
export { foo };
const foo = 'foo';
命名导出的行为就像模块是导出值的容器一样。内联命名导出,顾名思义,可以与变量声明在同一行中执行。在下面示例中,变量声明与内联导出配对。外部模块可以导入此模块,并且值foo
将在其中作为该模块的属性使用:
export const foo = 'foo';
声明不需要与export
出现在同一行,可在模组内的任何地方声明export
子句:
const foo = 'foo';
export { foo };
导出时也可以提供别名。别名必须出现在export
子句括号语法中;因此,声明值、导出值和提供别名不能全部在同一行中完成。在以下示例中,外部模块将通过导入此模块并使用myFoo
来访问此值:
const foo = 'foo';
export { foo as myFoo };
由于ES6命名导出允许将模块视为容器,因此可以在单个模块中声明多个命名导出。值可以在export
语句中声明,也可以在将其指定为export
之前声明:
export const foo = 'foo';
export const bar = 'bar';
export const baz = 'baz';
由于导出多个值是常见行为,因此支持对导出声明进行分组,以及为部分或全部导出设置别名:
const foo = 'foo';
const bar = 'bar';
const baz = 'baz';
export { foo, bar as myBar, baz };
默认导出的行为就好像模块与导出的值是相同的实体。default
关键字修饰符用于将值声明为默认导出,并且只能有一个默认导出。尝试指定重复的默认导出将导致SyntaxError
。 在下面的例子中,一个外部模块可以导入这个模块,模块本身就是foo
的值:
const foo = 'foo';
export default foo;
语法: ES6模块系统将在default
关键字作为别名提供时识别之,并将默认导出应用于该值,即使它使用命名导出:
const foo = 'foo';
// Behaves identically to "export default foo;"
export { foo as default };
因为命名导出和默认导出之间没有不兼容,ES6允许在同一个模块中使用两者:
const foo = 'foo';
const bar = 'bar';
export { bar };
export default foo;
两个export
语句可以合并为同一行:
const foo = 'foo';
const bar = 'bar';
export { foo as default, bar };
ES6规范限制了在各种形式的导出语句中可以做什么和不能做什么。有些形式允许声明和赋值,有些形式只允许表达式,有些形式只允许简单的标识符。请注意,有些形式使用分号,有些则不使用。
// Named inline exports
export const baz = 'baz';
export const foo = 'foo', bar = 'bar';
export function foo() {}
export function* foo() {}
export class Foo {}
// Named clause exports
export { foo };
export { foo, bar };
export { foo as myFoo, bar };
// Default exports
export default 'foo';
export default 123;
export default /[a-z]*/;
export default { foo: 'foo' };
export { foo, bar as default };
export default foo;
export default function() {}
export default function foo() {}
export default function*() {}
export default class {}
// 多种不被允许的形式将导致错误
// 变量声明不可发生在内联默认导出中
export default const foo = 'bar';
// 只有标识符可以出现在导出从句中
export { 123 as foo }'
// 别名只可在导出从句中使用
export const foo = 'foo' as myFoo;
模块导入
模块可以使用import
关键字使用别处导出的模块。和export
一样,import
必须出现在模块的顶层:
// 允许
import ...
// 不允许
if (condition) {
import ...
}
import
语句被提升到模块的顶部。因此,与export
关键字一样,import
语句出现的顺序并不重要。但是,建议将导入保留在模块的顶部:
// 允许
import { foo } from './fooModule.js';
console.log(foo); // 'foo'
// 允许,但应避免
console.log(foo); // 'foo'
import { foo } from './fooModule.js';
模块标识符可以是从当前模块到该模块文件的相对路径,也可以是从基本路径到该模块文件的绝对路径。它必须是一个普通的字符串;标识符不能动态计算,例如,连接字符串。
如果模块通过模块标识符中的路径从本地加载到浏览器中,则需要.js
扩展名才能引用正确的文件。但是,如果ES6模块由构建工具或第三方模块加载器捆绑或解释,则可能不需要在其标识符中包含模块的文件扩展名:
// Resolves to /components/bar.js
import ... from './bar.js';
// Resolves to /bar.js
import ... from '../bar.js';
// Resolves to /bar.js
import ... from '/bar.js';
模块不需要通过其导出的成员导入。如果不需要从模块中导出特定的绑定,但仍然需要加载和执行该模块的副作用,则可以仅使用其路径加载它:
import './foo.js';
导入被视为模块的只读视图,实际上与const
声明的变量相同。使用*
执行批量导入时,命名导出的别名集合的行为就像使用Object.freeze()
处理一样。尽管仍然可以修改导出对象的属性,但无法直接操作导出值。也不允许添加或删除导出集合的导出属性。必须使用可以访问内部变量和属性的导出方法来更改导出值。
import foo, * as Foo from './foo.js';
foo = 'foo'; // Error
Foo.foo = 'foo'; // Error
foo.bar = 'bar'; // Allowed
命名导出和默认导出之间的区别体现在它们的导入方式上。可以批量获取命名导出,而无需使用*指定其确切标识符并为导出集合提供标识符:
const foo = 'foo', bar = 'bar', baz = 'baz';
export { foo, bar, baz }
import * as Foo from './foo.js';
console.log(Foo.foo); // foo
console.log(Foo.bar); // bar
console.log(Foo.baz); // baz
要执行显式导入,可以将标识符放在import子句中。使用import子句还允许为导入指定别名:
import { foo, bar, baz as myBaz } from './foo.js';
console.log(foo); // foo
console.log(bar); // bar
console.log(myBaz); // baz
默认导出的行为就像模块目标是导出的值一样。可以使用default关键字并提供别名来导入它们;或者,它们可以在不使用花括号的情况下导入,并且指定的标识符实际上是默认导出的别名:
// Equivalent
import { default as foo } from './foo.js';
import foo from './foo.js';
如果一个模块同时导出命名导出和默认导出,则可以在同一个导入语句中获取它们。可以通过枚举特定导出或使用*来执行:
import foo, { bar, baz } from './foo.js';
import { default as foo, bar, baz } from './foo.js';
import foo, * as Foo from './foo.js';
模块转移导出
导入的值可以直接通过管道传输到导出。还可以将默认导出转换为命名导出,反之亦然。如果想将一个模块的所有命名导出合并到另一个模块中,可以使用 export * from './foo.js';
完成。
导入 bar.js
时,foo.js
中的所有命名导出都将可用。如果 foo.js
有默认值,此语法将忽略它。此语法还需要注意导出名称冲突。如果 foo.js
导出 baz
,而 bar.js
也导出 baz
,则最终导出值将是 bar.js
中指定的值。这种“覆盖”将静默地发生:
// foo.js
export const baz = 'origin:foo';
// bar.js
export * from './foo.js';
export const baz = 'origin:bar';
// main.js
import { baz } from './bar.js';
console.log(baz); // origin:bar
还可以枚举外部模块中的哪些值被传递到本地导出。此语法支持别名:
export { default } from './foo.js';
这不会执行导出的任何副本;它只是将导入的引用传播到原始模块。导入的定义值仍然存在于原始模块中,并且涉及导入突变的相同限制适用于重新导出的导入。
执行重新导出时,还可以更改导入模块的命名/默认名称。命名导入可以指定为默认导出,如下所示:
export { foo as default } from './foo.js';
# Worker 模块
ECMAScript 6 模块与 Worker 实例完全兼容。在实例化时,可以像传递普通脚本文件一样向 Worker 传递一个模块文件的路径。Worker 构造函数接受第二个参数,允许通知它正在传递一个模块文件。
两种类型的 Worker 实例化:
// 第二个参数默认为 { type: 'classic' }
const scriptWorker = new Worker('scriptWorker.js');
const moduleWorker = new Worker('moduleWorker.js', { type: 'module' });
向后兼容
因为 ECMAScript 模块兼容性的采用将是渐进的,所以早期的模块采用者能够为支持模块的浏览器和不支持模块的浏览器进行开发是很有价值的。对于希望尽可能在浏览器中本地使用 ECMAScript 6 模块的用户,解决方案将涉及提供两个版本的代码——基于模块的版本和基于脚本的版本。如果不希望这样做,则使用第三方模块系统(例如 SystemJS)或在构建时向下转换 ES6 模块是更好的选择。
第一种策略涉及检查服务器上浏览器的用户代理,将其与支持模块的已知浏览器列表进行匹配,并使用它来决定提供哪些 JS 文件。这种方法脆弱且复杂,不推荐使用。一个更好、更优雅的解决方案是使用脚本类型属性和脚本 nomodule 属性。
当浏览器无法识别一个 <script>
元素的 type 属性值时,它将拒绝执行其内容。对于不支持模块的旧版浏览器,这意味着 <script type="module">
永远不会执行。所以可以放置一个应急 <script>
标签到 <script type="module">
后面:
// Legacy browser will not execute this
<script type="module" src="module.js"></script>
// Legacy browser will execute this
<script src="script.js"></script>
当然,这留下了支持模块的浏览器的问题。在这种情况下,前面的代码将执行两次——显然这是一个不受欢迎的结果。为了防止这种情况,原生支持 ECMAScript 6 模块的浏览器也会识别 nomodule 属性。该属性通知支持 ES6 模块的浏览器不要执行脚本。旧版浏览器将无法识别该属性并忽略它。
因此,以下配置将产生一个设置,其中现代和传统浏览器都将执行这些脚本之一:
// Modern browser will execute this
// Legacy browser will not execute this
<script type="module" src="module.js"></script>
// Modern browser will not execute this
// Legacy browser will execute this
<script nomodule src="script.js"></script>