你不知道的JavaScript:ES6与未来

第三章:组织

编写JS代码是一回事儿,而合理地组织它是另一回事儿。利用常见的组织和重用模式在很大程度上改善了你代码的可读性和可理解性。记住:代码在与其他开发者交流上起的作用,与在给计算机喂指令上起的作用同样重要。

ES6拥有几种重要的特性可以显著改善这些模式,包括:迭代器,generator,模块,和类。

迭代器

迭代器(iterator) 是一种结构化的模式,用于从一个信息源中以一次一个的方式抽取信息。这种模式在程序设计中存在很久了。而且不可否认的是,不知从什么时候起JS开发者们就已经特别地设计并实现了迭代器,所以它根本不是什么新的话题。

ES6所做的是,为迭代器引入了一个隐含的标准化接口。许多在JavaScript中内建的数据结构现在都会暴露一个实现了这个标准的迭代器。而且你也可以构建自己的遵循同样标准的迭代器,来使互用性最大化。

迭代器是一种消费数据的方法,它是组织有顺序的,相继的,基于抽取的。

举个例子,你可能实现一个工具,它在每次被请求时产生一个新的唯一的标识符。或者你可能循环一个固定的列表以轮流的方式产生一系列无限多的值。或者你可以在一个数据库查询的结果上添加一个迭代器来一次抽取一行结果。

虽然在JS中它们不经常以这样的方式被使用,但是迭代器还可以认为是每次控制行为中的一个步骤。这会在考虑generator时得到相当清楚的展示(参见本章稍后的“Generator”),虽然你当然可以不使用generator而做同样的事。

接口

在本书写作的时候,ES6的25.1.1.2部分 (https://people.mozilla.org/~jorendorff/es6-draft.html#sec-iterator-interface) 详述了Iterator接口,它有如下的要求:

Iterator [必须]
    next() {method}: 取得下一个IteratorResult

有两个可选成员,有些迭代器用它们进行了扩展:

Iterator [可选]
    return() {method}: 停止迭代并返回IteratorResult
    throw() {method}: 通知错误并返回IteratorResult

接口IteratorResult被规定为:

IteratorResult
    value {property}: 当前的迭代值或最终的返回值
        (如果它的值为`undefined`,是可选的)
    done {property}: 布尔值,指示完成的状态

注意: 我称这些接口是隐含的,不是因为它们没有在语言规范中被明确地被说出来 —— 它们被说出来了!—— 而是因为它们没有作为可以直接访问的对象暴露给代码。在ES6中,JavaScript不支持任何“接口”的概念,所以在你自己的代码中遵循它们纯粹是惯例上的。但是,不论JS在何处需要一个迭代器 —— 例如在一个for..of循环中 —— 你提供的东西必须遵循这些接口,否则代码就会失败。

还有一个Iterable接口,它描述了一定能够产生迭代器的对象:

Iterable
    @@iterator() {method}: 产生一个迭代器

如果你回忆一下第二章的“内建Symbol”,@@iterator是一种特殊的内建symbol,表示可以为对象产生迭代器的方法。

IteratorResult

IteratorResult接口规定从任何迭代器操作的返回值都是这样形式的对象:

{ value: .. , done: true / false }

内建迭代器将总是返回这种形式的值,当然,更多的属性也允许出现在这个返回值中,如果有必要的话。

例如,一个自定义的迭代器可能会在结果对象中加入额外的元数据(比如,数据是从哪里来的,取得它花了多久,缓存过期的时间长度,下次请求的恰当频率,等等)。

注意: 从技术上讲,在值为undefined的情况下,value是可选的,它将会被认为是不存在或者是没有被设置。因为不管它是表示的就是这个值还是完全不存在,访问res.value都将会产生undefined,所以这个属性的存在/不存在更大程度上是一个实现或者优化(或两者)的细节,而非一个功能上的问题。

next()迭代

让我们来看一个数组,它是一个可迭代对象,可以生成一个迭代器来消费它的值:

var arr = [1,2,3];

var it = arr[Symbol.iterator]();

it.next();        // { value: 1, done: false }
it.next();        // { value: 2, done: false }
it.next();        // { value: 3, done: false }

it.next();        // { value: undefined, done: true }

每一次定位在Symbol.iterator上的方法在值arr上被调用时,它都将生成一个全新的迭代器。大多数的数据结构都会这么做,包括所有内建在JS中的数据结构。

然而,像事件队列这样的结构也许只能生成一个单独的迭代器(单例模式)。或者某种结构可能在同一时间内只允许存在一个唯一的迭代器,要求当前的迭代器必须完成,才能创建一个新的。

前一个代码段中的it迭代器不会再你得到值3时报告done: true。你必须再次调用next(),实质上越过数组末尾的值,才能得到完成信号done: true。在这一节稍后会清楚地讲解这种设计方式的原因,但是它通常被认为是一种最佳实践。

基本类型的字符串值也默认地是可迭代对象:

var greeting = "hello world";

var it = greeting[Symbol.iterator]();

it.next();        // { value: "h", done: false }
it.next();        // { value: "e", done: false }
..

注意: 从技术上讲,这个基本类型值本身不是可迭代对象,但多亏了“封箱”,"hello world"被强制转换为它的String对象包装形式, 才是一个可迭代对象。更多信息参见本系列的 类型与文法

ES6还包括几种新的数据结构,称为集合(参见第五章)。这些集合不仅本身就是可迭代对象,而且它们还提供API方法来生成一个迭代器,例如:

var m = new Map();
m.set( "foo", 42 );
m.set( { cool: true }, "hello world" );

var it1 = m[Symbol.iterator]();
var it2 = m.entries();

it1.next();        // { value: [ "foo", 42 ], done: false }
it2.next();        // { value: [ "foo", 42 ], done: false }
..

一个迭代器的next(..)方法能够可选地接受一个或多个参数。大多数内建的迭代器不会实施这种能力,虽然一个generator的迭代器绝对会这么做(参见本章稍后的“Generator”)。

根据一般的惯例,包括所有的内建迭代器,在一个已经被耗尽的迭代器上调用next(..)不是一个错误,而是简单地持续返回结果{ value: undefined, done: true }

可选的return(..)throw(..)

在迭代器接口上的可选方法 —— return(..)throw(..) —— 在大多数内建的迭代器上都没有被实现。但是,它们在generator的上下文环境中绝对有某些含义,所以更具体的信息可以参看“Generator”。

return(..)被定义为向一个迭代器发送一个信号,告知它消费者代码已经完成而且不会再从它那里抽取更多的值。这个信号可以用于通知生产者(应答next(..)调用的迭代器)去实施一些可能的清理作业,比如释放/关闭网络,数据库,或者文件引用资源。

如果一个迭代器拥有return(..),而且发生了可以自动被解释为非正常或者提前终止消费迭代器的任何情况,return(..)就将会被自动调用。你也可以手动调用return(..)

return(..)将会像next(..)一样返回一个IteratorResult对象。一般来说,你向return(..)发送的可选值将会在这个IteratorResult中作为value发送回来,虽然在一些微妙的情况下这可能不成立。

throw(..)被用于向一个迭代器发送一个异常/错误信号,与return(..)隐含的完成信号相比,它可能会被迭代器用于不同的目的。它不一定像return(..)一样暗示着迭代器的完全停止。

例如,在generator迭代器中,throw(..)实际上会将一个被抛出的异常注射到generator暂停的执行环境中,这个异常可以用try..catch捕获。一个未捕获的throw(..)异常将会导致generator的迭代器异常中止。

注意: 根据一般的惯例,在return(..)throw(..)被调用之后,一个迭代器就不应该在产生任何结果了。

迭代器循环

正如我们在第二章的“for..of”一节中讲解的,ES6的for..of循环可以直接消费一个规范的可迭代对象。

如果一个迭代器也是一个可迭代对象,那么它就可以直接与for..of循环一起使用。通过给予迭代器一个简单地返回它自身的Symbol.iterator方法,你就可以使它成为一个可迭代对象:

var it = {
    // 使迭代器`it`成为一个可迭代对象
    [Symbol.iterator]() { return this; },

    next() { .. },
    ..
};

it[Symbol.iterator]() === it;        // true

现在我们就可以用一个for..of循环来消费迭代器it了:

for (var v of it) {
    console.log( v );
}

为了完全理解这样的循环如何工作,回忆下第二章中的for..of循环的for等价物:

for (var v, res; (res = it.next()) && !res.done; ) {
    v = res.value;
    console.log( v );
}

如果你仔细观察,你会发现it.next()是在每次迭代之前被调用的,然后res.done才被查询。如果res.donetrue,那么这个表达式将会求值为false于是这次迭代不会发生。

回忆一下之前我们建议说,迭代器一般不应与最终预期的值一起返回done: true。现在你知道为什么了。

如果一个迭代器返回了{ done: true, value: 42 }for..of循环将完全扔掉值42。因此,假定你的迭代器可能会被for..of循环或它的for等价物这样的模式消费的话,你可能应当等到你已经返回了所有相关的迭代值之后才返回done: true来表示完成。

警告: 当然,你可以有意地将你的迭代器设计为将某些相关的valuedone: true同时返回。但除非你将此情况在文档中记录下来,否则不要这么做,因为这样会隐含地强制你的迭代器消费者使用一种,与我们刚才描述的for..of或它的手动等价物不同的模式来进行迭代。

自定义迭代器

除了标准的内建迭代器,你还可以制造你自己的迭代器!所有使它们可以与ES6消费设施(例如,for..of循环和...操作符)进行互动的代价就是遵循恰当的接口。

让我们试着构建一个迭代器,它能够以斐波那契(Fibonacci)数列的形式产生无限多的数字序列:

var Fib = {
    [Symbol.iterator]() {
        var n1 = 1, n2 = 1;

        return {
            // 使迭代器成为一个可迭代对象
            [Symbol.iterator]() { return this; },

            next() {
                var current = n2;
                n2 = n1;
                n1 = n1 + current;
                return { value: current, done: false };
            },

            return(v) {
                console.log(
                    "Fibonacci sequence abandoned."
                );
                return { value: v, done: true };
            }
        };
    }
};

for (var v of Fib) {
    console.log( v );

    if (v > 50) break;
}
// 1 1 2 3 5 8 13 21 34 55
// Fibonacci sequence abandoned.

警告: 如果我们没有插入break条件,这个for..of循环将会永远运行下去,这回破坏你的程序,因此可能不是我们想要的!

方法Fib[Symbol.iterator]()在被调用时返回带有next()return(..)方法的迭代器对象。它的状态通过变量n1n2维护在闭包中。

接下来让我们考虑一个迭代器,它被设计为执行一系列(也叫队列)动作,一次一个:

var tasks = {
    [Symbol.iterator]() {
        var steps = this.actions.slice();

        return {
            // 使迭代器成为一个可迭代对象
            [Symbol.iterator]() { return this; },

            next(...args) {
                if (steps.length > 0) {
                    let res = steps.shift()( ...args );
                    return { value: res, done: false };
                }
                else {
                    return { done: true }
                }
            },

            return(v) {
                steps.length = 0;
                return { value: v, done: true };
            }
        };
    },
    actions: []
};

tasks上的迭代器步过在数组属性actions中找到的函数,并每次执行它们中的一个,并传入你传递给next(..)的任何参数值,并在标准的IteratorResult对象中向你返回任何它返回的东西。

这是我们如何使用这个tasks队列:

tasks.actions.push(
    function step1(x){
        console.log( "step 1:", x );
        return x * 2;
    },
    function step2(x,y){
        console.log( "step 2:", x, y );
        return x + (y * 2);
    },
    function step3(x,y,z){
        console.log( "step 3:", x, y, z );
        return (x * y) + z;
    }
);

var it = tasks[Symbol.iterator]();

it.next( 10 );            // step 1: 10
                        // { value:   20, done: false }

it.next( 20, 50 );        // step 2: 20 50
                        // { value:  120, done: false }

it.next( 20, 50, 120 );    // step 3: 20 50 120
                        // { value: 1120, done: false }

it.next();                // { done: true }

这种特别的用法证实了迭代器可以是一种具有组织功能的模式,不仅仅是数据。这也联系着我们在下一节关于generator将要看到的东西。

你甚至可以更有创意一些,在一块数据上定义一个表示元操作的迭代器。例如,我们可以为默认从0开始递增至(或递减至,对于负数来说)指定数字的一组数字定义一个迭代器。

考虑如下代码:

if (!Number.prototype[Symbol.iterator]) {
    Object.defineProperty(
        Number.prototype,
        Symbol.iterator,
        {
            writable: true,
            configurable: true,
            enumerable: false,
            value: function iterator(){
                var i, inc, done = false, top = +this;

                // 正向迭代还是负向迭代?
                inc = 1 * (top < 0 ? -1 : 1);

                return {
                    // 使迭代器本身成为一个可迭代对象!
                    [Symbol.iterator](){ return this; },

                    next() {
                        if (!done) {
                            // 最初的迭代总是0
                            if (i == null) {
                                i = 0;
                            }
                            // 正向迭代
                            else if (top >= 0) {
                                i = Math.min(top,i + inc);
                            }
                            // 负向迭代
                            else {
                                i = Math.max(top,i + inc);
                            }

                            // 这次迭代之后就完了?
                            if (i == top) done = true;

                            return { value: i, done: false };
                        }
                        else {
                            return { done: true };
                        }
                    }
                };
            }
        }
    );
}

现在,这种创意给了我们什么技巧?

for (var i of 3) {
    console.log( i );
}
// 0 1 2 3

[...-3];                // [0,-1,-2,-3]

这是一些有趣的技巧,虽然其实际用途有些值得商榷。但是再一次,有人可能想知道为什么ES6没有提供如此微小但讨喜的特性呢?

如果我连这样的提醒都没给过你,那就是我的疏忽:像我在前面的代码段中做的那样扩展原生原型,是一件你需要小心并了解潜在的危害后才应该做的事情。

在这样的情况下,你与其他代码或者未来的JS特性发生冲突的可能性非常低。但是要小心微小的可能性。并在文档中为后人详细记录下你在做什么。

注意: 如果你想知道更多细节,我在这篇文章(http://blog.getify.com/iterating-es6-numbers/) 中详细论述了这种特别的技术。而且这段评论(http://blog.getify.com/iterating-es6-numbers/comment-page-1/#comment-535294)甚至为制造一个字符串字符范围提出了一个相似的技巧。

消费迭代器

我们已经看到了使用for..of循环来一个元素一个元素地消费一个迭代器。但是还有一些其他的ES6结构可以消费迭代器。

让我们考虑一下附着这个数组上的迭代器(虽然任何我们选择的迭代器都将拥有如下的行为):

var a = [1,2,3,4,5];

扩散操作符...将完全耗尽一个迭代器。考虑如下代码:

function foo(x,y,z,w,p) {
    console.log( x + y + z + w + p );
}

foo( ...a );            // 15

...还可以在一个数组内部扩散一个迭代器:

var b = [ 0, ...a, 6 ];
b;                        // [0,1,2,3,4,5,6]

数组解构(参见第二章的“解构”)可以部分地或者完全地(如果与一个...剩余/收集操作符一起使用)消费一个迭代器:

var it = a[Symbol.iterator]();

var [x,y] = it;            // 仅从`it`中取前两个元素
var [z, ...w] = it;        // 取第三个,然后一次取得剩下所有的

// `it`被完全耗尽了吗?是的
it.next();                // { value: undefined, done: true }

x;                        // 1
y;                        // 2
z;                        // 3
w;                        // [4,5]

Generator

所有的函数都会运行至完成,对吧?换句话说,一旦一个函数开始运行,在它完成之前没有任何东西能够打断它。

至少对于到目前为止的JavaScript的整个历史来说是这样的。在ES6中,引入了一个有些异乎寻常的新形式的函数,称为generator。一个generator可以在运行期间暂停它自己,还可以立即或者稍后继续运行。所以显然它没有普通函数那样的运行至完成的保证。

另外,在运行期间的每次暂停/继续轮回都是一个双向消息传递的好机会,generator可以在这里返回一个值,而使它继续的控制端代码可以发回一个值。

就像前一节中的迭代器一样,有种方式可以考虑generator是什么,或者说它对什么最有用。对此没有一个正确的答案,但我们将试着从几个角度考虑。

注意: 关于generator的更多信息参见本系列的 异步与性能,还可以参见本书的第四章。

语法

generator函数使用这种新语法声明:

function *foo() {
    // ..
}

*的位置在功能上无关紧要。同样的声明还可以写做以下的任意一种:

function *foo()  { .. }
function* foo()  { .. }
function * foo() { .. }
function*foo()   { .. }
..

这里 唯一 的区别就是风格的偏好。大多数其他的文献似乎喜欢function* foo(..) { .. }。我喜欢function *foo(..) { .. },所以这就是我将在本书剩余部分中表示它们的方法。

我这样做的理由实质上纯粹是为了教学。在这本书中,当我引用一个generator函数时,我将使用*foo(..),与普通函数的foo(..)相对。我发现*foo(..)function *foo(..) { .. }*的位置更加吻合。

另外,就像我们在第二章的简约方法中看到的,在对象字面量中有一种简约generator形式:

var a = {
    *foo() { .. }
};

我要说在简约generator中,*foo() { .. }要比* foo() { .. }更自然。这进一步表明了为何使用*foo()匹配一致性。

一致性使理解与学习更轻松。

执行一个Generator

虽然一个generator使用*进行声明,但是你依然可以像一个普通函数那样执行它:

foo();

你依然可以传给它参数值,就像:

function *foo(x,y) {
    // ..
}

foo( 5, 10 );

主要区别在于,执行一个generator,比如foo(5,10),并不实际运行generator中的代码。取而代之的是,它生成一个迭代器来控制generator执行它的代码。

我们将在稍后的“迭代器控制”中回到这个话题,但是简要地说:

function *foo() {
    // ..
}

var it = foo();

// 要开始/推进`*foo()`,调用
// `it.next(..)`

yield

Generator还有一个你可以在它们内部使用的新关键字,用来表示暂停点:yield。考虑如下代码:

function *foo() {
    var x = 10;
    var y = 20;

    yield;

    var z = x + y;
}

在这个*foo()generator中,前两行的操作将会在开始时运行,然后yield将会暂停这个generator。如果这个generator被继续,*foo()的最后一行将运行。在一个generator中yield可以出现任意多次(或者,在技术上讲,根本不出现!)。

你甚至可以在一个循环内部放置yield,它可以表示一个重复的暂停点。事实上,一个永不完成的循环就意味着一个永不完成的generator,这是完全合法的,而且有时候完全是你需要的。

yield不只是一个暂停点。它是在暂停generator时发送出一个值的表达式。这里是一个位于generator中的while..true循环,它每次迭代时yield出一个新的随机数:

function *foo() {
    while (true) {
        yield Math.random();
    }
}

yield ..表达式不仅发送一个值 —— 不带值的yieldyield undefined相同 —— 它还接收(也就是,被替换为)最终的继续值。考虑如下代码:

function *foo() {
    var x = yield 10;
    console.log( x );
}

这个generator在暂停它自己时将首先yield出值10。当你继续这个generator时 —— 使用我们先前提到的it.next(..) —— 无论你使用什么值继续它,这个值都将替换/完成整个表达式yield 10,这意味着这个值将被赋值给变量x

一个yield..表达式可以出现在任意普通表达式可能出现的地方。例如:

function *foo() {
    var arr = [ yield 1, yield 2, yield 3 ];
    console.log( arr, yield 4 );
}

这里的*foo()有四个yield ..表达式。其中每个yield都会导致generator暂停以等待一个继续值,这个继续值稍后被用于各个表达式环境中。

yield在技术上讲不是一个操作符,虽然像yield 1这样使用时看起来确实很像。因为yield可以像var x = yield这样完全通过自己被使用,所以将它认为是一个操作符有时令人困惑。

从技术上讲,yield ..a = 3这样的赋值表达式拥有相同的“表达式优先级” —— 概念上和操作符优先级很相似。这意味着yield ..基本上可以出现在任何a = 3可以合法出现的地方。

让我们展示一下这种对称性:

var a, b;

a = 3;                    // 合法
b = 2 + a = 3;            // 不合法
b = 2 + (a = 3);        // 合法

yield 3;                // 合法
a = 2 + yield 3;        // 不合法
a = 2 + (yield 3);        // 合法

注意: 如果你好好考虑一下,认为一个yield ..表达式与一个赋值表达式的行为相似在概念上有些道理。当一个被暂停的generator被继续时,它就以一种与被这个继续值“赋值”区别不大的方式,被这个值完成/替换。

要点:如果你需要yield ..出现在a = 3这样的赋值本不被允许出现的位置,那么它就需要被包在一个( )中。

因为yield关键字的优先级很低,几乎任何出现在yield ..之后的表达式都会在被yield发送之前首先被计算。只有扩散操作符...和逗号操作符,拥有更低的优先级,这意味着他们会在yield已经被求值之后才会被处理。

所以正如带有多个操作符的普通语句一样,存在另一个可能需要( )来覆盖(提升)yield的低优先级的情况,就像这些表达式之间的区别:

yield 2 + 3;            // 与`yield (2 + 3)`相同

(yield 2) + 3;            // 首先`yield 2`,然后`+ 3`

=赋值一样,yield也是“右结合性”的,这意味着多个接连出现的yield表达式被视为从右到左被( .. )分组。所以,yield yield yield 3将被视为yield (yield (yield 3))。像((yield) yield) yield 3这样的“左结合性”解释没有意义。

和其他操作符一样,yield与其他操作符或yield组合时为了使你的意图没有歧义,使用( .. )分组是一个好主意,即使这不是严格要求的。

注意: 更多关于操作符优先级和结合性的信息,参见本系列的 类型与文法

yield *

*使一个function声明成为一个function *generator声明的方式一样,一个*使yield成为一个机制非常不同的yield *,称为 yield委托。从文法上讲,yield *..的行为与yield ..相同,就像在前一节讨论过的那样。

yield * ..需要一个可迭代对象;然后它调用这个可迭代对象的迭代器,并将它自己的宿主generator的控制权委托给那个迭代器,直到它被耗尽。考虑如下代码:

function *foo() {
    yield *[1,2,3];
}

注意: 与generator声明中*的位置(早先讨论过)一样,在yield *表达式中的*的位置在风格上由你来决定。大多数其他文献偏好yield* ..,但是我喜欢yield *..,理由和我们已经讨论过的相同。

[1,2,3]产生一个将会步过它的值的迭代器,所以generator*foo()将会在被消费时产生这些值。另一种说明这种行为的方式是,yield委托到了另一个generator:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

function *bar() {
    yield *foo();
}

*bar()调用*foo()产生的迭代器通过yield *受到委托,意味着无论*foo()产生什么值都会被*bar()产生。

yield ..中表达式的完成值来自于使用it.next(..)继续generator,而yield *..表达式的完成值来自于受到委托的迭代器的返回值(如果有的话)。

内建的迭代器一般没有返回值,正如我们在本章早先的“迭代器循环”一节的末尾讲过的。但是如果你定义你自己的迭代器(或者generator),你就可以将它设计为return一个值,yield *..将会捕获它:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
    return 4;
}

function *bar() {
    var x = yield *foo();
    console.log( "x:", x );
}

for (var v of bar()) {
    console.log( v );
}
// 1 2 3
// x: 4

虽然值12,和3*foo()中被yield出来,然后从*bar()中被yield出来,但是从*foo()中返回的值4是表达式yield *foo()的完成值,然后它被赋值给x

因为yield *可以调用另一个generator(通过委托到它的迭代器的方式),它还可以通过调用自己来实施某种generator递归:

function *foo(x) {
    if (x < 3) {
        x = yield *foo( x + 1 );
    }
    return x * 2;
}

foo( 1 );

取得foo(1)的结果并调用迭代器的next()来使它运行它的递归步骤,结果将是24。第一次*foo()运行时x拥有值1,它是x < 3x + 1被递归地传递到*foo(..),所以之后的x2。再一次递归调用导致x3

现在,因为x < 3失败了,递归停止,而且return 3 * 26给回前一个调用的yeild *..表达式,它被赋值给x。另一个return 6 * 2返回12给前一个调用的x。最终12 * 2,即24,从generator*foo(..)运行的完成中被返回。

迭代器控制

早先,我们简要地介绍了generator是由迭代器控制的概念。现在让我们完整地深入这个话题。

回忆一下前一节的递归*for(..)。这是我们如何运行它:

function *foo(x) {
    if (x < 3) {
        x = yield *foo( x + 1 );
    }
    return x * 2;
}

var it = foo( 1 );
it.next();                // { value: 24, done: true }

在这种情况下,generator并没有真正暂停过,因为这里没有yield ..表达式。而yield *只是通过递归调用保持当前的迭代步骤继续运行下去。所以,仅仅对迭代器的next()函数进行一次调用就完全地运行了generator。

现在让我们考虑一个有多个步骤并且因此有多个产生值的generator:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

我们已经知道我们可以是使用一个for..of循环来消费一个迭代器,即便它是一个附着在*foo()这样的generator上:

for (var v of foo()) {
    console.log( v );
}
// 1 2 3

注意: for..of循环需要一个可迭代对象。一个generator函数引用(比如foo)本身不是一个可迭代对象;你必须使用foo()来执行它以得到迭代器(它也是一个可迭代对象,正如我们在本章早先讲解过的)。理论上你可以使用一个实质上仅仅执行return this()Symbol.iterator函数来扩展GeneratorPrototype(所有generator函数的原型)。这将使foo引用本身成为一个可迭代对象,也就意味着for (var v of foo) { .. }(注意在foo上没有())将可以工作。

让我们手动迭代这个generator:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it = foo();

it.next();                // { value: 1, done: false }
it.next();                // { value: 2, done: false }
it.next();                // { value: 3, done: false }

it.next();                // { value: undefined, done: true }

如果你仔细观察,这里有三个yield语句和四个next()调用。这可能看起来像是一个奇怪的不匹配。事实上,假定所有的东西都被求值并且generator完全运行至完成的话,next()调用将总是比yield表达式多一个。

但是如果你相反的角度观察(从里向外而不是从外向里),yieldnext()之间的匹配就显得更有道理。

回忆一下,yield ..表达式将被你用于继续generator的值完成。这意味着你传递给next(..)的参数值将完成任何当前暂停中等待完成的yield ..表达式。

让我们这样展示一下这种视角:

function *foo() {
    var x = yield 1;
    var y = yield 2;
    var z = yield 3;
    console.log( x, y, z );
}

在这个代码段中,每个yield ..都送出一个值(123),但更直接的是,它暂停了generator来等待一个值。换句话说,它就像在问这样一个问题,“我应当在这里用什么值?我会在这里等你告诉我。”

现在,这是我们如何控制*foo()来启动它:

var it = foo();

it.next();                // { value: 1, done: false }

这第一个next()调用从generator初始的暂停状态启动了它,并运行至第一个yield。在你调用第一个next()的那一刻,并没有yield ..表达式等待完成。如果你给第一个next()调用传递一个值,目前它会被扔掉,因为没有yield等着接受这样的一个值。

注意: 一个“ES6之后”时间表中的早期提案 允许你在generator内部通过一个分离的元属性(见第七章)来访问一个被传入初始next(..)调用的值。

现在,让我们回答那个未解的问题,“我应当给x赋什么值?” 我们将通过给 下一个 next(..)调用发送一个值来回答:

it.next( "foo" );        // { value: 2, done: false }

现在,x将拥有值"foo",但我们也问了一个新的问题,“我应当给y赋什么值?”

it.next( "bar" );        // { value: 3, done: false }

答案给出了,另一个问题被提出了。最终答案:

it.next( "baz" );        // "foo" "bar" "baz"
                        // { value: undefined, done: true }

现在,每一个yield ..的“问题”是如何被 下一个 next(..)调用回答的,所以我们观察到的那个“额外的”next()调用总是使一切开始的那一个。

让我们把这些步骤放在一起:

var it = foo();

// 启动generator
it.next();                // { value: 1, done: false }

// 回答第一个问题
it.next( "foo" );        // { value: 2, done: false }

// 回答第二个问题
it.next( "bar" );        // { value: 3, done: false }

// 回答第三个问题
it.next( "baz" );        // "foo" "bar" "baz"
                        // { value: undefined, done: true }

在生成器的每次迭代都简单地为消费者生成一个值的情况下,你可认为一个generator是一个值的生成器。

但是在更一般的意义上,也许将generator认为是一个受控制的,累进的代码执行过程更恰当,与早先“自定义迭代器”一节中的tasks队列的例子非常相像。

注意: 这种视角正是我们将如何在第四章中重温generator的动力。特别是,next(..)没有理由一定要在前一个next(..)完成之后立即被调用。虽然generator的内部执行环境被暂停了,程序的其他部分仍然没有被阻塞,这包括控制generator什么时候被继续的异步动作能力。

提前完成

正如我们在本章早先讲过的,连接到一个generator的迭代器支持可选的return(..)throw(..)方法。它们俩都有立即中止一个暂停的的generator的效果。

考虑如下代码:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it = foo();

it.next();                // { value: 1, done: false }

it.return( 42 );        // { value: 42, done: true }

it.next();                // { value: undefined, done: true }

return(x)有点像强制一个return x就在那个时刻被处理,这样你就立即得到这个指定的值。一旦一个generator完成,无论是正常地还是像展示的那样提前地,它就不再处理任何代码或返回任何值了。

return(..)除了可以手动调用,它还在迭代的最后被任何ES6中消费迭代器的结构自动调用,比如for..of循环和...扩散操作符。

这种能力的目的是,在控制端的代码不再继续迭代generator时它可以收到通知,这样它就可能做一些清理工作(释放资源,复位状态,等等)。与普通函数的清理模式完全相同,达成这个目的的主要方法是使用一个finally子句:

function *foo() {
    try {
        yield 1;
        yield 2;
        yield 3;
    }
    finally {
        console.log( "cleanup!" );
    }
}

for (var v of foo()) {
    console.log( v );
}
// 1 2 3
// cleanup!

var it = foo();

it.next();                // { value: 1, done: false }
it.return( 42 );        // cleanup!
                        // { value: 42, done: true }

警告: 不要把yield语句放在finally子句内部!它是有效和合法的,但这确实是一个可怕的主意。它在某种意义上推迟了return(..)调用的完成,因为在finally子句中的任何yield ..表达式都被遵循来暂停和发送消息;你不会像期望的那样立即得到一个完成的generator。基本上没有任何好的理由去选择这种疯狂的 坏的部分,所以避免这么做!

前一个代码段除了展示return(..)如何在中止generator的同时触发finally子句,它还展示了一个generator在每次被调用时都产生一个全新的迭代器。事实上,你可以并发地使用连接到相同generator的多个迭代器:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it1 = foo();
it1.next();                // { value: 1, done: false }
it1.next();                // { value: 2, done: false }

var it2 = foo();
it2.next();                // { value: 1, done: false }

it1.next();                // { value: 3, done: false }

it2.next();                // { value: 2, done: false }
it2.next();                // { value: 3, done: false }

it2.next();                // { value: undefined, done: true }
it1.next();                // { value: undefined, done: true }

提前中止

你可以调用throw(..)来代替return(..)调用。就像return(x)实质上在generator当前的暂停点上注入了一个return x一样,调用throw(x)实质上就像在暂停点上注入了一个throw x

除了处理异常的行为(我们在下一节讲解这对try子句意味着什么),throw(..)产生相同的提前完成 —— 在generator当前的暂停点中止它的运行。例如:

function *foo() {
    yield 1;
    yield 2;
    yield 3;
}

var it = foo();

it.next();                // { value: 1, done: false }

try {
    it.throw( "Oops!" );
}
catch (err) {
    console.log( err );    // Exception: Oops!
}

it.next();                // { value: undefined, done: true }

因为throw(..)基本上注入了一个throw ..来替换generator的yield 1这一行,而且没有东西处理这个异常,它立即传播回外面的调用端代码,调用端代码使用了一个try..catch来处理了它。

return(..)不同的是,迭代器的throw(..)方法绝不会被自动调用。

当然,虽然没有在前面的代码段中展示,但如果当你调用throw(..)时有一个try..finally子句等在generator内部的话,这个finally子句将会在异常被传播回调用端代码之前有机会运行。

错误处理

正如我们已经得到的提示,generator中的错误处理可以使用try..catch表达,它在上行和下行两个方向都可以工作。

function *foo() {
    try {
        yield 1;
    }
    catch (err) {
        console.log( err );
    }

    yield 2;

    throw "Hello!";
}

var it = foo();

it.next();                // { value: 1, done: false }

try {
    it.throw( "Hi!" );    // Hi!
                        // { value: 2, done: false }
    it.next();

    console.log( "never gets here" );
}
catch (err) {
    console.log( err );    // Hello!
}

错误也可以通过yield *委托在两个方向上传播:

function *foo() {
    try {
        yield 1;
    }
    catch (err) {
        console.log( err );
    }

    yield 2;

    throw "foo: e2";
}

function *bar() {
    try {
        yield *foo();

        console.log( "never gets here" );
    }
    catch (err) {
        console.log( err );
    }
}

var it = bar();

try {
    it.next();            // { value: 1, done: false }

    it.throw( "e1" );    // e1
                        // { value: 2, done: false }

    it.next();            // foo: e2
                        // { value: undefined, done: true }
}
catch (err) {
    console.log( "never gets here" );
}

it.next();                // { value: undefined, done: true }

*foo()调用yield 1时,值1原封不动地穿过了*bar(),就像我们已经看到过的那样。

但这个代码段最有趣的部分是,当*foo()调用throw "foo: e2"时,这个错误传播到了*bar()并立即被*bar()try..catch块儿捕获。错误没有像值1那样穿过*bar()

然后*bar()catcherr普通地输出("foo: e2")之后*bar()就正常结束了,这就是为什么迭代器结果{ value: undefined, done: true }it.next()中返回。

如果*bar()没有用try..catch环绕着yield *..表达式,那么错误将理所当然地一直传播出来,而且在它传播的路径上依然会完成(中止)*bar()

转译一个Generator

有可能在ES6之前的环境中表达generator的能力吗?事实上是可以的,而且有好几种了不起的工具在这么做,包括最著名的Facebook的Regenerator工具 (https://facebook.github.io/regenerator/)。

但为了更好地理解generator,让我们试着手动转换一下。基本上讲,我们将制造一个简单的基于闭包的状态机。

我们将使原本的generator非常简单:

function *foo() {
    var x = yield 42;
    console.log( x );
}

开始之前,我们将需要一个我们能够执行的称为foo()的函数,它需要返回一个迭代器:

function foo() {
    // ..

    return {
        next: function(v) {
            // ..
        }

        // 我们将省略`return(..)`和`throw(..)`
    };
}

现在,我们需要一些内部变量来持续跟踪我们的“generator”的逻辑走到了哪一个步骤。我们称它为state。我们将有三种状态:起始状态的0,等待完成yield表达式的1,和generator完成的2

每次next(..)被调用时,我们需要处理下一个步骤,然后递增state。为了方便,我们将每个步骤放在一个switch语句的case子句中,并且我们将它放在一个next(..)可以调用的称为nextState(..)的内部函数中。另外,因为x是一个横跨整个“generator”作用域的变量,所以它需要存活在nextState(..)函数的外部。

这是将它们放在一起(很明显,为了使概念的展示更清晰,它经过了某些简化):

function foo() {
    function nextState(v) {
        switch (state) {
            case 0:
                state++;

                // `yield`表达式
                return 42;
            case 1:
                state++;

                // `yield`表达式完成了
                x = v;
                console.log( x );

                // 隐含的`return`
                return undefined;

            // 无需处理状态`2`
        }
    }

    var state = 0, x;

    return {
        next: function(v) {
            var ret = nextState( v );

            return { value: ret, done: (state == 2) };
        }

        // 我们将省略`return(..)`和`throw(..)`
    };
}

最后,让我们测试一下我们的前ES6“generator”:

var it = foo();

it.next();                // { value: 42, done: false }

it.next( 10 );            // 10
                        // { value: undefined, done: true }

不赖吧?希望这个练习能在你的脑中巩固这个概念:generator实际上只是状态机逻辑的简单语法。这使它们可以广泛地应用。

Generator的使用

我们现在非常深入地理解了generator如何工作,那么,它们在什么地方有用?

我们已经看过了两种主要模式:

  • 生产一系列值: 这种用法可以很简单(例如,随机字符串或者递增的数字),或者它也可以表达更加结构化的数据访问(例如,迭代一个数据库查询结果的所有行)。

    这两种方式中,我们使用迭代器来控制generator,这样就可以为每次next(..)调用执行一些逻辑。在数据解构上的普通迭代器只不过生成值而没有任何控制逻辑。

  • 串行执行的任务队列: 这种用法经常用来表达一个算法中步骤的流程控制,其中每一步都要求从某些外部数据源取得数据。对每块儿数据的请求可能会立即满足,或者可能会异步延迟地满足。

    从generator内部代码的角度来看,在yield的地方,同步或异步的细节是完全不透明的。另外,这些细节被有意地抽象出去,如此就不会让这样的实现细节把各个步骤间自然的,顺序的表达搞得模糊不清。抽象还意味着实现可以被替换/重构,而根本不用碰generator中的代码。

当根据这些用法观察generator时,它们的含义要比仅仅是手动状态机的一种不同或更好的语法多多了。它们是一种用于组织和控制有序地生产与消费数据的强大工具。

模块

我觉得这样说并不夸张:在所有的JavaScript代码组织模式中最重要的就是,而且一直是,模块。对于我自己来说,而且我认为对广大典型的技术社区来说,模块模式驱动着绝大多数代码。

过去的方式

传统的模块模式基于一个外部函数,它带有内部变量和函数,以及一个被返回的“公有API”。这个“公有API”带有对内部变量和功能拥有闭包的方法。它经常这样表达:

function Hello(name) {
    function greeting() {
        console.log( "Hello " + name + "!" );
    }

    // 公有API
    return {
        greeting: greeting
    };
}

var me = Hello( "Kyle" );
me.greeting();            // Hello Kyle!

这个Hello(..)模块通过被后续调用可以产生多个实例。有时,一个模块为了作为一个单例(也就是,只需要一个实例)而只被调用一次,这样的情况下常见的是一种前面代码段的变种,使用IIFE:

var me = (function Hello(name){
    function greeting() {
        console.log( "Hello " + name + "!" );
    }

    // 公有API
    return {
        greeting: greeting
    };
})( "Kyle" );

me.greeting();            // Hello Kyle!

这种模式是经受过检验的。它也足够灵活,以至于在许多不同的场景下可以有大量的各种变化。

其中一种最常见的是异步模块定义(AMD),另一种是统一模块定义(UMD)。我们不会在这里涵盖这些特定的模式和技术,但是它们在网上的许多地方有大量的讲解。

向前迈进

在ES6中,我们不再需要依赖外围函数和闭包来为我们提供模块支持了。ES6模块拥有头等语法上和功能上的支持。

在我们接触这些具体语法之前,重要的是要理解ES6模块与你以前曾经用过的模块比较起来,在概念上的一些相当显著的不同之处:

  • ES6使用基于文件的模块,这意味着一个模块一个文件。目前,没有标准的方法将多个模块组合到一个文件中。

    这意味着如果你要直接把ES6模块加载到一个浏览器web应用中的话,你将个别地加载它们,不是像常见的那样为了性能优化而作为一个单独文件中的一个巨大的包加载。

    预计同时期到来的HTTP/2将会大幅缓和这种性能上的顾虑,因为它工作在一个持续的套接字连接上,因而可以用并行的,互相交错的方式非常高效地加载许多小文件。

  • 一个ES6模块的API是静态的。这就是说,你在模块的公有API上静态地定义所有被导出的顶层内容,而这些内容导出之后不能被修改。

    有些用法习惯于能够提供动态API定义,它的方法可以根据运行时的条件被增加/删除/替换。这些用法要么必须改变以适应ES6静态API,要么它们就不得不将属性/方法的动态修改限制在一个内层对象中。

  • ES6模块都是单例。也就是,模块只有一个维持它状态的实例。每次你将这个模块导入到另一个模块时,你得到的都是一个指向中央实例的引用。如果你想要能够产生多个模块实例,你的模块将需要提供某种工厂来这么做。

  • 你在模块的公有API上暴露的属性和方法不是值和引用的普通赋值。它们是在你内部模块定义中的标识符的实际绑定(几乎就是指针)。

    在前ES6的模块中,如果你将一个持有像数字或者字符串这样基本类型的属性放在你的共有API中,那么这个属性是通过值拷贝赋值的,任何对相应内部变量的更新都将是分离的,不会影响在API对象上的共有拷贝。

    在ES6中,导出一个本地私有变量,即便它当前持有一个基本类型的字符串/数字/等等,导出的都是这个变量的一个绑定。如果这个模块改变了这个变量的值,外部导入的绑定就会解析为那个新的值。

  • 导入一个模块和静态地请求它被加载是同一件事情(如果它还没被加载的话)。如果你在浏览器中,这意味着通过网络的阻塞加载。如果你在服务器中,它是一个通过文件系统的阻塞加载。

    但是,不要对它在性能的影响上惊慌。因为ES6模块是静态定义的,导入的请求可以被静态地扫描,并提前加载,甚至是在你使用这个模块之前。

    ES6并没有实际规定或操纵这些加载请求如何工作的机制。有一个模块加载器的分离概念,它让每一个宿主环境(浏览器,Node.js,等等)为该环境提供合适的默认加载器。一个模块的导入使用一个字符串值来表示从哪里去取得模块(URL,文件路径,等等),但是这个值在你的程序中是不透明的,它仅对加载器自身有意义。

    如果你想要比默认加载器提供的更细致的控制能力,你可以定义你自己的加载器 —— 默认加载器基本上不提供任何控制,它对于你的程序代码是完全隐藏的。

如你所见,ES6模块将通过封装,控制共有API,以及应用依赖导入来服务于所有的代码组织需求。但是它们用一种非常特别的方式来这样做,这可能与你已经使用多年的模块方式十分接近,也肯能差得很远。

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项目体系结构时,这些新工具应当是你的第一站。

看完两件小事

如果你觉得这篇文章对你挺有启发,我想请你帮我两个小忙:

  1. 关注我们的 GitHub 博客,让我们成为长期关系
  2. 把这篇文章分享给你的朋友 / 交流群,让更多的人看到,一起进步,一起成长!
  3. 关注公众号 「IT平头哥联盟」,公众号后台回复「资源」 免费领取我精心整理的前端进阶资源教程

JS中文网是中国领先的新一代开发者社区和专业的技术媒体,一个帮助开发者成长的社区,目前已经覆盖和服务了超过 300 万开发者,你每天都可以在这里找到技术世界的头条内容。欢迎热爱技术的你一起加入交流与学习,JS中文网的使命是帮助开发者用代码改变世界

results matching ""

    No results matching ""