你不懂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是源值而ban是被赋值的目标变量。换句话说,对象字面量是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 }语法可能是你初次见到;它称为“简约属性”,我们将在下一节讲解它!

推荐阅读更多精彩内容