你不懂JS:ES6与未来

 主页   资讯   文章   代码   电子书 

你不懂JS:ES6与未来

第二章:语法

如果你曾经或多或少地写过JS,那么你很可能对它的语法感到十分熟悉。当然有一些奇怪之处,但是总体来讲这是一种与其他语言有很多相似之处的,相当合理而且直接的语法。

然而,ES6增加了好几种需要费些功夫才能习惯的新语法形式。在这一章中,我们将遍历它们来看看葫芦里到底卖的什么药。

提示: 在写作本书时,这本书中所讨论的特性中的一些已经被各种浏览器(Firefox,Chrome,等等)实现了,但是有一些仅仅被实现了一部分,而另一些根本就没实现。如果直接尝试这些例子,你的体验可能会夹杂着三种情况。如果是这样,就使用转译器尝试吧,这些特性中的大多数都被那些工具涵盖了。ES6Fiddle(http://www.es6fiddle.net/)是一个了不起的尝试ES6的游乐场,简单易用,它是一个Babel转译器的在线REPL(http://babeljs.io/repl/)。

块儿作用域声明

你可能知道在JavaScript中变量作用域的基本单位总是function。如果你需要创建一个作用域的块儿,除了普通的函数声明以外最流行的方法就是使用立即被调用的函数表达式(IIFE)。例如:

var a = 2;

(function IIFE(){
    var a = 3;
    console.log( a );   // 3
})();

console.log( a );       // 2

let声明

但是,现在我们可以创建绑定到任意的块儿上的声明了,它(勿庸置疑地)称为 块儿作用域。这意味着一对{ .. }就是我们用来创建一个作用域所需要的全部。var总是声明附着在外围函数(或者全局,如果在顶层的话)上的变量,取而代之的是,使用let

var a = 2;

{
    let a = 3;
    console.log( a );   // 3
}

console.log( a );       // 2

迄今为止,在JS中使用独立的{ .. }块儿不是很常见,也不是惯用模式,但它总是合法的。而且那些来自拥有 块儿作用域 的语言的开发者将很容易认出这种模式。

我相信使用一个专门的{ .. }块儿是创建块儿作用域变量的最佳方法。但是,你应该总是将let声明放在块儿的最顶端。如果你有多于一个的声明,我推荐只使用一个let

从文体上说,我甚至喜欢将let放在与开放的{的同一行中,以便更清楚地表示这个块儿的目的仅仅是为了这些变量声明作用域。

{   let a = 2, b, c;
    // ..
}

它现在看起来很奇怪,而且不大可能与其他大多数ES6文献中推荐的文法吻合。但我的疯狂是有原因的。

这是另一种实验性的(不是标准化的)let声明形式,称为let块儿,看起来就像这样:

let (a = 2, b, c) {
    // ..
}

我称这种形式为 明确的 块儿作用域,而与var相似的let声明形式更像是 隐含的,因为它在某种意义上劫持了它所处的{ .. }。一般来说开发者们认为 明确的 机制要比 隐含的 机制更好一些,我主张这种情况就是这样的情况之一。

如果你比较前面两个形式的代码段,它们非常相似,而且我个人认为两种形式都有资格在文体上称为 明确的 块儿作用域。不幸的是,两者中最 明确的 let (..) { .. }形式没有被ES6所采用。它可能会在后ES6时代被重新提起,但我想目前为止前者是我们的最佳选择。

为了增强对let ..声明的 隐含 性质的理解,考虑一下这些用法:

let a = 2;

if (a > 1) {
    let b = a * 3;
    console.log( b );       // 6

    for (let i = a; i <= b; i++) {
        let j = i + 10;
        console.log( j );
    }
    // 12 13 14 15 16

    let c = a + b;
    console.log( c );       // 8
}

不要回头去看这个代码段,小测验:哪些变量仅存在于if语句内部?哪些变量仅存在于for循环内部?

答案:if语句包含块儿作用域变量bc,而for循环包含块儿作用域变量ij

你有任何迟疑吗?i没有被加入外围的if语句的作用域让你惊讶吗?思维上的停顿和疑问 —— 我称之为“思维税” —— 不仅源自于let机制对我们来说是新东西,还因为它是 隐含的

还有一个灾难是let c = ..声明出现在作用域中太过靠下的地方。传统的被var声明的变量,无论它们出现在何处,都会被附着在整个外围的函数作用域中;与此不同的是,let声明附着在块儿作用域,而且在它们出现在块儿中之前是不会被初始化的。

在一个let ..声明/初始化之前访问一个用let声明的变量会导致一个错误,而对于var声明来说这个顺序无关紧要(除了文体上的区别)。

考虑如下代码:

{
    console.log( a );   // undefined
    console.log( b );   // ReferenceError!

    var a;
    let b;
}

警告: 这个由于过早访问被let声明的引用而引起的ReferenceError在技术上称为一个 临时死区(Temporal Dead Zone —— TDZ) 错误 —— 你在访问一个已经被声明但还没被初始化的变量。这将不是我们唯一能够见到TDZ错误的地方 —— 在ES6中它们会在几种地方意外地发生。另外,注意“初始化”并不要求在你的代码中明确地赋一个值,比如let b;是完全合法的。一个在声明时没有被赋值的变量被认为已经被赋予了undefined值,所以let b;let b = undefined;是一样的。无论是否明确赋值,在let b语句运行之前你都不能访问b

最后一个坑:对于TDZ变量和未声明的(或声明的!)变量,typeof的行为是不同的。例如:

{
    // `a` 没有被声明
    if (typeof a === "undefined") {
        console.log( "cool" );
    }

    // `b` 被声明了,但位于它的TDZ中
    if (typeof b === "undefined") {     // ReferenceError!
        // ..
    }

    // ..

    let b;
}

a没有被声明,所以typeof是检查它是否存在的唯一安全的方法。但是typeof b抛出了TDZ错误,因为在代码下面很远的地方偶然出现了一个let b声明。噢。

现在你应当清楚为什么我坚持认为所有的let声明都应该位于它们作用域的顶部了。这完全避免了偶然过早访问的错误。当你观察一个块儿,或任何块儿的开始部分时,它还更 明确 地指出这个块儿中含有什么变量。

你的块儿(if语句,while循环,等等)不一定要与作用域行为共享它们原有的行为。

这种明确性要由你负责,由你用毅力来维护,它将为你省去许多重构时的头疼和后续的麻烦。

注意: 更多关于let和块儿作用域的信息,参见本系列的 作用域与闭包 的第三章。

let + for

我偏好 明确 形式的let声明块儿,但对此的唯一例外是出现在for循环头部的let。这里的原因看起来很微妙,但我相信它是更重要的ES6特性中的一个。

考虑如下代码:

var funcs = [];

for (let i = 0; i < 5; i++) {
    funcs.push( function(){
        console.log( i );
    } );
}

funcs[3]();     // 3

for头部中的let i不仅是为for循环本身声明了一个i,而且它为循环的每一次迭代都重新声明了一个新的i。这意味着在循环迭代内部创建的闭包都分别引用着那些在每次迭代中创建的变量,正如你期望的那样。

如果你尝试在这段相同代码的for循环头部使用var i,那么你会得到5而不是3,因为在被引用的外部作用域中只有一个i,而不是为每次迭代的函数都有一个i被引用。

你也可以稍稍繁冗地实现相同的东西:

var funcs = [];

for (var i = 0; i < 5; i++) {
    let j = i;
    funcs.push( function(){
        console.log( j );
    } );
}

funcs[3]();     // 3

在这里,我们强制地为每次迭代都创建一个新的j,然后闭包以相同的方式工作。我喜欢前一种形式;那种额外的特殊能力正是我支持for(let .. ) ..形式的原因。可能有人会争论说它有点儿 隐晦,但是对我的口味来说,它足够 明确 了,也足够有用。

letfor..infor..of(参见“for..of循环”)循环中也以形同的方式工作。

const声明

还有另一种需要考虑的块儿作用域声明:const,它创建 常量

到底什么是一个常量?它是一个在初始值被设定后就成为只读的变量。考虑如下代码:

{
    const a = 2;
    console.log( a );   // 2

    a = 3;              // TypeError!
}

变量持有的值一旦在声明时被设定就不允许你改变了。一个const声明必须拥有一个明确的初始化。如果想要一个持有undefined值的 常量,你必须声明const a = undefined来得到它。

常量不是一个作用于值本身的制约,而是作用于变量对这个值的赋值。换句话说,值不会因为const而冻结或不可变,只是它的赋值被冻结了。如果这个值是一个复杂值,比如对象或数组,那么这个值的内容仍然是可以被修改的:

{
    const a = [1,2,3];
    a.push( 4 );
    console.log( a );       // [1,2,3,4]

    a = 42;                 // TypeError!
}

变量a实际上没有持有一个恒定的数组;而是持有一个指向数组的恒定的引用。数组本身可以自由变化。

警告: 将一个对象或数组作为常量赋值意味着这个值在常量的词法作用域消失以前是不能够被垃圾回收的,因为指向这个值的引用是永远不能解除的。这可能是你期望的,但如果不是你就要小心!

实质上,const声明强制实行了我们许多年来在代码中用文体来表明的东西:我们声明一个名称全由大写字母组成的变量并赋予它某些字面值,我们小心照看它以使它永不改变。var赋值没有强制性,但是现在const赋值上有了,它可以帮你发现不经意的改变。

const可以 被用于forfor..in,和for..of循环(参见“for..of循环”)的变量声明。然而,如果有任何重新赋值的企图,一个错误就会被抛出,例如在for循环中常见的i++子句。

const用还是不用

有些流传的猜测认为在特定的场景下,与letvar相比一个const可能会被JS引擎进行更多的优化。理论上,引擎可以更容易地知道变量的值/类型将永远不会改变,所以它可以免除一些可能的追踪工作。

无论const在这方面是否真的有帮助,还是这仅仅是我们的幻想和直觉,你要做的更重要的决定是你是否打算使用常量的行为。记住:源代码扮演的一个最重要的角色是为了明确地交流你的意图是什么,不仅是与你自己,而且还是与未来的你和其他的代码协作者。

一些开发者喜欢在一开始将每个变量都声明为一个const,然后当它的值在代码中有必要发生变化的时候将声明放松至一个let。这是一个有趣的角度,但是不清楚这是否真正能够改善代码的可读性或可推理性。

就像许多人认为的那样,它不是一种真正的 保护,因为任何后来的想要改变一个const值的开发者都可以盲目地将声明从const改为let。它至多是防止意外的改变。但是同样地,除了我们的直觉和感觉以外,似乎没有客观和明确的标准可以衡量什么构成了“意外”或预防措施。这与类型强制上的思维模式类似。

我的建议:为了避免潜在的令人糊涂的代码,仅将const用于那些你有意地并且明显地标识为不会改变的变量。换言之,不要为了代码行为而 依靠 const,而是在为了意图可以被清楚地表明时,将它作为一个表明意图的工具。

块儿作用域的函数

从ES6开始,发生在块儿内部的函数声明现在被明确规定属于那个块儿的作用域。在ES6之前,语言规范没有要求这一点,但是许多实现不管怎样都是这么做的。所以现在语言规范和现实吻合了。

考虑如下代码:

{
    foo();                  // 好用!

    function foo() {
        // ..
    }
}

foo();                      // ReferenceError

函数foo()是在{ .. }块儿内部被声明的,由于ES6的原因它是属于那里的块儿作用域的。所以在那个块儿的外部是不可用的。但是还要注意它在块儿里面被“提升”了,这与早先提到的遭受TDZ错误陷阱的let声明是相反的。

如果你以前曾经写过这样的代码,并依赖于老旧的非块儿作用域行为的话,那么函数声明的块儿作用域可能是一个问题:

if (something) {
    function foo() {
        console.log( "1" );
    }
}
else {
    function foo() {
        console.log( "2" );
    }
}

foo();      // ??

在前ES6环境下,无论something的值是什么foo()都将会打印"2",因为两个函数声明被提升到了块儿的顶端,而且总是第二个有效。

在ES6中,最后一行将抛出一个ReferenceError

扩散/剩余

ES6引入了一个新的...操作符,根据你在何处以及如何使用它,它一般被称作 扩散(spread)剩余(rest) 操作符。让我们看一看:

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

foo( ...[1,2,3] );              // 1 2 3

...在一个数组(实际上,是我们将在第三章中讲解的任何的 可迭代 对象)前面被使用时,它就将数组“扩散”为它的个别的值。

通常你将会在前面所展示的那样的代码段中看到这种用法,它将一个数组扩散为函数调用的一组参数。在这种用法中,...扮演了apply(..)方法的简约语法替代品,在前ES6中我们经常这样使用apply(..)

foo.apply( null, [1,2,3] );     // 1 2 3

...也可以在其他上下文环境中被用于扩散/展开一个值,比如在另一个数组声明内部:

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

console.log( b );                   // [1,2,3,4,5]

在这种用法中,...取代了concat(..),它在这里的行为就像[1].concat( a, [5] )

另一种...的用法常见于一种实质上相反的操作;与将值散开不同,...将一组值 收集 到一个数组中。

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

foo( 1, 2, 3, 4, 5 );           // 1 2 [3,4,5]

这个代码段中的...z实质上是在说:“将 剩余的 参数值(如果有的话)收集到一个称为z的数组中。” 因为x被赋值为1,而y被赋值为2,所以剩余的参数值34,和5被收集进了z

当然,如果你没有任何命名参数,...会收集所有的参数值:

function foo(...args) {
    console.log( args );
}

foo( 1, 2, 3, 4, 5);            // [1,2,3,4,5]

注意:foo(..)函数声明中的...args经常因为你向其中收集参数的剩余部分而被称为“剩余参数”。我喜欢使用“收集”这个词,因为它描述了它做什么而不是它包含什么。

这种用法最棒的地方是,它为被废弃了很久的arguments数组 —— 实际上它不是一个真正的数组,而是一个类数组对象 —— 提供了一种非常稳健的替代方案。因为args(无论你叫它什么 —— 许多人喜欢叫它r或者rest)是一个真正的数组,我们可以摆脱许多愚蠢的前ES6技巧,我们曾经通过这些技巧尽全力去使arguments变成我们可以视之为数组的东西。

考虑如下代码:

// 使用新的ES6方式
function foo(...args) {
    // `args`已经是一个真正的数组了

    // 丢弃`args`中的第一个元素
    args.shift();

    // 将`args`的所有内容作为参数值传给`console.log(..)`
    console.log( ...args );
}

// 使用老旧的前ES6方式
function bar() {
    // 将`arguments`转换为一个真正的数组
    var args = Array.prototype.slice.call( arguments );

    // 在末尾添加一些元素
    args.push( 4, 5 );

    // 过滤掉所有奇数
    args = args.filter( function(v){
        return v % 2 == 0;
    } );

    // 将`args`的所有内容作为参数值传给`foo(..)`
    foo.apply( null, args );
}

bar( 0, 1, 2, 3 );                  // 2 4

在函数foo(..)声明中的...args收集参数值,而在console.log(..)调用中的...args将它们扩散开。这个例子很好地展示了...操作符平行但相反的用途。

除了在函数声明中...的用法以外,还有另一种...被用于收集值的情况,我们将在本章稍后的“太多,太少,正合适”一节中检视它。

默认参数值

也许在JavaScript中最常见的惯用法之一就是为函数参数设置默认值。我们多年来一直使用的方法应当看起来很熟悉:

function foo(x,y) {
    x = x || 11;
    y = y || 31;

    console.log( x + y );
}

foo();              // 42
foo( 5, 6 );        // 11
foo( 5 );           // 36
foo( null, 6 );     // 17

当然,如果你曾经用过这种模式,你就会知道它既有用又有点儿危险,例如如果你需要能够为其中一个参数传入一个可能被认为是falsy的值。考虑下面的代码:

foo( 0, 42 );       // 53 <-- 噢,不是42

为什么?因为0是falsy,因此x || 11的结果为11,而不是直接被传入的0

为了填这个坑,一些人会像这样更加啰嗦地编写检查:

function foo(x,y) {
    x = (x !== undefined) ? x : 11;
    y = (y !== undefined) ? y : 31;

    console.log( x + y );
}

foo( 0, 42 );           // 42
foo( undefined, 6 );    // 17

当然,这意味着除了undefined以外的任何值都可以直接传入。然而,undefined将被假定是这样一种信号,“我没有传入这个值。” 除非你实际需要能够传入undefined,它就工作的很好。

在那样的情况下,你可以通过测试参数值是否没有出现在arguments数组中,来看它是否实际上被省略了,也许是像这样:

function foo(x,y) {
    x = (0 in arguments) ? x : 11;
    y = (1 in arguments) ? y : 31;

    console.log( x + y );
}

foo( 5 );               // 36
foo( 5, undefined );    // NaN

但是在没有能力传入意味着“我省略了这个参数值”的任何种类的值(连undefined也不行)的情况下,你如何才能省略第一个参数值x呢?

foo(,5)很诱人,但它不是合法的语法。foo.apply(null,[,5])看起来应该可以实现这个技巧,但是apply(..)的奇怪之处意味着这组参数值将被视为[undefined,5],显然它没有被省略。

如果你深入调查下去,你将发现你只能通过简单地传入比“期望的”参数值个数少的参数值来省略末尾的参数值,但是你不能省略在参数值列表中间或者开头的参数值。这就是不可能。

这里有一个施用于JavaScript设计的重要原则需要记住:undefined意味着 缺失。也就是,在undefined缺失 之间没有区别,至少是就函数参数值而言。

注意: 容易令人糊涂的是,JS中有其他的地方不适用这种特殊的设计原则,比如带有空值槽的数组。更多信息参见本系列的 类型与文法

带着所有这些认识,现在我们可以检视在ES6中新增的一种有用的好语法,来简化对丢失的参数值进行默认值的赋值。

function foo(x = 11, y = 31) {
    console.log( x + y );
}

foo();                  // 42
foo( 5, 6 );            // 11
foo( 0, 42 );           // 42

foo( 5 );               // 36
foo( 5, undefined );    // 36 <-- `undefined`是缺失
foo( 5, null );         // 5  <-- null强制转换为`0`

foo( undefined, 6 );    // 17 <-- `undefined`是缺失
foo( null, 6 );         // 6  <-- null强制转换为`0`

注意这些结果,和它们如何暗示了与前面的方式的微妙区别和相似之处。

与常见得多的x || 11惯用法相比,在一个函数声明中的x = 11更像x !== undefined ? x : 11,所以在将你的前ES6代码转换为这种ES6默认参数值语法时要多加小心。

注意: 一个剩余/收集参数(参见“扩散/剩余”)不能拥有默认值。所以,虽然function foo(...vals=[1,2,3]) {看起来是一种迷人的能力,但它不是合法的语法。有必要的话你需要继续手动实施那种逻辑。

默认值表达式

函数默认值可以比像31这样的简单值复杂得多;它们可以是任何合法的表达式,甚至是函数调用:

function bar(val) {
    console.log( "bar called!" );
    return y + val;
}

function foo(x = y + 3, z = bar( x )) {
    console.log( x, z );
}

var y = 5;
foo();                              // "bar called"
                                    // 8 13
foo( 10 );                          // "bar called"
                                    // 10 15
y = 6;
foo( undefined, 10 );               // 9 10

如你所见,默认值表达式是被懒惰地求值的,这意味着他们仅在被需要时运行 —— 也就是,当一个参数的参数值被省略或者为undefined

这是一个微妙的细节,但是在一个函数声明中的正式参数是在它们自己的作用域中的(将它想象为一个仅仅围绕在函数声明的(..)外面的一个作用域气泡),不是在函数体的作用域中。这意味着在一个默认值表达式中的标识符引用会在首先在正式参数的作用域中查找标识符,然后再查找一个外部作用域。更多信息参见本系列的 作用域与闭包

考虑如下代码:

var w = 1, z = 2;

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

foo();                  // ReferenceError

在默认值表达式w + 1中的w在正式参数作用域中查找w,但没有找到,所以外部作用域的w被使用了。接下来,在默认值表达式x + 1中的x在正式参数的作用域中找到了x,而且走运的是x已经被初始化了,所以对y的赋值工作的很好。

然而,z + 1中的z找到了一个在那个时刻还没有被初始化的参数变量z,所以它绝不会试着在外部作用域中寻找z

正如我们在本章早先的“let声明”一节中提到过的那样,ES6拥有一个TDZ,它会防止一个变量在它还没有被初始化的状态下被访问。因此,z + 1默认值表达式抛出一个TDZReferenceError错误。

虽然对于代码的清晰度来说不见得是一个好主意,一个默认值表达式甚至可以是一个内联的函数表达式调用 —— 通常被称为一个立即被调用的函数表达式(IIFE):

function foo( x =
    (function(v){ return v + 11; })( 31 )
) {
    console.log( x );
}

foo();          // 42

一个IIFE(或者任何其他被执行的内联函数表达式)作为默认值表示来说很合适是非常少见的。如果你发现自己试图这么做,那么就退一步再考虑一下!

警告: 如果一个IIFE试图访问标识符x,而且还没有声明自己的x,那么这也将是一个TDZ错误,就像我们刚才讨论的一样。

前一个代码段的默认值表达式是一个IIFE,这是因为它是通过(31)在内联时立即被执行。如果我们去掉这一部分,赋予x的默认值将会仅仅是一个函数的引用,也许像一个默认的回调。可能有一些情况这种模式将十分有用,比如:

function ajax(url, cb = function(){}) {
    // ..
}

ajax( "http://some.url.1" );

这种情况下,我们实质上想在没有其他值被指定时,让默认的cb是一个没有操作的空函数。这个函数表达式只是一个函数引用,不是一个调用它自己(在它末尾没有调用的())以达成自己目的的函数。

从JS的早些年开始,就有一个少为人知但是十分有用的奇怪之处可供我们使用:Function.prototype本身就是一个没有操作的空函数。这样,这个声明可以是cb = Function.prototype而省去内联函数表达式的创建。

解构

ES6引入了一个称为 解构 的新语法特性,如果你将它考虑为 结构化赋值 那么它令人困惑的程度可能会小一些。为了理解它的含义,考虑如下代码:

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

var tmp = foo(),
    a = tmp[0], b = tmp[1], c = tmp[2];

console.log( a, b, c );             // 1 2 3

如你所见,我们创建了一个手动赋值:从foo()返回的数组中的值到个别的变量ab,和c,而且这么做我们就(不幸地)需要tmp变量。

相似地,我们也可以用对象这么做:

function bar() {
    return {
        x: 4,
        y: 5,
        z: 6
    };
}

var tmp = bar(),
    x = tmp.x, y = tmp.y, z = tmp.z;

console.log( x, y, z );             // 4 5 6

属性值tmp.x被赋值给变量xtmp.yytmp.zz也一样。

从一个数组中取得索引的值,或从一个对象中取得属性并手动赋值可以被认为是 结构化赋值。ES6为 解构 增加了一种专门的语法,具体地称为 数组解构对象结构。这种语法消灭了前一个代码段中对变量tmp的需要,使它们更加干净。考虑如下代码:

var [ a, b, c ] = foo();
var { x: x, y: y, z: z } = bar();

console.log( a, b, c );             // 1 2 3
console.log( x, y, z );             // 4 5 6

你很可能更加习惯于看到像[a,b,c]这样的东西出现在一个=赋值的右手边的语法,即作为要被赋予的值。

解构对称地翻转了这个模式,所以在=赋值左手边的[a,b,c]被看作是为了将右手边的数组拆解为分离的变量赋值的某种“模式”。

类似地,{ x: x, y: y, z: z }指明了一种“模式”把来自于bar()的对象拆解为分离的变量赋值。

对象属性赋值模式

让我们深入前一个代码段中的{ x: x, .. }语法。如果属性名与你想要声明的变量名一致,你实际上可以缩写这个语法:

var { x, y, z } = bar();

console.log( x, y, z );             // 4 5 6

很酷,对吧?

{ x, .. }是省略了x:部分还是省略了: x部分?当我们使用这种缩写语法时,我们实际上省略了x:部分。这看起来可能不是一个重要的细节,但是一会儿你就会了解它的重要性。

如果你能写缩写形式,那为什么你还要写出更长的形式呢?因为更长的形式事实上允许你将一个属性赋值给一个不同的变量名称,这有时很有用:

var { x: bam, y: baz, z: bap } = bar();

console.log( bam, baz, bap );       // 4 5 6
console.log( x, y, z );             // ReferenceError

关于这种对象结构形式有一个微妙但超级重要的怪异之处需要理解。为了展示为什么它可能是一个你需要注意的坑,让我们考虑一下普通对象字面量的“模式”是如何被指定的:

var X = 10, Y = 20;

var o = { a: X, b: Y };

console.log( o.a, o.b );            // 10 20

{ a: X, b: Y }中,我们知道a是对象属性,而X是被赋值给它的源值。换句话说,它的语义模式是目标: 源,或者更明显地,属性别名: 值。我们能直观地明白这一点,因为它和=赋值是一样的,而它的模式就是目标 = 源

然而,当你使用对象解构赋值时 —— 也就是,将看起来像是对象字面量的{ .. }语法放在=操作符的左手边 —— 你反转了这个目标: 源的模式。

回想一下:

var { x: bam, y: baz, z: bap } = bar();

这里面对称的模式是源: 目标(或者值: 属性别名)。x: bam意味着属性x是源值而bam是被赋值的目标变量。换句话说,对象字面量是target <-- source,而对象解构赋值是source --> target。看到它是如何反转的了吗?

有另外一种考虑这种语法的方式,可能有助于缓和这种困惑。考虑如下代码:

var aa = 10, bb = 20;

var o = { x: aa, y: bb };
var     { x: AA, y: BB } = o;

console.log( AA, BB );              // 10 20

{ x: aa, y: bb }这一行中,xy代表对象属性。在{ x: AA, y: BB }这一行,xy 代表对象属性。

还记得刚才我是如何断言{ x, .. }省去了x:部分的吗?在这两行中,如果你在代码段中擦掉x:y:部分,仅留下aa, bbAA, BB,它的效果 —— 从概念上讲,实际上不能 —— 将是从aa赋值到AA和从bb赋值到BB

所以,这种平行性也许有助于解释为什么对于这种ES6特性,语法模式被故意地反转了。

注意: 对于解构赋值来说我更喜欢它的语法是{ AA: x , BB: y },因为那样的话可以在两种用法中一致地使用我们更熟悉的target: source模式。唉,我已经被迫训练自己的大脑去习惯这种反转了,就像一些读者也不得不去做的那样。

不仅是声明

至此,我们一直将解构赋值与var声明(当然,它们也可以使用letconst)一起使用,但是解构是一种一般意义上的赋值操作,不仅是一种声明。

考虑如下代码:

var a, b, c, x, y, z;

[a,b,c] = foo();
( { x, y, z } = bar() );

console.log( a, b, c );             // 1 2 3
console.log( x, y, z );             // 4 5 6

变量可以是已经被定义好的,然后解构仅仅负责赋值,正如我们已经看到的那样。

注意: 特别对于对象解构形式来说,当我们省略了var/let/const声明符时,就必须将整个赋值表达式包含在()中,因为如果不这样做的话左手边作为语句第一个元素的{ .. }将被视为一个语句块儿而不是一个对象。

事实上,变量表达式(ay,等等)不必是一个变量标识符。任何合法的赋值表达式都是允许的。例如:

var o = {};

[o.a, o.b, o.c] = foo();
( { x: o.x, y: o.y, z: o.z } = bar() );

console.log( o.a, o.b, o.c );       // 1 2 3
console.log( o.x, o.y, o.z );       // 4 5 6

你甚至可以在解构中使用计算型属性名。考虑如下代码:

var which = "x",
    o = {};

( { [which]: o[which] } = bar() );

console.log( o.x );                 // 4

[which]:的部分是计算型属性名,它的结果是x —— 将从当前的对象中拆解出来作为赋值的源头的属性。o[which]的部分只是一个普通的对象键引用,作为赋值的目标来说它与o.x是等价的。

你可以使用普通的赋值来创建对象映射/变形,例如:

var o1 = { a: 1, b: 2, c: 3 },
    o2 = {};

( { a: o2.x, b: o2.y, c: o2.z } = o1 );

console.log( o2.x, o2.y, o2.z );    // 1 2 3

或者你可以将对象映射进一个数组,例如:

var o1 = { a: 1, b: 2, c: 3 },
    a2 = [];

( { a: a2[0], b: a2[1], c: a2[2] } = o1 );

console.log( a2 );                  // [1,2,3]

或者从另一个方向:

var a1 = [ 1, 2, 3 ],
    o2 = {};

[ o2.a, o2.b, o2.c ] = a1;

console.log( o2.a, o2.b, o2.c );    // 1 2 3

或者你可以将一个数组重排到另一个数组中:

var a1 = [ 1, 2, 3 ],
    a2 = [];

[ a2[2], a2[0], a2[1] ] = a1;

console.log( a2 );                  // [2,3,1]

你甚至可以不使用临时变量来解决传统的“交换两个变量”的问题:

var x = 10, y = 20;

[ y, x ] = [ x, y ];

console.log( x, y );                // 20 10

警告: 小心:你不应该将声明和赋值混在一起,除非你想要所有的赋值表达式 被视为声明。否则,你会得到一个语法错误。这就是为什么在刚才的例子中我必须将var a2 = [][ a2[0], .. ] = ..解构赋值分开做。尝试var [ a2[0], .. ] = ..没有任何意义,因为a2[0]不是一个合法的声明标识符;很显然它也不能隐含地创建一个var a2 = []声明来使用。

重复赋值

对象解构形式允许源属性(持有任意值的类型)被罗列多次。例如:

var { a: X, a: Y } = { a: 1 };

X;  // 1
Y;  // 1

这意味着你既可以解构一个子对象/数组属性,也可以捕获这个子对象/数组的值本身。考虑如下代码:

var { a: { x: X, x: Y }, a } = { a: { x: 1 } };

X;  // 1
Y;  // 1
a;  // { x: 1 }

( { a: X, a: Y, a: [ Z ] } = { a: [ 1 ] } );

X.push( 2 );
Y[0] = 10;

X;  // [10,2]
Y;  // [10,2]
Z;  // 1

关于解构有一句话要提醒:像我们到目前为止的讨论中做的那样,将所有的解构赋值都罗列在单独一行中的方式可能很诱人。然而,一个好得多的主意是使用恰当的缩进将解构赋值的模式分散在多行中 —— 和你在JSON或对象字面量中做的事非常相似 —— 为了可读性。

// 很难读懂:
var { a: { b: [ c, d ], e: { f } }, g } = obj;

// 好一些:
var {
    a: {
        b: [ c, d ],
        e: { f }
    },
    g
} = obj;

记住:解构的目的不仅是为了少打些字,更多是为了声明可读性

解构赋值表达式

带有对象或数组解构的赋值表达式的完成值是右手边完整的对象/数组值。考虑如下代码:

var o = { a:1, b:2, c:3 },
    a, b, c, p;

p = { a, b, c } = o;

console.log( a, b, c );         // 1 2 3
p === o;                        // true

在前面的代码段中,p被赋值为对象o的引用,而不是ab,或c的值。数组解构也是一样:

var o = [1,2,3],
    a, b, c, p;

p = [ a, b, c ] = o;

console.log( a, b, c );         // 1 2 3
p === o;                        // true

通过将这个对象/数组作为完成值传递下去,你可将解构赋值表达式链接在一起:

var o = { a:1, b:2, c:3 },
    p = [4,5,6],
    a, b, c, x, y, z;

( {a} = {b,c} = o );
[x,y] = [z] = p;

console.log( a, b, c );         // 1 2 3
console.log( x, y, z );         // 4 5 4

太多,太少,正合适

对于数组解构赋值和对象解构赋值两者来说,你不必分配所有出现的值。例如:

var [,b] = foo();
var { x, z } = bar();

console.log( b, x, z );             // 2 4 6

foo()返回的值13被丢弃了,从bar()返回的值5也是。

相似地,如果你试着分配比你正在解构/拆解的值要多的值时,它们会如你所想的那样安静地退回到undefined

var [,,c,d] = foo();
var { w, z } = bar();

console.log( c, z );                // 3 6
console.log( d, w );                // undefined undefined

这种行为平行地遵循早先提到的“undefined意味着缺失”原则。

我们在本章早先检视了...操作符,并看到了它有时可以用于将一个数组值扩散为它的分离值,而有时它可以被用于相反的操作:将一组值收集进一个数组。

除了在函数声明中的收集/剩余用法以外,...可以在解构赋值中实施相同的行为。为了展示这一点,让我们回想一下本章早先的一个代码段:

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

console.log( b );                   // [1,2,3,4,5]

我们在这里看到因为...a出现在数组[ .. ]中值的位置,所以它将a扩散开。如果...a出现一个数组解构的位置,它会实施收集行为:

var a = [2,3,4];
var [ b, ...c ] = a;

console.log( b, c );                // 2 [3,4]

解构赋值var [ .. ] = a为了将a赋值给在[ .. ]中描述的模式而将它扩散开。第一部分的名称b对应a中的第一个值(2)。然后...c将剩余的值(34)收集到一个称为c的数组中。

注意: 我们已经看到...是如何与数组一起工作的,但是对象呢?那不是一个ES6特性,但是参看第八章中关于一种可能的“ES6之后”的特性的讨论,它可以让...扩散或者收集对象。

默认值赋值

两种形式的解构都可以为赋值提供默认值选项,它使用和早先讨论过的默认函数参数值相似的=语法。

考虑如下代码:

var [ a = 3, b = 6, c = 9, d = 12 ] = foo();
var { x = 5, y = 10, z = 15, w = 20 } = bar();

console.log( a, b, c, d );          // 1 2 3 12
console.log( x, y, z, w );          // 4 5 6 20

你可以将默认值赋值与前面讲过的赋值表达式语法组合在一起。例如:

var { x, y, z, w: WW = 20 } = bar();

console.log( x, y, z, WW );         // 4 5 6 20

如果你在一个解构中使用一个对象或者数组作为默认值,那么要小心不要把自己(或者读你的代码的其他开发者)搞糊涂了。你可能会创建一些非常难理解的代码:

var x = 200, y = 300, z = 100;
var o1 = { x: { y: 42 }, z: { y: z } };

( { y: x = { y: y } } = o1 );
( { z: y = { y: z } } = o1 );
( { x: z = { y: x } } = o1 );

你能从这个代码段中看出xyz最终是什么值吗?花点儿时间好好考虑一下,我能想象你的样子。我会终结这个悬念:

console.log( x.y, y.y, z.y );       // 300 100 42

这里的要点是:解构很棒也可以很有用,但是如果使用得不明智,它也是一把可以伤人(某人的大脑)的利剑。

嵌套解构

如果你正在解构的值拥有嵌套的对象或数组,你也可以解构这些嵌套的值:

var a1 = [ 1, [2, 3, 4], 5 ];
var o1 = { x: { y: { z: 6 } } };

var [ a, [ b, c, d ], e ] = a1;
var { x: { y: { z: w } } } = o1;

console.log( a, b, c, d, e );       // 1 2 3 4 5
console.log( w );                   // 6

嵌套的解构可以是一种将对象名称空间扁平化的简单方法。例如:

var App = {
    model: {
        User: function(){ .. }
    }
};

// 取代:
// var User = App.model.User;

var { model: { User } } = App;

参数解构

你能在下面的代码段中发现赋值吗?

function foo(x) {
    console.log( x );
}

foo( 42 );

其中的赋值有点儿被隐藏的感觉:当foo(42)被执行时42(参数值)被赋值给x(参数)。如果参数/参数值对是一种赋值,那么按常理说它是一个可以被解构的赋值,对吧?当然!

考虑参数的数组解构:

function foo( [ x, y ] ) {
    console.log( x, y );
}

foo( [ 1, 2 ] );                    // 1 2
foo( [ 1 ] );                       // 1 undefined
foo( [] );                          // undefined undefined

参数也可以进行对象解构:

function foo( { x, y } ) {
    console.log( x, y );
}

foo( { y: 1, x: 2 } );              // 2 1
foo( { y: 42 } );                   // undefined 42
foo( {} );                          // undefined undefined

这种技术是命名参数值(一个长期以来被渴求的JS特性!)的一种近似解法:对象上的属性映射到被解构的同名参数上。这也意味着我们免费地(在任何位置)得到了可选参数,如你所见,省去“参数”x可以如我们期望的那样工作。

当然,先前讨论过的所有解构的种类对于参数解构来说都是可用的,包括嵌套解构,默认值,和其他。解构也可以和其他ES6函数参数功能很好地混合在一起,比如默认参数值和剩余/收集参数。

考虑这些快速的示例(当然这没有穷尽所有可能的种类):

function f1([ x=2, y=3, z ]) { .. }
function f2([ x, y, ...z], w) { .. }
function f3([ x, y, ...z], ...w) { .. }

function f4({ x: X, y }) { .. }
function f5({ x: X = 10, y = 20 }) { .. }
function f6({ x = 10 } = {}, { y } = { y: 10 }) { .. }

为了展示一下,让我们从这个代码段中取一个例子来检视:

function f3([ x, y, ...z], ...w) {
    console.log( x, y, z, w );
}

f3( [] );                           // undefined undefined [] []
f3( [1,2,3,4], 5, 6 );              // 1 2 [3,4] [5,6]

这里使用了两个...操作符,他们都是将值收集到数组中(zw),虽然...z是从第一个数组参数值的剩余值中收集,而...w是从第一个之后的剩余主参数值中收集的。

解构默认值 + 参数默认值

有一个微妙的地方你应当注意要特别小心 —— 解构默认值与函数参数默认值的行为之间的不同。例如:

function f6({ x = 10 } = {}, { y } = { y: 10 }) {
    console.log( x, y );
}

f6();                               // 10 10

首先,看起来我们用两种不同的方法为参数xy都声明了默认值10。然而,这两种不同的方式会在特定的情况下表现出不同的行为,而且这种区别极其微妙。

考虑如下代码:

f6( {}, {} );                       // 10 undefined

等等,为什么会这样?十分清楚,如果在第一个参数值的对象中没有一个同名属性被传递,那么命名参数x将默认为10

yundefined是怎么回事儿?值{ y: 10 }是一个作为函数参数默认值的对象,不是结构默认值。因此,它仅在第二个参数根本没有被传递,或者undefined被传递时生效,

在前面的代码段中,我们传递了第二个参数({}),所以默认值{ y: 10 }不被使用,而解构{ y }会针对被传入的空对象值{}发生。

现在,将{ y } = { y: 10 }{ x = 10 } = {}比较一下。

对于x的使用形式来说,如果第一个函数参数值被省略或者是undefined,会默认地使用空对象{}。然后,不管在第一个参数值的位置上是什么值 —— 要么是默认的{},要么是你传入的 —— 都会被{ x = 10 }解构,它会检查属性x是否被找到,如果没有找到(或者是undefined),默认值10会被设置到命名参数x上。

深呼吸。回过头去把最后几段多读几遍。让我们用代码复习一下:

function f6({ x = 10 } = {}, { y } = { y: 10 }) {
    console.log( x, y );
}

f6();                               // 10 10
f6( undefined, undefined );         // 10 10
f6( {}, undefined );                // 10 10

f6( {}, {} );                       // 10 undefined
f6( undefined, {} );                // 10 undefined

f6( { x: 2 }, { y: 3 } );           // 2 3

一般来说,与参数y的默认行为比起来,参数x的默认行为可能看起来更可取也更合理。因此,理解{ x = 10 } = {}形式与{ y } = { y: 10 }形式为何与如何不同是很重要的。

如果这仍然有点儿模糊,回头再把它读一遍,并亲自把它玩弄一番。未来的你将会感谢你花了时间把这种非常微妙的,晦涩的细节的坑搞明白。

嵌套默认值:解构与重构

虽然一开始可能很难掌握,但是为一个嵌套的对象的属性设置默认值产生了一种有趣的惯用法:将对象解构与一种我称为 重构 的东西一起使用。

考虑在一个嵌套的对象结构中的一组默认值,就像下面这样:

// 摘自:http://es-discourse.com/t/partial-default-arguments/120/7

var defaults = {
    options: {
        remove: true,
        enable: false,
        instance: {}
    },
    log: {
        warn: true,
        error: true
    }
};

现在,我们假定你有一个称为config的对象,它有一些这其中的值,但也许不全有,而且你想要将所有的默认值设置到这个对象的缺失点上,但不覆盖已经存在的特定设置:

var config = {
    options: {
        remove: false,
        instance: null
    }
};

你当然可以手动这样做,就像你可能曾经做过的那样:

config.options = config.options || {};
config.options.remove = (config.options.remove !== undefined) ?
    config.options.remove : defaults.options.remove;
config.options.enable = (config.options.enable !== undefined) ?
    config.options.enable : defaults.options.enable;
...

讨厌。

另一些人可能喜欢用覆盖赋值的方式来完成这个任务。你可能会被ES6的Object.assign(..)工具(见第六章)所吸引,来首先克隆defaults中的属性然后使用从config中克隆的属性覆盖它,像这样:

config = Object.assign( {}, defaults, config );

这看起来好多了,是吧?但是这里有一个重大问题!Object.assign(..)是浅拷贝,这意味着当它拷贝defaults.options时,它仅仅拷贝这个对象的引用,而不是深度克隆这个对象的属性到一个config.options对象。Object.assign(..)需要在你的对象树的每一层中实施才能得到你期望的深度克隆。

注意: 许多JS工具库/框架都为对象的深度克隆提供它们自己的选项,但是那些方式和它们的坑超出了我们在这里的讨论范围。

那么让我们检视一下ES6的带有默认值的对象解构能否帮到我们:

config.options = config.options || {};
config.log = config.log || {};
({
    options: {
        remove: config.options.remove = defaults.options.remove,
        enable: config.options.enable = defaults.options.enable,
        instance: config.options.instance = defaults.options.instance
    } = {},
    log: {
        warn: config.log.warn = defaults.log.warn,
        error: config.log.error = defaults.log.error
    } = {}
} = config);

不像Object.assign(..)的虚假诺言(因为它只是浅拷贝)那么好,但是我想它要比手动的方式强多了。虽然它仍然很不幸地带有冗余和重复。

前面的代码段的方式可以工作,因为我黑进了结构和默认机制来为我做属性的=== undefined检查和赋值的决定。这里的技巧是,我解构了config(看看在代码段末尾的= config),但是我将所有解构出来的值又立即赋值回config,带着config.options.enable赋值引用。

但还是太多了。让我们看看能否做得更好。

下面的技巧在你知道你正在解构的所有属性的名称都是唯一的情况下工作得最好。但即使不是这样的情况你也仍然可以使用它,只是没有那么好 —— 你将不得不分阶段解构,或者创建独一无二的本地变量作为临时的别名。

如果我们将所有的属性完全解构为顶层变量,那么我们就可以立即重构来重组原本的嵌套对象解构。

但是所有那些游荡在外的临时变量将会污染作用域。所以,让我们通过一个普通的{ }包围块儿来使用块儿作用域(参见本章早先的“块儿作用域声明”)。

// 将`defaults`混入`config`
{
    // 解构(使用默认值赋值)
    let {
        options: {
            remove = defaults.options.remove,
            enable = defaults.options.enable,
            instance = defaults.options.instance
        } = {},
        log: {
            warn = defaults.log.warn,
            error = defaults.log.error
        } = {}
    } = config;

    // 重构
    config = {
        options: { remove, enable, instance },
        log: { warn, error }
    };
}

这看起来好多了,是吧?

注意: 你也可以使用箭头IIFE来代替一般的{ }块儿和let声明来达到圈占作用域的目的。你的解构赋值/默认值将位于参数列表中,而你的重构将位于函数体的return语句中。

在重构部分的{ warn, error }语法可能是你初次见到;它称为“简约属性”,我们将在下一节讲解它!

对象字面量扩展

ES6给不起眼儿的{ .. }对象字面量增加了几个重要的便利扩展。

简约属性

你一定很熟悉用这种形式的对象字面量声明:

var x = 2, y = 3,
    o = {
        x: x,
        y: y
    };

如果到处说x: x总是让你感到繁冗,那么有个好消息。如果你需要定义一个名称和词法标识符一致的属性,你可以将它从x: x缩写为x。考虑如下代码:

var x = 2, y = 3,
    o = {
        x,
        y
    };

简约方法

本着与我们刚刚检视的简约属性相同的精神,添附在对象字面量属性上的函数也有一种便利简约形式。

以前的方式:

var o = {
    x: function(){
        // ..
    },
    y: function(){
        // ..
    }
}

而在ES6中:

var o = {
    x() {
        // ..
    },
    y() {
        // ..
    }
}

警告: 虽然x() { .. }看起来只是x: function(){ .. }的缩写,但是简约方法有一种特殊行为,是它们对应的老方式所不具有的;确切地说,是允许super(参见本章稍后的“对象super”)的使用。

Generator(见第四章)也有一种简约方法形式:

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

简约匿名

虽然这种便利缩写十分诱人,但是这其中有一个微妙的坑要小心。为了展示这一点,让我们检视一下如下的前ES6代码,你可能会试着使用简约方法来重构它:

function runSomething(o) {
    var x = Math.random(),
        y = Math.random();

    return o.something( x, y );
}

runSomething( {
    something: function something(x,y) {
        if (x > y) {
            // 使用相互对调的`x`和`y`来递归地调用
            return something( y, x );
        }

        return y - x;
    }
} );

这段蠢代码只是生成两个随机数,然后用大的减去小的。但这里重要的不是它做的是什么,而是它是如何被定义的。让我把焦点放在对象字面量和函数定义上,就像我们在这里看到的:

runSomething( {
    something: function something(x,y) {
        // ..
    }
} );

为什么我们同时说something:function something?这不是冗余吗?实际上,不是,它们俩被用于不同的目的。属性something让我们能够调用o.something(..),有点儿像它的公有名称。但是第二个something是一个词法名称,使这个函数可以为了递归而从内部引用它自己。

你能看出来为什么return something(y,x)这一行需要名称something来引用这个函数吗?因为这里没有对象的词法名称,要是有的话我们就可以说return o.something(y,x)或者其他类似的东西。

当一个对象字面量的确拥有一个标识符名称时,这其实是一个很常见的做法,比如:

var controller = {
    makeRequest: function(..){
        // ..
        controller.makeRequest(..);
    }
};

这是个好主意吗?也许是,也许不是。你在假设名称controller将总是指向目标对象。但它也很可能不是 —— 函数makeRequest(..)不能控制外部的代码,因此不能强制你的假设一定成立。这可能会回过头来咬到你。

另一些人喜欢使用this定义这样的东西:

var controller = {
    makeRequest: function(..){
        // ..
        this.makeRequest(..);
    }
};

这看起来不错,而且如果你总是用controller.makeRequest(..)来调用方法的话它就应该能工作。但现在你有一个this绑定的坑,如果你做这样的事情的话:

btn.addEventListener( "click", controller.makeRequest, false );

当然,你可以通过传递controller.makeRequest.bind(controller)作为绑定到事件上的处理器引用来解决这个问题。但是这很讨厌 —— 它不是很吸引人。

或者要是你的内部this.makeRequest(..)调用需要从一个嵌套的函数内发起呢?你会有另一个this绑定灾难,人们经常使用var self = this这种用黑科技解决,就像:

var controller = {
    makeRequest: function(..){
        var self = this;

        btn.addEventListener( "click", function(){
            // ..
            self.makeRequest(..);
        }, false );
    }
};

更讨厌。

注意: 更多关于this绑定规则和陷阱的信息,参见本系列的 this与对象原型 的第一到二章。

好了,这些与简约方法有什么关系?回想一下我们的something(..)方法定义:

runSomething( {
    something: function something(x,y) {
        // ..
    }
} );

在这里的第二个something提供了一个超级便利的词法标识符,它总是指向函数自己,给了我们一个可用于递归,事件绑定/解除等等的完美引用 —— 不用乱搞this或者使用不可靠的对象引用。

太好了!

那么,现在我们试着将函数引用重构为这种ES6解约方法的形式:

runSomething( {
    something(x,y) {
        if (x > y) {
            return something( y, x );
        }

        return y - x;
    }
} );

第一眼看上去不错,除了这个代码将会坏掉。return something(..)调用经不会找到something标识符,所以你会得到一个ReferenceError。噢,但为什么?

上面的ES6代码段将会被翻译为:

runSomething( {
    something: function(x,y){
        if (x > y) {
            return something( y, x );
        }

        return y - x;
    }
} );

仔细看。你看出问题了吗?简约方法定义暗指something: function(x,y)。看到我们依靠的第二个something是如何被省略的了吗?换句话说,简约方法暗指匿名函数表达式。

对,讨厌。

注意: 你可能认为在这里=>箭头函数是一个好的解决方案。但是它们也同样不够,因为它们也是匿名函数表达式。我们将在本章稍后的“箭头函数”中讲解它们。

一个部分地补偿了这一点的消息是,我们的简约函数something(x,y)将不会是完全匿名的。参见第七章的“函数名”来了解ES6函数名称的推断规则。这不会在递归中帮到我们,但是它至少在调试时有用处。

那么我们怎样总结简约方法?它们简短又甜蜜,而且很方便。但是你应当仅在你永远不需要将它们用于递归或事件绑定/解除时使用它们。否则,就坚持使用你的老式something: function something(..)方法定义。

你的很多方法都将可能从简约方法定义中受益,这是个非常好的消息!只要小心几处未命名的灾难就好。

ES5 Getter/Setter

技术上讲,ES5定义了getter/setter字面形式,但是看起来它们没有被太多地使用,这主要是由于缺乏转译器来处理这种新的语法(其实,它是ES5中加入的唯一的主要新语法)。所以虽然它不是一个ES6的新特性,我们也将简单地复习一下这种形式,因为它可能会随着ES6的向前发展而变得有用得多。

考虑如下代码:

var o = {
    __id: 10,
    get id() { return this.__id++; },
    set id(v) { this.__id = v; }
}

o.id;           // 10
o.id;           // 11
o.id = 20;
o.id;           // 20

// 而:
o.__id;         // 21
o.__id;         // 还是 —— 21!

这些getter和setter字面形式也可以出现在类中;参见第三章。

警告: 可能不太明显,但是setter字面量必须恰好有一个被声明的参数;省略它或罗列其他的参数都是不合法的语法。这个单独的必须参数 可以 使用解构和默认值(例如,set id({ id: v = 0 }) { .. }),但是收集/剩余...是不允许的(set id(...v) { .. })。

计算型属性名

你可能曾经遇到过像下面的代码段那样的情况,你的一个或多个属性名来自于某种表达式,因此你不能将它们放在对象字面量中:

var prefix = "user_";

var o = {
    baz: function(..){ .. }
};

o[ prefix + "foo" ] = function(..){ .. };
o[ prefix + "bar" ] = function(..){ .. };
..

ES6为对象字面定义增加了一种语法,它允许你指定一个应当被计算的表达式,其结果就是被赋值属性名。考虑如下代码:

var prefix = "user_";

var o = {
    baz: function(..){ .. },
    [ prefix + "foo" ]: function(..){ .. },
    [ prefix + "bar" ]: function(..){ .. }
    ..
};

任何合法的表达式都可以出现在位于对象字面定义的属性名位置的[ .. ]内部。

很有可能,计算型属性名最经常与Symbol(我们将在本章稍后的“Symbol”中讲解)一起使用,比如:

var o = {
    [Symbol.toStringTag]: "really cool thing",
    ..
};

Symbol.toStringTag是一个特殊的内建值,我们使用[ .. ]语法求值得到,所以我们可以将值"really cool thing"赋值给这个特殊的属性名。

计算型属性名还可以作为简约方法或简约generator的名称出现:

var o = {
    ["f" + "oo"]() { .. }   // 计算型简约方法
    *["b" + "ar"]() { .. }  // 计算型简约generator
};

设置[[Prototype]]

我们不会在这里讲解原型的细节,所以关于它的更多信息,参见本系列的 this与对象原型

有时候在你声明对象字面量的同时给它的[[Prototype]]赋值很有用。下面的代码在一段时期内曾经是许多JS引擎的一种非标准扩展,但是在ES6中得到了标准化:

var o1 = {
    // ..
};

var o2 = {
    __proto__: o1,
    // ..
};

o2是用一个对象字面量声明的,但它也被[[Prototype]]链接到了o1。这里的__proto__属性名还可以是一个字符串"__proto__",但是要注意它 不能 是一个计算型属性名的结果(参见前一节)。

客气点儿说,__proto__是有争议的。在ES6中,它看起来是一个最终被很勉强地标准化了的,几十年前的自主扩展功能。实际上,它属于ES6的“Annex B”,这一部分罗列了JS感觉它仅仅为了兼容性的原因,而不得不标准化的东西。

警告: 虽然我勉强赞同在一个对象字面定义中将__proto__作为一个键,但我绝对不赞同在对象属性形式中使用它,就像o.__proto__。这种形式既是一个getter也是一个setter(同样也是为了兼容性的原因),但绝对存在更好的选择。更多信息参见本系列的 this与对象原型

对于给一个既存的对象设置[[Prototype]],你可以使用ES6的工具Object.setPrototypeOf(..)。考虑如下代码:

var o1 = {
    // ..
};

var o2 = {
    // ..
};

Object.setPrototypeOf( o2, o1 );

注意: 我们将在第六章中再次讨论Object。“Object.setPrototypeOf(..)静态函数”提供了关于Object.setPrototypeOf(..)的额外细节。另外参见“Object.assign(..)静态函数”来了解另一种将o2原型关联到o1的形式。

对象super

super通常被认为是仅与类有关。然而,由于JS对象仅有原型而没有类的性质,super是同样有效的,而且在普通对象的简约方法中行为几乎一样。

考虑如下代码:

var o1 = {
    foo() {
        console.log( "o1:foo" );
    }
};

var o2 = {
    foo() {
        super.foo();
        console.log( "o2:foo" );
    }
};

Object.setPrototypeOf( o2, o1 );

o2.foo();       // o1:foo
                // o2:foo

警告: super仅在简约方法中允许使用,而不允许在普通的函数表达式属性中。而且它还仅允许使用super.XXX形式(属性/方法访问),而不是super()形式。

在方法o2.foo()中的super引用被静态地锁定在了o2,而且明确地说是o2[[Prototype]]。这里的super基本上是Object.getPrototypeOf(o2) —— 显然被解析为o1 —— 这就是他如何找到并调用o1.foo()的。

关于super的完整细节,参见第三章的“类”。

模板字面量

在这一节的最开始,我将不得不呼唤这个ES6特性的极其……误导人的名称,这要看在你的经验中 模板(template) 一词的含义是什么。

许多开发者认为模板是一段可复用的,可重绘的文本,就像大多数模板引擎(Mustache,Handlebars,等等)提供的能力那样。ES6中使用的 模板 一词暗示着相似的东西,就像一种声明可以被重绘的内联模板字面量的方法。然而,这根本不是考虑这个特性的正确方式。

所以,在我们继续之前,我把它重命名为它本应被称呼的名字:插值型字符串字面量(或者略称为 插值型字面量)。

你已经十分清楚地知道了如何使用"'分隔符来声明字符串字面量,而且你还知道它们不是(像有些语言中拥有的)内容将被解析为插值表达式的 智能字符串

但是,ES6引入了一种新型的字符串字面量,使用反引号`作为分隔符。这些字符串字面量允许嵌入基本的字符串插值表达式,之后这些表达式自动地被解析和求值。

这是老式的前ES6方式:

var name = "Kyle";

var greeting = "Hello " + name + "!";

console.log( greeting );            // "Hello Kyle!"
console.log( typeof greeting );     // "string"

现在,考虑这种新的ES6方式:

var name = "Kyle";

var greeting = `Hello ${name}!`;

console.log( greeting );            // "Hello Kyle!"
console.log( typeof greeting );     // "string"

如你所见,我们在一系列被翻译为字符串字面量的字符周围使用了`..`,但是${..}形式中的任何表达式都将立即内联地被解析和求值。称呼这样的解析和求值的高大上名词就是 插值(interpolation)(比模板要准确多了)。

被插值的字符串字面量表达式的结果只是一个老式的普通字符串,赋值给变量greeting

警告: typeof greeting == "string"展示了为什么不将这些实体考虑为特殊的模板值很重要,因为你不能将这种字面量的未求值形式赋值给某些东西并复用它。`..`字符串字面量在某种意义上更像是IIFE,因为它自动内联地被求值。`..`字符串字面量的结果只不过是一个简单的字符串。

插值型字符串字面量的一个真正的好处是他们允许被分割为多行:

var text =
`Now is the time for all good men
to come to the aid of their
country!`;

console.log( text );
// Now is the time for all good men
// to come to the aid of their
// country!

在插值型字符串字面量中的换行将会被保留在字符串值中。

除非在字面量值中作为明确的转义序列出现,回车字符\r(编码点U+000D)的值或者回车+换行序列\r\n(编码点U+000DU+000A)的值都会被泛化为一个换行字符\n(编码点U+000A)。但不要担心;这种泛化很少见而且很可能仅会在你将文本拷贝粘贴到JS文件中时才会发生。

插值表达式

在一个插值型字符串字面量中,任何合法的表达式都被允许出现在${..}内部,包括函数调用,内联函数表达式调用,甚至是另一个插值型字符串字面量!

考虑如下代码:

function upper(s) {
    return s.toUpperCase();
}

var who = "reader";

var text =
`A very ${upper( "warm" )} welcome
to all of you ${upper( `${who}s` )}!`;

console.log( text );
// A very WARM welcome
// to all of you READERS!

当我们组合变量who与字符串s时, 相对于who + "s",这里的内部插值型字符串字面量`${who}s`更方便一些。有些情况下嵌套的插值型字符串字面量是有用的,但是如果你发现自己做这样的事情太频繁,或者发现你自己嵌套了好几层时,你就要小心一些。

如果确实有这样情况,你的字符串你值生产过程很可能可以从某些抽象中获益。

警告: 作为一个忠告,使用这样的新发现的力量时要非常小心你代码的可读性。就像默认值表达式和解构赋值表达式一样,仅仅因为你 做某些事情,并不意味着你 应该 做这些事情。在使用新的ES6技巧时千万不要做过了头,使你的代码比你或者你的其他队友聪明。

表达式作用域

关于作用域的一个快速提醒是它用于解析表达式中的变量时。我早先提到过一个插值型字符串字面量与IIFE有些相像,事实上这也可以考虑为作用域行为的一种解释。

考虑如下代码:

function foo(str) {
    var name = "foo";
    console.log( str );
}

function bar() {
    var name = "bar";
    foo( `Hello from ${name}!` );
}

var name = "global";

bar();                  // "Hello from bar!"

在函数bar()内部,字符串字面量`..`被表达的那一刻,可供它查找的作用域发现变量的name的值为"bar"。既不是全局的name也不是foo(..)name。换句话说,一个插值型字符串字面量在它出现的地方是词法作用域的,而不是任何方式的动态作用域。

标签型模板字面量

再次为了合理性而重命名这个特性:标签型字符串字面量

老实说,这是一个ES6提供的更酷的特性。它可能看起来有点儿奇怪,而且也许一开始看起来一般不那么实用。但一旦你花些时间在它上面,标签型字符串字面量的用处可能会令你惊讶。

例如:

function foo(strings, ...values) {
    console.log( strings );
    console.log( values );
}

var desc = "awesome";

foo`Everything is ${desc}!`;
// [ "Everything is ", "!"]
// [ "awesome" ]

让我们花点儿时间考虑一下前面的代码段中发生了什么。首先,跳出来的最刺眼的东西就是foo`Everything...`;。它看起来不像是任何我们曾经见过的东西。不是吗?

它实质上是一种不需要( .. )的特殊函数调用。标签 —— 在字符串字面量`..`之前的foo部分 —— 是一个应当被调用的函数的值。实际上,它可以是返回函数的任何表达式,甚至是一个返回另一个函数的函数调用,就像:

function bar() {
    return function foo(strings, ...values) {
        console.log( strings );
        console.log( values );
    }
}

var desc = "awesome";

bar()`Everything is ${desc}!`;
// [ "Everything is ", "!"]
// [ "awesome" ]

但是当作为一个字符串字面量的标签时,函数foo(..)被传入了什么?

第一个参数值 —— 我们称它为strings —— 是一个所有普通字符串的数组(所有被插值的表达式之间的东西)。我们在strings数组中得到两个值:"Everything is ""!"

之后为了我们示例的方便,我们使用...收集/剩余操作符(见本章早先的“扩散/剩余”部分)将所有后续的参数值收集到一个称为values的数组中,虽说你本来当然可以把它们留作参数strings后面单独的命名参数。

被收集进我们的values数组中的参数值,就是在字符串字面量中发现的,已经被求过值的插值表达式的结果。所以在我们的例子中values里唯一的元素显然就是awesome

你可以将这两个数组考虑为:在values中的值原本是你拼接在stings的值之间的分隔符,