特别说明,为便于查阅,文章转自https://github.com/getify/You-Dont-Know-JS
CommonJS
有一种相似,但不是完全兼容的模块语法,称为CommonJS,那些使用Node.js生态系统的人很熟悉它。
不太委婉地说,从长久看来,ES6模块实质上将要取代所有先前的模块格式与标准,即便是CommonJS,因为它们是建立在语言的语法支持上的。如果除了普遍性以外没有其他原因,迟早ES6将不可避免地作为更好的方式胜出。
但是,要达到那一天我们还有相当长的路要走。在服务器端的JavaScript世界中差不多有成百上千的CommonJS风格模块,而在浏览器的世界里各种格式标准的模块(UMD,AMD,临时性的模块方案)数量还要多十倍。这要花许多年过渡才能取得任何显著的进展。
在这个过渡期间,模块转译器/转换器将是绝对必要的。你可能刚刚适应了这种新的现实。不论你是使用正规的模块,AMD,UMD,CommonJS,或者ES6,这些工具都不得不解析并转换为适合你代码运行环境的格式。
对于Node.js,这可能意味着(目前)转换的目标是CommonJS。对于浏览器来说,可能是UMD或者AMD。除了在接下来的几年中随着这些工具的成熟和最佳实践的出现而发生的许多变化。
从现在起,我能对模块的提出的最佳建议是:不管你曾经由于强烈的爱好而虔诚地追随哪一种格式,都要培养对理解ES6模块的欣赏能力,并让你对其他模块模式的倾向性渐渐消失掉。它们就是JS中模块的未来,即便现实有些偏差。
新的方式
使用ES6模块的两个主要的新关键字是import
和export
。在语法上有许多微妙的地方,那么让我们深入地看看。
警告: 一个容易忽视的重要细节:import
和export
都必须总是出现在它们分别被使用之处的顶层作用域。例如,你不能把import
或export
放在一个if
条件内部;它们必须出现在所有块儿和函数的外部。
export
API成员
export
关键字要么放在一个声明的前面,要么就与一组特殊的要被导出的绑定一起用作一个操作符。考虑如下代码:
export function foo() {
// ..
}
export var awesome = 42;
var bar = [1,2,3];
export { bar };
表达相同导出的另一种方法:
function foo() {
// ..
}
var awesome = 42;
var bar = [1,2,3];
export { foo, awesome, bar };
这些都称为 命名导出,因为你实际上导出的是变量/函数/等等其他的名称绑定。
任何你没有使用export
标记 的东西将在模块作用域的内部保持私有。也就是说,虽然有些像var bar = ..
的东西看起来像是在顶层全局作用域中声明的,但是这个顶层作用域实际上是模块本身;在模块中没有全局作用域。
注意: 模块确实依然可以访问挂在它外面的window
和所有的“全局”,只是不作为顶层词法作用域而已。但是,你真的应该在你的模块中尽可能地远离全局。
你还可以在命名导出期间“重命名”(也叫别名)一个模块成员:
function foo() { .. }
export { foo as bar };
当这个模块被导入时,只有成员名称bar
可以用于导入;foo
在模块内部保持隐藏。
模块导出不像你习以为常的=
赋值操作符那样,仅仅是值或引用的普通赋值。实际上,当你导出某些东西时,你导出了一个对那个东西(变量等)的一个绑定(有些像指针)。
在你的模块内部,如果你改变一个你已经被导出绑定的变量的值,即使它已经被导入了(见下一节),这个被导入的绑定也将解析为当前的(更新后的)值。
考虑如下代码:
var awesome = 42;
export { awesome };
// 稍后
awesome = 100;
当这个模块被导入时,无论它是在awesome = 100
设定的之前还是之后,一旦这个赋值发生,被导入的绑定都将被解析为值100
,不是42
。
这是因为,这个绑定实质上是一个指向变量awesome
本身的一个引用,或指针,而不是它的值的一个拷贝。ES6模块绑定引入了一个对于JS来说几乎是史无前例的概念。
虽然你显然可以在一个模块定义的内部多次使用export
,但是ES6绝对偏向于一个模块只有一个单独导出的方式,这称为 默认导出。用TC39协会的一些成员的话说,如果你遵循这个模式你就可以“获得更简单的import
语法作为奖励”,如果你不遵循你就会反过来得到更繁冗的语法作为“惩罚”。
一个默认导出将一个特定的导出绑定设置为在这个模块被导入时的默认绑定。这个绑定的名称是字面上的default
。正如你即将看到的,在导入模块绑定时你还可以重命名它们,你经常会对默认导出这么做。
每个模块定义只能有一个default
。我们将在下一节中讲解import
,你将看到如果模块拥有默认导入时import
语法如何变得更简洁。
默认导出语法有一个微妙的细节你应当多加注意。比较这两个代码段:
function foo(..) {
// ..
}
export default foo;
和这一个:
function foo(..) {
// ..
}
export { foo as default };
在第一个代码段中,你导出的是那一个函数表达式在那一刻的值的绑定,不是 标识符foo
的绑定。换句话说,export default ..
接收一个表达式。如果你稍后在你的模块内部赋给foo
一个不同的值,这个模块导入将依然表示原本被导出的函数,而不是那个新的值。
顺带一提,第一个代码段还可以写做:
export default function foo(..) {
// ..
}
警告: 虽然技术上讲这里的function foo..
部分是一个函数表达式,但是对于模块内部作用域来说,它被视为一个函数声明,因为名称foo
被绑定在模块的顶层作用域(经常称为“提升”)。对export default var foo = ..
也是如此。然而,虽然你 可以 export var foo = ..
,但是一个令人沮丧的不一致是,你目前还不能export default bar foo = ..
(或者let
和const
)。在写作本书时,为了保持一致性,已经开始了在后ES6不久的时期增加这种能力的讨论。
再次回想一下第二个代码段:
function foo(..) {
// ..
}
export { foo as default };
这种版本的模块导出中,默认导出的绑定实际上是标识符foo
而不是它的值,所以你会得到先前描述过的绑定行为(也就是,如果你稍后改变foo
的值,在导入一端看到的值也会被更新)。
要非常小心这种默认导出语法的微妙区别,特别是在你的逻辑需要导出的值要被更新时。如果你永远不打算更新一个默认导出的值,export default ..
就没问题。如果你确实打算更新这个值,你必须使用export { .. as default }
。无论哪种情况,都要确保注释你的代码以解释你的意图!
因为一个模块只能有一个default
,这可能会诱使你将你的模块设计为默认导出一个带有你所有API方法的对象,就像这样:
export default {
foo() { .. },
bar() { .. },
..
};
这种模式看起来十分接近于许多开发者构建它们的前ES6模块时曾经用过的模式,所以它看起来像是一种十分自然的方式。不幸的是,它有一些缺陷并且不为官方所鼓励使用。
特别是,JS引擎不能静态地分析一个普通对象的内容,这意味着它不能为静态import
性能进行一些优化。使每个成员独立地并明确地导出的好处是,引擎 可以 进行静态分析和性能优化。
如果你的API已经有多于一个的成员,这些原则 —— 一个模块一个默认导出,和所有API成员作为被命名的导出 —— 看起来是冲突的,不是吗?但是你 可以 有一个单独的默认导出并且有其他的被命名导出;它们不是互相排斥的。
所以,取代这种(不被鼓励使用的)模式:
export default function foo() { .. }
foo.bar = function() { .. };
foo.baz = function() { .. };
你可以这样做:
export default function foo() { .. }
export function bar() { .. }
export function baz() { .. }
注意: 在前面这个代码段中,我为标记为default
的函数使用了名称foo
。但是,这个名称foo
为了导出的目的而被忽略掉了 —— default
才是实际上被导出的名称。当你导入这个默认绑定时,你可以叫它任何你想用的名字,就像你将在下一节中看到的。
或者,一些人喜欢:
function foo() { .. }
function bar() { .. }
function baz() { .. }
export { foo as default, bar, baz, .. };
混合默认和被命名导出的效果将在稍后我们讲解import
时更加清晰。但它实质上意味着最简洁的默认导入形式将仅仅取回foo()
函数。用户可以额外地手动罗列bar
和baz
作为命名导入,如果他们想用它们的话。
你可能能够想象,如果你的模块有许多命名导出绑定,那么对于模块的消费者来说将有多么乏味。有一个通配符导入形式,你可以在一个名称空间对象中导入一个模块的所有导出,但是没有办法用通配符导入到顶层绑定。
要重申的是,ES6模块机制被有意设计为不鼓励带有许多导出的模块;相对而言,它被期望成为一种更困难一些的,作为某种社会工程的方式,以鼓励对大型/复杂模块设计有利的简单模块设计。
我将可能推荐你不要将默认导出与命名导出混在一起,特别是当你有一个大型API,并且将它重构为分离的模块是不现实或不希望的时候。在这种情况下,就都使用命名导出,并在文档中记录你的模块的消费者可能应当使用import * as ..
(名称空间导入,在下一节中讨论)方式来将整个API一次性地带到一个单独的名称空间中。
我们早先提到过这一点,但让我们回过头来更详细地讨论一下。除了导出一个表达式的值的绑定的export default ...
形式,所有其他的导出形式都导出本地标识符的绑定。对于这些绑定,如果你在导出之后改变一个模块内部变量的值,外部被导入的绑定将可以访问这个被更新的值:
var foo = 42;
export { foo as default };
export var bar = "hello world";
foo = 10;
bar = "cool";
当你导出这个模块时,default
和bar
导出将会绑定到本地变量foo
和bar
,这意味着它们将反映被更新的值10
和"cool"
。在被导出时的值是无关紧要的。在被导入时的值是无关紧要的。这些绑定是实时的链接,所以唯一重要的是当你访问这个绑定时它当前的值是什么。
警告: 双向绑定是不允许的。如果你从一个模块中导入一个foo
,并试图改变你导入的变量foo
的值,一个错误就会被抛出!我们将在下一节重新回到这个问题。
你还可以重新导出另一个模块的导出,比如:
export { foo, bar } from "baz";
export { foo as FOO, bar as BAR } from "baz";
export * from "baz";
这些形式都与首先从"baz"
模块导入然后为了从你的模块中到处而明确地罗列它的成员相似。然而,在这些形式中,模块"baz"
的成员从没有被导入到你的模块的本地作用域;某种程度上,它们原封不动地穿了过去。
import
API成员
要导入一个模块,你将不出意料地使用import
语句。就像export
有几种微妙的变化一样,import
也有,所以你要花相当多的时间来考虑下面的问题,并试验你的选择。
如果你想要导入一个模块的API中的特定命名成员到你的顶层作用域,使用这种语法:
import { foo, bar, baz } from "foo";
警告: 这里的{ .. }
语法可能看起来像一个对象字面量,甚至是像一个对象解构语法。但是,它的形式仅对模块而言是特殊的,所以不要将它与其他地方的{ .. }
模式搞混了。
字符串"foo"
称为一个 模块指示符。因为它的全部目的在于可以静态分析的语法,所以模块指示符必须是一个字符串字面量;它不能是一个持有字符串值的变量。
从你的ES6代码和JS引擎本身的角度来看,这个字符串字面量的内容是完全不透明和没有意义的。模块加载器将会把这个字符串翻译为一个在何处寻找被期望的模块的指令,不是作为一个URL路径就是一个本地文件系统路径。
被罗列的标识符foo
,bar
和baz
必须匹配在模块的API上的命名导出(这里将会发生静态分析和错误断言)。它们在你当前的作用域中被绑定为顶层标识符。
import { foo } from "foo";
foo();
你可以重命名被导入的绑定标识符,就像:
import { foo as theFooFunc } from "foo";
theFooFunc();
如果这个模块仅有一个你想要导入并绑定到一个标识符的默认导出,你可以为这个绑定选择性地跳过外围的{ .. }
语法。在这种首选情况下import
会得到最好的最简洁的import
语法形式:
import foo from "foo";
// 或者:
import { default as foo } from "foo";
注意: 正如我们在前一节中讲解过的,一个模块的export
中的default
关键字指定了一个名称实际上为default
的命名导出,正如在第二个更加繁冗的语法中展示的那样。在这个例子中,从default
到foo
的重命名在后者的语法中是明确的,并且与前者隐含地重命名是完全相同的。
如果模块有这样的定义,你还可以与其他的命名导出一起导入一个默认导出。回忆一下先前的这个模块定义:
export default function foo() { .. }
export function bar() { .. }
export function baz() { .. }
要引入这个模块的默认导出和它的两个命名导出:
import FOOFN, { bar, baz as BAZ } from "foo";
FOOFN();
bar();
BAZ();
ES6的模块哲学强烈推荐的方式是,你只从一个模块中导入你需要的特定的绑定。如果一个模块提供10个API方法,但是你只需它们中的两个,有些人认为带入整套API绑定是一种浪费。
一个好处是,除了代码变得更加明确,收窄导入使得静态分析和错误检测(例如,不小心使用了错误的绑定名称)变得更加健壮。
当然,这只是受ES6设计哲学影响的标准观点;没有什么东西要求我们坚持这种方式。
许多开发者可能很快指出这样的方式更令人厌烦,每次你发现自己需要一个模块中的其他某些东西时,它要求你经常地重新找到并更新你的import
语句。它的代价是牺牲便利性。
以这种观点看,首选方式可能是将模块中的所有东西都导入到一个单独的名称空间中,而不是将每个个别的成员直接导入到作用域中。幸运的是,import
语句拥有一个变种语法可以支持这种风格的模块使用,它被称为 名称空间导入。
考虑一个被这样导出的"foo"
模块:
export function bar() { .. }
export var x = 42;
export function baz() { .. }
你可以将整个API导入到一个单独的模块名称空间绑定中:
import * as foo from "foo";
foo.bar();
foo.x; // 42
foo.baz();
注意: * as ..
子句要求使用*
通配符。换句话说,你不能做像import { bar, x } as foo from "foo"
这样的事情来将API的一部分绑定到foo
名称空间。我会很喜欢这样的东西,但是对ES6的名称空间导入来说,要么全有要么全无。
如果你正在使用* as ..
导入的模块拥有一个默认导出,它会在指定的名称空间中被命名为default
。你可以在这个名称空间绑定的外面,作为一个顶层标识符额外地命名这个默认导出。考虑一个被这样导出的"world"
模块:
export default function foo() { .. }
export function bar() { .. }
export function baz() { .. }
和这个import
:
import foofn, * as hello from "world";
foofn();
hello.default();
hello.bar();
hello.baz();
虽然这个语法是合法的,但是它可能令人困惑:这个模块的一个方法(那个默认导出)被绑定到你作用域的顶层,然而其他的命名导出(而且之中之一称为default
)作为一个不同名称(hello
)的标识符名称空间的属性被绑定。
正如我早先提到的,我的建议是避免这样设计你的模块导出,以降低你模块的用户受困于这些奇异之处的可能性。
所有被导入的绑定都是不可变和/或只读的。考虑前面的导入;所有这些后续的赋值尝试都将抛出TypeError
:
import foofn, * as hello from "world";
foofn = 42; // (运行时)TypeError!
hello.default = 42; // (运行时)TypeError!
hello.bar = 42; // (运行时)TypeError!
hello.baz = 42; // (运行时)TypeError!
回忆早先在“export
API成员”一节中,我们谈到bar
和baz
绑定是如何被绑定到"world"
模块内部的实际标识符上的。它意味着如果模块改变那些值,hello.bar
和hello.baz
将引用更新后的值。
但是你的本地导入绑定的不可变/只读的性质强制你不能从被导入的绑定一方改变他们,不然就会发生TypeError
。这很重要,因为如果没有这种保护,你的修改将会最终影响所有其他该模块的消费者(记住:单例),这可能会产生一些非常令人吃惊的副作用!
另外,虽然一个模块 可以 从内部改变它的API成员,但你应当对有意地以这种风格设计你的模块非常谨慎。ES6模块 被预计 是静态的,所以背离这个原则应当是不常见的,而且应当在文档中被非常小心和详细地记录下来。
警告: 存在一些这样的模块设计思想,你实际上打算允许一个消费者改变你的API上的一个属性的值,或者模块的API被设计为可以通过向API的名称空间中添加“插件”来“扩展”。但正如我们刚刚断言的,ES6模块API应当被认为并设计为静态的和不可变的,这强烈地约束和不鼓励那些其他的模块设计模式。你可以通过导出一个普通对象 —— 它理所当然是可以随意改变的 —— 来绕过这些限制。但是在选择这条路之前要三思而后行。
作为一个import
的结果发生的声明将被“提升”(参见本系列的 作用域与闭包)。考虑如下代码:
foo();
import { foo } from "foo";
foo()
可以运行是因为import ..
语句的静态解析不仅在编译时搞清了foo
是什么,它还将这个声明“提升”到模块作用域的顶部,如此使它在模块中通篇都是可用的。
最后,最基本的import
形式看起来像这样:
import "foo";
这种形式实际上不会将模块的任何绑定导入到你的作用域中。它加载(如果还没被加载过),编译(如果还没被编译过),并对"foo"
模块求值(如果还没被运行过)。
一般来说,这种导入可能不会特别有用。可能会有一些模块的定义拥有副作用(比如向window
/全局对象赋值)的特殊情况。你还可以将import "foo"
用作稍后可能需要的模块的预加载。
模块循环依赖
A导入B。B导入A。这将如何工作?
我要立即声明,一般来说我会避免使用刻意的循环依赖来设计系统。话虽如此,我也认识到人们这么做是有原因的,而且它可以解决一些艰难的设计问题。
让我们考虑一下ES6如何处理这种情况。首先,模块"A"
:
import bar from "B";
export default function foo(x) {
if (x > 10) return bar( x - 1 );
return x * 2;
}
现在,是模块"B"
:
import foo from "A";
export default function bar(y) {
if (y > 5) return foo( y / 2 );
return y * 3;
}
这两个函数,foo(..)
和bar(..)
,如果它们在相同的作用域中就会像标准的函数声明那样工作,因为声明被“提升”至整个作用域,而因此与它们的编写顺序无关,它们互相是可用的。
在模块中,你的声明在完全不同的作用域中,所以ES6必须做一些额外的工作以使这些循环引用工作起来。
在大致的概念上,这就是循环的import
依赖如何被验证和解析的:
如果模块
"A"
被首先加载,第一步将是扫描文件并分析所有的导出,这样就可以为导入注册所有可用的绑定。然后它处理import .. from "B"
,这指示它需要去取得"B"
。一旦引擎加载了
"B"
,它会做同样的导出绑定分析。当它看到import .. from "A"
时,它知道"A"
的API已经准备好了,所以它可以验证这个import
为合法的。现在它知道了"B"
的API,它也可以验证在模块"A"
中等待的import .. from "B"
了。
实质上,这种相互导入,连同对两个import
语句合法性的静态验证,虚拟地组合了两个分离的模块作用域(通过绑定),因此foo(..)
可以调用bar(..)
或相反。这与我们在相同的作用域中声明是对称的。
现在让我们试着一起使用这两个模块。首先,我们将试用foo(..)
:
import foo from "foo";
foo( 25 ); // 11
或者我们可以试用bar(..)
:
import bar from "bar";
bar( 25 ); // 11.5
在foo(25)
调用bar(25)
被执行的时刻,所有模块的所有分析/编译都已经完成了。这意味着foo(..)
内部地直接知道bar(..)
,而且bar(..)
内部地直接知道foo(..)
。
如果所有我们需要的仅是与foo(..)
互动,那么我们只需要导入"foo"
模块。bar(..)
和"bar"
模块也同理。
当然,如果我们想,我们 可以 导入并使用它们两个:
import foo from "foo";
import bar from "bar";
foo( 25 ); // 11
bar( 25 ); // 11.5
import
语句的静态加载语义意味着通过import
互相依赖对方的"foo"
和"bar"
将确保在它们运行前被加载,解析,和编译。所以它们的循环依赖是被静态地解析的,而且将会如你所愿地工作。
模块加载
我们在“模块”这一节的最开始声称,import
语句使用了一个由宿主环境(浏览器,Node.js,等等)提供的分离的机制,来实际地将模块指示符字符串解析为一些对寻找和加载所期望模块的有用的指令。这种机制就是系统 模块加载器。
由环境提供的默认模块加载器,如果是在浏览器中将会把模块指示符解释为一个URL,如果是在服务器端(一般地)将会解释为一个本地文件系统路径,比如Node.js。它的默认行为是假定被加载的文件是以ES6标准的模块格式编写的。
另外,与当下脚本程序被加载的方式相似,你将可以通过一个HTML标签将一个模块加载到浏览器中。在本书写作时,这个标签将会是还是
还不完全清楚。ES6没有控制这个决定,但是在相应的标准化机构中的讨论早已随着ES6开始了。
无论这个标签看起来什么样,你可以确信它的内部将会使用默认加载器(或者一个你预先指定好的加载器,就像我们将在下一节中讨论的)。
就像你将在标记中使用的标签一样,ES6没有规定模块加载器本身。它是一个分离的,目前由WHATWG浏览器标准化小组控制的平行的标准。(http://whatwg.github.io/loader/)
在本书写作时,接下来的讨论反映了它的API设计的一个早期版本,和一些可能将要改变的东西。
加载模块之外的模块
一个与模块加载器直接交互的用法,是当一个非模块需要加载一个模块时。考虑如下代码:
// 在浏览器中通过`