第三章:组织 2

特别说明,为便于查阅,文章转自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模块的两个主要的新关键字是importexport。在语法上有许多微妙的地方,那么让我们深入地看看。

警告: 一个容易忽视的重要细节:importexport都必须总是出现在它们分别被使用之处的顶层作用域。例如,你不能把importexport放在一个if条件内部;它们必须出现在所有块儿和函数的外部。

exportAPI成员

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 = ..(或者letconst)。在写作本书时,为了保持一致性,已经开始了在后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()函数。用户可以额外地手动罗列barbaz作为命名导入,如果他们想用它们的话。

你可能能够想象,如果你的模块有许多命名导出绑定,那么对于模块的消费者来说将有多么乏味。有一个通配符导入形式,你可以在一个名称空间对象中导入一个模块的所有导出,但是没有办法用通配符导入到顶层绑定。

要重申的是,ES6模块机制被有意设计为不鼓励带有许多导出的模块;相对而言,它被期望成为一种更困难一些的,作为某种社会工程的方式,以鼓励对大型/复杂模块设计有利的简单模块设计。

我将可能推荐你不要将默认导出与命名导出混在一起,特别是当你有一个大型API,并且将它重构为分离的模块是不现实或不希望的时候。在这种情况下,就都使用命名导出,并在文档中记录你的模块的消费者可能应当使用import * as ..(名称空间导入,在下一节中讨论)方式来将整个API一次性地带到一个单独的名称空间中。

我们早先提到过这一点,但让我们回过头来更详细地讨论一下。除了导出一个表达式的值的绑定的export default ...形式,所有其他的导出形式都导出本地标识符的绑定。对于这些绑定,如果你在导出之后改变一个模块内部变量的值,外部被导入的绑定将可以访问这个被更新的值:

var foo = 42;
export { foo as default };

export var bar = "hello world";

foo = 10;
bar = "cool";

当你导出这个模块时,defaultbar导出将会绑定到本地变量foobar,这意味着它们将反映被更新的值10"cool"。在被导出时的值是无关紧要的。在被导入时的值是无关紧要的。这些绑定是实时的链接,所以唯一重要的是当你访问这个绑定时它当前的值是什么。

警告: 双向绑定是不允许的。如果你从一个模块中导入一个foo,并试图改变你导入的变量foo的值,一个错误就会被抛出!我们将在下一节重新回到这个问题。

你还可以重新导出另一个模块的导出,比如:

export { foo, bar } from "baz";
export { foo as FOO, bar as BAR } from "baz";
export * from "baz";

这些形式都与首先从"baz"模块导入然后为了从你的模块中到处而明确地罗列它的成员相似。然而,在这些形式中,模块"baz"的成员从没有被导入到你的模块的本地作用域;某种程度上,它们原封不动地穿了过去。

importAPI成员

要导入一个模块,你将不出意料地使用import语句。就像export有几种微妙的变化一样,import也有,所以你要花相当多的时间来考虑下面的问题,并试验你的选择。

如果你想要导入一个模块的API中的特定命名成员到你的顶层作用域,使用这种语法:

import { foo, bar, baz } from "foo";

警告: 这里的{ .. }语法可能看起来像一个对象字面量,甚至是像一个对象解构语法。但是,它的形式仅对模块而言是特殊的,所以不要将它与其他地方的{ .. }模式搞混了。

字符串"foo"称为一个 模块指示符。因为它的全部目的在于可以静态分析的语法,所以模块指示符必须是一个字符串字面量;它不能是一个持有字符串值的变量。

从你的ES6代码和JS引擎本身的角度来看,这个字符串字面量的内容是完全不透明和没有意义的。模块加载器将会把这个字符串翻译为一个在何处寻找被期望的模块的指令,不是作为一个URL路径就是一个本地文件系统路径。

被罗列的标识符foobarbaz必须匹配在模块的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的命名导出,正如在第二个更加繁冗的语法中展示的那样。在这个例子中,从defaultfoo的重命名在后者的语法中是明确的,并且与前者隐含地重命名是完全相同的。

如果模块有这样的定义,你还可以与其他的命名导出一起导入一个默认导出。回忆一下先前的这个模块定义:

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成员”一节中,我们谈到barbaz绑定是如何被绑定到"world"模块内部的实际标识符上的。它意味着如果模块改变那些值,hello.barhello.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标签将一个模块加载到浏览器中。在本书写作时,这个标签将会是<script type="module">还是<module>还不完全清楚。ES6没有控制这个决定,但是在相应的标准化机构中的讨论早已随着ES6开始了。

无论这个标签看起来什么样,你可以确信它的内部将会使用默认加载器(或者一个你预先指定好的加载器,就像我们将在下一节中讨论的)。

就像你将在标记中使用的标签一样,ES6没有规定模块加载器本身。它是一个分离的,目前由WHATWG浏览器标准化小组控制的平行的标准。(http://whatwg.github.io/loader/)

在本书写作时,接下来的讨论反映了它的API设计的一个早期版本,和一些可能将要改变的东西。

加载模块之外的模块

一个与模块加载器直接交互的用法,是当一个非模块需要加载一个模块时。考虑如下代码:

// 在浏览器中通过`<script>`加载的普通script,
// `import`在这里是不合法的

Reflect.Loader.import( "foo" ) // 返回一个`"foo"`的promise
.then( function(foo){
    foo.bar();
} );

工具Reflect.Loader.import(..)将整个模块导入到命名参数中(作为一个名称空间),就像我们早先讨论过的import * as foo ..名称空间导入。

注意: Reflect.Loader.import(..)返回一个promise,它在模块准备好时被完成。要导入多个模块的话,你可以使用Promise.all([ .. ])将多个Reflect.Loader.import(..)的promise组合起来。有关Promise的更多信息,参见第四章的“Promise”。

你还可以在一个真正的模块中使用Reflect.Loader.import(..)来动态地/条件性地加载一个模块,这是import自身无法做到的。例如,你可能在一个特性测试表明某个ES7+特性没有被当前的引擎所定义的情况下,选择性地加载一个含有此特性的填补的模块。

由于性能的原因,你将想要尽量避免动态加载,因为它阻碍了JS引擎从它的静态分析中提前获取的能力。

自定义加载

直接与模块加载器交互的另外一种用法是,你想要通过配置或者甚至是重定义来定制它的行为。

在本书写作时,有一个被开发好的模块加载器API的填补(https://github.com/ModuleLoader/es6-module-loader)。虽然关于它的细节非常匮乏,而且很可能改变,但是我们可以通过它来探索最终可能固定下来的东西是什么。

Reflect.Loader.import(..)调用可能会支持第二个参数,它指定各种选项来定制导入/加载任务。例如:

Reflect.Loader.import( "foo", { address: "/path/to/foo.js" } )
.then( function(foo){
    // ..
} )

还有一种预期是,会为一个自定义内容提供某种机制来将之挂钩到模块加载的处理过程中,就在翻译/转译可能发生的加载之后,但是在引擎编译这个模块之前。

例如,你可能会加载某些还不是ES6兼容的模块格式的东西(例如,CoffeeScript,TypeScript,CommonJS,AMD)。你的翻译步骤可能会为了后面的引擎处理而将它转换为ES6兼容的模块。

几乎从JavaScript的最开始的那时候起,语法和开发模式都曾努力(读作:挣扎地)地戴上一个支持面向类的开发的假面具。伴随着newinstanceof和一个.constructor属性,谁能不认为JS在它的原型系统的某个地方藏着类机制呢?

当然,JS的“类”与经典的类完全不同。其区别有很好的文档记录,所以在此我不会在这一点上花更多力气。

注意: 要学习更多关于在JS中假冒“类”的模式,以及另一种称为“委托”的原型的视角,参见本系列的 this与对象原型 的后半部分。

class

虽然JS的原型机制与传统的类的工作方式不同,但是这并不能阻挡一种强烈的潮流 —— 要求这门语言扩展它的语法糖以便将“类”表达得更像真正的类。让我们进入ES6class关键字和它相关的机制。

这个特性是一个具有高度争议、旷日持久的争论的结果,而且代表了几种对关于如何处理JS类的强烈反对意见的妥协的一小部分。大多数希望JS拥有完整的类机制的开发者将会发现新语法的一些部分十分吸引人,但是也会发现一些重要的部分仍然缺失了。但不要担心,TC39已经致力于另外的特性,以求在后ES6时代中增强类机制。

新的ES6类机制的核心是class关键字,它标识了一个 ,其内容定义了一个函数的原型的成员。考虑如下代码:

class Foo {
    constructor(a,b) {
        this.x = a;
        this.y = b;
    }

    gimmeXY() {
        return this.x * this.y;
    }
}

一些要注意的事情:

  • class Foo 暗示着创建一个(特殊的)名为Foo的函数,与你在前ES6中所做的非常相似。
  • constructor(..)表示了这个Foo(..)函数的签名,和它的函数体内容。
  • 类方法同样使用对象字面量中可以使用的“简约方法”语法,正如在第二章中讨论过的。这也包括在本章早先讨论过的简约generator,以及ES5的getter/setter语法。但是,类方法是不可枚举的而对象方法默认是可枚举的。
  • 与对象字面量不同的是,在一个class内容的部分没有逗号分隔各个成员!事实上,这甚至是不允许的。

前一个代码段的class语法定义可以大致认为和这个前ES6等价物相同,对于那些以前做过原型风格代码的人来说可能十分熟悉它:

function Foo(a,b) {
    this.x = a;
    this.y = b;
}

Foo.prototype.gimmeXY = function() {
    return this.x * this.y;
}

不管是前ES6形式还是新的ES6class形式,这个“类”现在可以被实例化并如你所想地使用了:

var f = new Foo( 5, 15 );

f.x;                        // 5
f.y;                        // 15
f.gimmeXY();                // 75

注意!虽然class Foo看起来很像function Foo(),但是有一些重要的区别:

  • class Foo的一个Foo(..)调用 必须new一起使用,因为前ES6的Foo.call( obj )方式 不能 工作。
  • 虽然function Foo会被“提升”(参见本系列的 作用域与闭包),但是class Foo不会;extends ..指定的表达式不能被“提升”。所以,在你能够实例化一个class之前必须先声明它。
  • 在顶层全局作用域中的class Foo在这个作用域中创建了一个词法标识符Foo,但与此不同的是function Foo不会创建一个同名的全局对象属性。

已经建立的instanceof操作仍然可以与ES6的类一起工作,因为class只是创建了一个同名的构造器函数。然而,ES6引入了一个定制instanceof如何工作的方法,使用Symbol.hasInstance(参见第七章的“通用Symbol”)。

我发现另一种更方便地考虑class的方法是,将它作为一个用来自动填充proptotype对象的 。可选的是,如果使用extends(参见下一节)的话它还能连接[[Prototype]]关系。

其实一个ES6class本身不是一个实体,而是一个元概念,它包裹在其他具体实体上,例如函数和属性,并将它们绑在一起。

提示: 除了这种声明的形式,一个class还可以是一个表达式,就像:var x = class Y { .. }。这主要用于将类的定义(技术上说,是构造器本身)作为函数参数值传递,或者将它赋值给一个对象属性。

extendssuper

ES6的类还有一种语法糖,用于在两个函数原型之间建立[[Prototype]]委托链 —— 通常被错误地标记为“继承”或者令人困惑地标记为“原型继承” —— 使用我们熟悉的面向类的术语extends

class Bar extends Foo {
    constructor(a,b,c) {
        super( a, b );
        this.z = c;
    }

    gimmeXYZ() {
        return super.gimmeXY() * this.z;
    }
}

var b = new Bar( 5, 15, 25 );

b.x;                        // 5
b.y;                        // 15
b.z;                        // 25
b.gimmeXYZ();               // 1875

一个有重要意义的新增物是super,它实际上在前ES6中不是直接可能的东西(不付出一些不幸的黑科技的代价的话)。在构造器中,super自动指向“父构造器”,这在前一个例子中是Foo(..)。在方法中,它指向“父对象”,如此你就可以访问它上面的属性/方法,比如super.gimmeXY()

Bar extends Foo理所当然地意味着将Bar.prototype[[Prototype]]链接到Foo.prototype。所以,在gimmeXYZ()这样的方法中的super特被地意味着Foo.prototype,而当super用在Bar构造器中时意味着Foo

注意: super不仅限于class声明。它也可以在对象字面量中工作,其方式在很大程度上与我们在此讨论的相同。更多信息参见第二章中的“对象super”。

super的坑

注意到super的行为根据它出现的位置不同而不同是很重要的。公平地说,大多数时候这不是一个问题。但是如果你背离一个狭窄的规范,令人诧异的事情就会等着你。

可能会有这样的情况,你想在构造器中引用Foo.prototype,比如直接访问它的属性/方法之一。然而,在构造器中的super不能这样被使用;super.prototype将不会工作。super(..)大致上意味着调用new Foo(..),但它实际上不是一个可用的对Foo本身的引用。

与此对称的是,你可能想要在一个非构造器方法中引用Foo(..)函数。super.constructor将会指向Foo(..)函数,但是要小心这个函数 只能new一起被调用。new super.constructor(..)将是合法的,但是在大多数情况下它都不是很有用, 因为你不能使这个调用使用或引用当前的this对象环境,而这很可能是你想要的。

另外,super看起来可能就像this一样是被函数的环境所驱动的 —— 也就是说,它们都是被动态绑定的。但是,super不像this那样是动态的。当声明时一个构造器或者方法在它内部使用一个super引用时(在class的内容部分),这个super是被静态地绑定到这个指定的类阶层中的,而且不能被覆盖(至少是在ES6中)。

这意味着什么?这意味着如果你习惯于从一个“类”中拿来一个方法并通过覆盖它的this,比如使用call(..)或者apply(..),来为另一个类而“借用”它的话,那么当你借用的方法中有一个super时,将很有可能发生令你诧异的事情。考虑这个类阶层:

class ParentA {
    constructor() { this.id = "a"; }
    foo() { console.log( "ParentA:", this.id ); }
}

class ParentB {
    constructor() { this.id = "b"; }
    foo() { console.log( "ParentB:", this.id ); }
}

class ChildA extends ParentA {
    foo() {
        super.foo();
        console.log( "ChildA:", this.id );
    }
}

class ChildB extends ParentB {
    foo() {
        super.foo();
        console.log( "ChildB:", this.id );
    }
}

var a = new ChildA();
a.foo();                    // ParentA: a
                            // ChildA: a
var b = new ChildB();       // ParentB: b
b.foo();                    // ChildB: b

在前面这个代码段中一切看起来都相当自然和在意料之中。但是,如果你试着借来b.foo()并在a的上下文中使用它的话 —— 通过动态this绑定的力量,这样的借用十分常见而且以许多不同的方式被使用,包括最明显的mixin —— 你可能会发现这个结果出奇地难看:

// 在`a`的上下文环境中借用`b.foo()`
b.foo.call( a );            // ParentB: a
                            // ChildB: a

如你所见,引用this.id被动态地重绑定所以在两种情况下都报告: a而不是: b。但是b.foo()super.foo()引用没有被动态重绑定,所以它依然报告ParentB而不是期望的ParentA

因为b.foo()引用super,所以它被静态地绑定到了ChildB/ParentB阶层而不能被用于ChildA/ParentA阶层。在ES6中没有办法解决这个限制。

如果你有一个不带移花接木的静态类阶层,那么super的工作方式看起来很直观。但公平地说,实施带有this的编码的一个主要好处正是这种灵活性。简单地说,class + super要求你避免使用这样的技术。

你能在对象设计上作出的选择归结为两个:使用这些静态的阶层 —— classextends,和super将十分不错 —— 要么放弃所有“山寨”类的企图,而接受动态且灵活的,没有类的对象和[[Prototype]]委托(参见本系列的 this与对象原型)。

子类构造器

对类或子类来说构造器不是必需的;如果构造器被省略,这两种情况下都会有一个默认构造器顶替上来。但是,对于一个直接的类和一个被扩展的类来说,顶替上来的默认构造器是不同的。

特别地,默认的子类构造器自动地调用父构造器,并且传递所有参数值。换句话说,你可以认为默认的子类构造器有些像这样:

constructor(...args) {
    super(...args);
}

这是一个需要注意的重要细节。不是所有支持类的语言的子类构造器都会自动地调用父构造器。C++会,但Java不会。更重要的是,在前ES6的类中,这样的自动“父构造器”调用不会发生。如果你曾经依赖于这样的调用 不会 发生,按么当你将代码转换为ES6class时就要小心。

ES6子类构造器的另一个也许令人吃惊的偏差/限制是:在一个子类的构造器中,在super(..)被调用之前你不能访问this。其中的原因十分微妙和复杂,但是可以归结为是父构造器在实际上创建/初始化你的实例的this。前ES6中,它相反地工作;this对象被“子类构造器”创建,然后你使用这个“子类”的this上下文环境调用“父构造器”。

让我们展示一下。这是前ES6版本:

function Foo() {
    this.a = 1;
}

function Bar() {
    this.b = 2;
    Foo.call( this );
}

// `Bar` “扩展” `Foo`
Bar.prototype = Object.create( Foo.prototype );

但是这个ES6等价物不允许:

class Foo {
    constructor() { this.a = 1; }
}

class Bar extends Foo {
    constructor() {
        this.b = 2;         // 在`super()`之前不允许
        super();            // 可以通过调换这两个语句修正
    }
}

在这种情况下,修改很简单。只要在子类Bar的构造器中调换两个语句的位置就行了。但是,如果你曾经依赖于前ES6可以跳过“父构造器”调用的话,就要小心这不再被允许了。

extend原生类型

新的classextend设计中最值得被欢呼的好处之一,就是(终于!)能够为内建原生类型,比如Array,创建子类。考虑如下代码:

class MyCoolArray extends Array {
    first() { return this[0]; }
    last() { return this[this.length - 1]; }
}

var a = new MyCoolArray( 1, 2, 3 );

a.length;                   // 3
a;                          // [1,2,3]

a.first();                  // 1
a.last();                   // 3

在ES6之前,可以使用手动的对象创建并将它链接到Array.prototype来制造一个Array的“子类”的山寨版,但它仅能部分地工作。它缺失了一个真正数组的特殊行为,比如自动地更新length属性。ES6子类应该可以如我们盼望的那样使用“继承”与增强的行为来完整地工作!

另一个常见的前ES6“子类”的限制与Error对象有关,在创建自定义的错误“子类”时。当纯粹的Error被创建时,它们自动地捕获特殊的stack信息,包括错误被创建的行号和文件。前ES6的自定义错误“子类”没有这样的特殊行为,这严重地限制了它们的用处。

ES6前来拯救:

class Oops extends Error {
    constructor(reason) {
        super(reason);
        this.oops = reason;
    }
}

// 稍后:
var ouch = new Oops( "I messed up!" );
throw ouch;

前面代码段的ouch自定义错误对象将会向任何其他的纯粹错误对象那样动作,包括捕获stack。这是一个巨大的改进!

new.target

ES6引入了一个称为 元属性 的新概念(见第七章),用new.target的形式表示。

如果这看起来很奇怪,是的;将一个带有.的关键字与一个属性名配成一对,对JS来说绝对是不同寻常的模式。

new.target是一个在所有函数中可用的“魔法”值,虽然在普通的函数中它总是undefined。在任意的构造器中,new.target总是指向new实际直接调用的构造器,即便这个构造器是在一个父类中,而且是通过一个在子构造器中的super(..)调用被委托的。

class Foo {
    constructor() {
        console.log( "Foo: ", new.target.name );
    }
}

class Bar extends Foo {
    constructor() {
        super();
        console.log( "Bar: ", new.target.name );
    }
    baz() {
        console.log( "baz: ", new.target );
    }
}

var a = new Foo();
// Foo: Foo

var b = new Bar();
// Foo: Bar   <-- 遵照`new`的调用点
// Bar: Bar

b.baz();
// baz: undefined

new.target元属性在类构造器中没有太多作用,除了访问一个静态属性/方法(见下一节)。

如果new.targetundefined,那么你就知道这个函数不是用new调用的。然后你就可以强制一个new调用,如果有必要的话。

static

当一个子类Bar扩展一个父类Foo时,我们已经观察到Bar.prototype[[Prototype]]链接到Foo.prototype。但是额外地,Bar()[[Prototype]]链接到Foo()。这部分可能就没有那么明显了。

但是,在你为一个类声明static方法(不只是属性)时它就十分有用,因为这些静态方法被直接添加到这个类的函数对象上,不是函数对象的prototype对象上。考虑如下代码:

class Foo {
    static cool() { console.log( "cool" ); }
    wow() { console.log( "wow" ); }
}

class Bar extends Foo {
    static awesome() {
        super.cool();
        console.log( "awesome" );
    }
    neat() {
        super.wow();
        console.log( "neat" );
    }
}

Foo.cool();                 // "cool"
Bar.cool();                 // "cool"
Bar.awesome();              // "cool"
                            // "awesome"

var b = new Bar();
b.neat();                   // "wow"
                            // "neat"

b.awesome;                  // undefined
b.cool;                     // undefined

小心不要被搞糊涂,认为static成员是在类的原型链上的。它们实际上存在与函数构造器中间的一个双重/平行链条上。

Symbol.species构造器Getter

一个static可以十分有用的地方是为一个衍生(子)类设置Symbol.speciesgetter(在语言规范内部称为@@species)。这种能力允许一个子类通知一个父类应当使用什么样的构造器 —— 当不打算使用子类的构造器本身时 —— 如果有任何父类方法需要产生新的实例的话。

举个例子,在Array上的许多方法都创建并返回一个新的Array实例。如果你从Array定义一个衍生的类,但你想让这些方法实际上继续产生Array实例,而非从你的衍生类中产生实例,那么这就可以工作:

class MyCoolArray extends Array {
    // 强制`species`为父类构造器
    static get [Symbol.species]() { return Array; }
}

var a = new MyCoolArray( 1, 2, 3 ),
    b = a.map( function(v){ return v * 2; } );

b instanceof MyCoolArray;   // false
b instanceof Array;         // true

为了展示一个父类方法如何可以有些像Array#map(..)所做的那样,使用一个子类型声明,考虑如下代码:

class Foo {
    // 将`species`推迟到衍生的构造器中
    static get [Symbol.species]() { return this; }
    spawn() {
        return new this.constructor[Symbol.species]();
    }
}

class Bar extends Foo {
    // 强制`species`为父类构造器
    static get [Symbol.species]() { return Foo; }
}

var a = new Foo();
var b = a.spawn();
b instanceof Foo;                   // true

var x = new Bar();
var y = x.spawn();
y instanceof Bar;                   // false
y instanceof Foo;                   // true

父类的Symbol.species使用return this来推迟到任意的衍生类,就像你通常期望的那样。然后Bar手动地声明Foo被用于这样的实例创建。当然,一个衍生的类依然可以使用new this.constructor(..)生成它本身的实例。

复习

ES6引入了几个在代码组织上提供帮助的新特性:

  • 迭代器提供了对数据和操作的序列化访问。它们可以被for..of...这样的新语言特性消费。
  • Generator是由一个迭代器控制的能够在本地暂停/继续的函数。它们可以被用于程序化地(并且是互动地,通过yield/next(..)消息传递) 生成 通过迭代器被消费的值。
  • 模块允许实现的细节的私有封装带有一个公开导出的API。模块定义是基于文件的,单例的实例,并且在编译时静态地解析。
  • 类为基于原型的编码提供了更干净的语法。super的到来也解决了在[[Prototype]]链中进行相对引用的刁钻问题。

在你考虑通过采纳ES6来改进你的JS项目体系结构时,这些新工具应当是你的第一站。

推荐阅读更多精彩内容