解构

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

  1. function foo() {
  2. return [1,2,3];
  3. }
  4. var tmp = foo(),
  5. a = tmp[0], b = tmp[1], c = tmp[2];
  6. console.log( a, b, c ); // 1 2 3

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

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

  1. function bar() {
  2. return {
  3. x: 4,
  4. y: 5,
  5. z: 6
  6. };
  7. }
  8. var tmp = bar(),
  9. x = tmp.x, y = tmp.y, z = tmp.z;
  10. console.log( x, y, z ); // 4 5 6

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

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

  1. var [ a, b, c ] = foo();
  2. var { x: x, y: y, z: z } = bar();
  3. console.log( a, b, c ); // 1 2 3
  4. console.log( x, y, z ); // 4 5 6

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

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

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

对象属性赋值模式

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

  1. var { x, y, z } = bar();
  2. console.log( x, y, z ); // 4 5 6

很酷,对吧?

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

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

  1. var { x: bam, y: baz, z: bap } = bar();
  2. console.log( bam, baz, bap ); // 4 5 6
  3. console.log( x, y, z ); // ReferenceError

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

  1. var X = 10, Y = 20;
  2. var o = { a: X, b: Y };
  3. console.log( o.a, o.b ); // 10 20

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

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

回想一下:

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

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

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

  1. var aa = 10, bb = 20;
  2. var o = { x: aa, y: bb };
  3. var { x: AA, y: BB } = o;
  4. 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)一起使用,但是解构是一种一般意义上的赋值操作,不仅是一种声明。

考虑如下代码:

  1. var a, b, c, x, y, z;
  2. [a,b,c] = foo();
  3. ( { x, y, z } = bar() );
  4. console.log( a, b, c ); // 1 2 3
  5. console.log( x, y, z ); // 4 5 6

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

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

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

  1. var o = {};
  2. [o.a, o.b, o.c] = foo();
  3. ( { x: o.x, y: o.y, z: o.z } = bar() );
  4. console.log( o.a, o.b, o.c ); // 1 2 3
  5. console.log( o.x, o.y, o.z ); // 4 5 6

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

  1. var which = "x",
  2. o = {};
  3. ( { [which]: o[which] } = bar() );
  4. console.log( o.x ); // 4

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

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

  1. var o1 = { a: 1, b: 2, c: 3 },
  2. o2 = {};
  3. ( { a: o2.x, b: o2.y, c: o2.z } = o1 );
  4. console.log( o2.x, o2.y, o2.z ); // 1 2 3

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

  1. var o1 = { a: 1, b: 2, c: 3 },
  2. a2 = [];
  3. ( { a: a2[0], b: a2[1], c: a2[2] } = o1 );
  4. console.log( a2 ); // [1,2,3]

或者从另一个方向:

  1. var a1 = [ 1, 2, 3 ],
  2. o2 = {};
  3. [ o2.a, o2.b, o2.c ] = a1;
  4. console.log( o2.a, o2.b, o2.c ); // 1 2 3

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

  1. var a1 = [ 1, 2, 3 ],
  2. a2 = [];
  3. [ a2[2], a2[0], a2[1] ] = a1;
  4. console.log( a2 ); // [2,3,1]

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

  1. var x = 10, y = 20;
  2. [ y, x ] = [ x, y ];
  3. console.log( x, y ); // 20 10

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

重复赋值

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

  1. var { a: X, a: Y } = { a: 1 };
  2. X; // 1
  3. Y; // 1

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

  1. var { a: { x: X, x: Y }, a } = { a: { x: 1 } };
  2. X; // 1
  3. Y; // 1
  4. a; // { x: 1 }
  5. ( { a: X, a: Y, a: [ Z ] } = { a: [ 1 ] } );
  6. X.push( 2 );
  7. Y[0] = 10;
  8. X; // [10,2]
  9. Y; // [10,2]
  10. Z; // 1

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

  1. // 很难读懂:
  2. var { a: { b: [ c, d ], e: { f } }, g } = obj;
  3. // 好一些:
  4. var {
  5. a: {
  6. b: [ c, d ],
  7. e: { f }
  8. },
  9. g
  10. } = obj;

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

解构赋值表达式

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

  1. var o = { a:1, b:2, c:3 },
  2. a, b, c, p;
  3. p = { a, b, c } = o;
  4. console.log( a, b, c ); // 1 2 3
  5. p === o; // true

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

  1. var o = [1,2,3],
  2. a, b, c, p;
  3. p = [ a, b, c ] = o;
  4. console.log( a, b, c ); // 1 2 3
  5. p === o; // true

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

  1. var o = { a:1, b:2, c:3 },
  2. p = [4,5,6],
  3. a, b, c, x, y, z;
  4. ( {a} = {b,c} = o );
  5. [x,y] = [z] = p;
  6. console.log( a, b, c ); // 1 2 3
  7. console.log( x, y, z ); // 4 5 4

太多,太少,正合适

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

  1. var [,b] = foo();
  2. var { x, z } = bar();
  3. console.log( b, x, z ); // 2 4 6

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

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

  1. var [,,c,d] = foo();
  2. var { w, z } = bar();
  3. console.log( c, z ); // 3 6
  4. console.log( d, w ); // undefined undefined

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

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

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

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

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

  1. var a = [2,3,4];
  2. var [ b, ...c ] = a;
  3. console.log( b, c ); // 2 [3,4]

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

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

默认值赋值

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

考虑如下代码:

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

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

  1. var { x, y, z, w: WW = 20 } = bar();
  2. console.log( x, y, z, WW ); // 4 5 6 20

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

  1. var x = 200, y = 300, z = 100;
  2. var o1 = { x: { y: 42 }, z: { y: z } };
  3. ( { y: x = { y: y } } = o1 );
  4. ( { z: y = { y: z } } = o1 );
  5. ( { x: z = { y: x } } = o1 );

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

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

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

嵌套解构

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

  1. var a1 = [ 1, [2, 3, 4], 5 ];
  2. var o1 = { x: { y: { z: 6 } } };
  3. var [ a, [ b, c, d ], e ] = a1;
  4. var { x: { y: { z: w } } } = o1;
  5. console.log( a, b, c, d, e ); // 1 2 3 4 5
  6. console.log( w ); // 6

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

  1. var App = {
  2. model: {
  3. User: function(){ .. }
  4. }
  5. };
  6. // 取代:
  7. // var User = App.model.User;
  8. var { model: { User } } = App;

参数解构

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

  1. function foo(x) {
  2. console.log( x );
  3. }
  4. foo( 42 );

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

考虑参数的数组解构:

  1. function foo( [ x, y ] ) {
  2. console.log( x, y );
  3. }
  4. foo( [ 1, 2 ] ); // 1 2
  5. foo( [ 1 ] ); // 1 undefined
  6. foo( [] ); // undefined undefined

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

  1. function foo( { x, y } ) {
  2. console.log( x, y );
  3. }
  4. foo( { y: 1, x: 2 } ); // 2 1
  5. foo( { y: 42 } ); // undefined 42
  6. foo( {} ); // undefined undefined

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

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

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

  1. function f1([ x=2, y=3, z ]) { .. }
  2. function f2([ x, y, ...z], w) { .. }
  3. function f3([ x, y, ...z], ...w) { .. }
  4. function f4({ x: X, y }) { .. }
  5. function f5({ x: X = 10, y = 20 }) { .. }
  6. function f6({ x = 10 } = {}, { y } = { y: 10 }) { .. }

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

  1. function f3([ x, y, ...z], ...w) {
  2. console.log( x, y, z, w );
  3. }
  4. f3( [] ); // undefined undefined [] []
  5. f3( [1,2,3,4], 5, 6 ); // 1 2 [3,4] [5,6]

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

解构默认值 + 参数默认值

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

  1. function f6({ x = 10 } = {}, { y } = { y: 10 }) {
  2. console.log( x, y );
  3. }
  4. f6(); // 10 10

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

考虑如下代码:

  1. 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上。

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

  1. function f6({ x = 10 } = {}, { y } = { y: 10 }) {
  2. console.log( x, y );
  3. }
  4. f6(); // 10 10
  5. f6( undefined, undefined ); // 10 10
  6. f6( {}, undefined ); // 10 10
  7. f6( {}, {} ); // 10 undefined
  8. f6( undefined, {} ); // 10 undefined
  9. f6( { x: 2 }, { y: 3 } ); // 2 3

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

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

嵌套默认值:解构与重构

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

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

  1. // 摘自:http://es-discourse.com/t/partial-default-arguments/120/7
  2. var defaults = {
  3. options: {
  4. remove: true,
  5. enable: false,
  6. instance: {}
  7. },
  8. log: {
  9. warn: true,
  10. error: true
  11. }
  12. };

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

  1. var config = {
  2. options: {
  3. remove: false,
  4. instance: null
  5. }
  6. };

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

  1. config.options = config.options || {};
  2. config.options.remove = (config.options.remove !== undefined) ?
  3. config.options.remove : defaults.options.remove;
  4. config.options.enable = (config.options.enable !== undefined) ?
  5. config.options.enable : defaults.options.enable;
  6. ...

讨厌。

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

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

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

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

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

  1. config.options = config.options || {};
  2. config.log = config.log || {};
  3. ({
  4. options: {
  5. remove: config.options.remove = defaults.options.remove,
  6. enable: config.options.enable = defaults.options.enable,
  7. instance: config.options.instance = defaults.options.instance
  8. } = {},
  9. log: {
  10. warn: config.log.warn = defaults.log.warn,
  11. error: config.log.error = defaults.log.error
  12. } = {}
  13. } = config);

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

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

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

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

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

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

  1. // 将`defaults`混入`config`
  2. {
  3. // 解构(使用默认值赋值)
  4. let {
  5. options: {
  6. remove = defaults.options.remove,
  7. enable = defaults.options.enable,
  8. instance = defaults.options.instance
  9. } = {},
  10. log: {
  11. warn = defaults.log.warn,
  12. error = defaults.log.error
  13. } = {}
  14. } = config;
  15. // 重构
  16. config = {
  17. options: { remove, enable, instance },
  18. log: { warn, error }
  19. };
  20. }

这看起来好多了,是吧?

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

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