第二章:语法 (2/5) -《你不知道的JavaScript:ES6 & Beyond》

解构(Destructuring)

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()返回的数组分别赋值给了abc,这样做我们必须(无可奈何地)引入变量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被赋值给了变量x,同样地,y得到的是tmp.yztmp.z

手动给数组的索引值或对象的属性值赋值的方式被称为结构化赋值。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是用来赋值的数据源。换句话说,这个语法的模式是target: source,说白了,就是property-alias: value。直觉告诉我们这和=赋值的用法是一样的,就像target = source

然而在使用对象解构赋值的时候——也就是把看起来像对象一样的{ .. }语法放在了=左边的时候——其实我们是把target: source这个语法倒置了。

回想:

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

这里的语法模式是source: target(或value: variable-alias)。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赋值给BB以及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,就必须要用( )把整个赋值表达式包起来,不然表达式左手边的第一个{ .. }就会被当成块声明,而不是对象。

事实上,赋值表达式(a, y等等)并不只能是变量标识符。任何合法的赋值表达式都可以,例如:

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]部分就是一个正常的对象key引用,和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的引用,而不是a, b, 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

不多不少刚刚好

无论是数组解构还是对象解构赋值,你都不需要把所有出现的值都赋上:例如:

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

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

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 [ .. ] = aa解构成[ .. ]里面的结构。第一部分叫做b对应的是a的第一个值2。然后...c聚合其余的值(34)放到叫做c的数组里。

注意:我们见过...在数组里是如何工作的,但对象呢?它并不是一个ES6的特性,不过可以参考第八章里讨论的“beyond 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 );

你能得出最后x, yz最后的值是什么吗?我可以想象这需要花点时间来想明白。我来揭开谜底:

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(){ .. }
    }
};

// instead of:
// 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,只是用了两种不同的方式。然而,这两种不同的方式在特定的场景下会有不同的行为,并且非常微妙。

Consider:

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

等等,为什么会这样?很明显,命名为x的参数在第一个参数没有相同名字的属性时,会使用默认值10

但为什么yundefined呢?对象值{ y: 10 }是一个函数参数默认值,而不是解构默认值。因此,它只会在没传第二个参数的时候起作用,或是undefined的时候。

在前面的代码中,我们了第二过参数({}),所以默认的{ y: 10 }并没有起作用,而{ y }在尝试解构空对象{}

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

对于x这种使用方式来说,如果没有传第一个函数参数或者是undefined,就会使用空对象{}。然后,无论什么第一个传进来的是什么值——无论是默认值{}还是传进去的任何东西——会用表达式{ x = 10 }来解构,它会检查属性x是否存在,如果没有(或者是undefined),参数x就会使用默认值10

深呼吸。把前面几段反复读几次,然后我们再来看下面的代码:

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的行为会更合理一些。因此,理解这两种形式为什么会有差异,以及差异在哪里就显得很重要。

如果你还是有点懵就把上面的内容再看一遍,自己也玩一下。未来的你会感激你现在花时间把这个东西理解透了的。

嵌套默认值:解构和重组

这种方式乍看上去很难懂,但最近很流行这样做,用嵌套的对象属性来设默认值:和重组(我这么叫)一起使用对象解构。

考虑一组嵌套对象解构默认值,如下:

// 见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;
...

Eww。

也有些人倾向于用赋值-覆盖方式来做。你可能会被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(..)这样虚假的承诺那么好看(但它仅仅是浅赋值),但我觉得比起手动赋值还是好一点。尽管看起来还是又臭又长。

前面代码中的方式可以工作,因为我hack了解构和默认值的机制,来检查属性是不是=== undefined然后决定是否赋值。我在解构config的同时又把解构出来的值重新赋给了config,通过config.options.enable赋值引用(见代码最后的= config)。

然而还是太麻烦了。我们来看看还可以怎么改进。

下面这种做法是我们认为做好的,如果你清楚地知道你在解构的各种属性都有唯一的命名。如果不是这样的话,你也可以这么做,但看起来就没那么好看了——你不得不分阶段的解构,或者给那些重复的命名创建临时唯一变量。

如果我们完全解构所有顶层变量,我们随即就可以重组还原这个嵌套的对象解构。

但是所有这些临时变量就会萦绕在周围污染作用域。所以,我们还是用块作用域{ }的方式把这部分包起来(见本章前面的“块作用域”部分)。

// 把`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 }
    };
}

看起来好一点了吧?

注意:你也可以用立即执行箭头函数来替代块作用域的{ }let声明。这样的话,解构赋值/默认值需要在参数列表里,以及重组的结果需要在函数体中返回(return)。

在重组部分的{ warn, error }可能对你来说有点眼生,这种形式叫做“简明属性”,我们会在下一节详细讲它。


该系列文章翻译自Kyle Simpson的《You don't know about Javascript》,本章原文在此

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 151,829评论 1 331
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 64,603评论 1 273
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 101,846评论 0 226
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 42,600评论 0 191
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 50,780评论 3 272
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 39,695评论 1 192
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,136评论 2 293
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 29,862评论 0 182
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 33,453评论 0 229
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 29,942评论 2 233
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 31,347评论 1 242
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 27,790评论 2 236
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,293评论 3 221
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 25,839评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,448评论 0 181
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 34,564评论 2 249
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 34,623评论 2 249

推荐阅读更多精彩内容