Clloz's Blog

Back

解构赋值的一些细节 Blur image

前言#

解构赋值 Destructuring assignmentES6 提供的新语法,通过解构赋值我们可以从对象或数组(类数组对象也可)中取出属性或值,赋值给其他变量。本文整理一下比较容易忽略和不太好理解的点。

知识点#

undefined 的确定#

ES6 用严格相等运算符来判断一个位置是否有值。在解构赋值中只有一个位置的值严格等于 undefined,我们设置的默认值才会生效。

let [x = 1] = [undefined]
x // 1

let [x = 1] = [null]
x // null
javascript

默认值表达式惰性求值#

如果解构赋值中某个变量的默认值是个表达式,那么这个表达式是惰性求值的,也就是只有需要执行的时候才会求值。

function f() {
  console.log('aaa')
}

let [x = f()] = [1] //f() 不会执行
javascript

对象解构机制#

数组的解构赋值是根据变量的位置来确定其值的。由于对象不像数组一样是按次序排列的,所以对象的解构赋值只能根据变量的名称到对象中查找。但是需要注意的是,我们要区分好用于匹配的模式(可以理解为键值对中的键)和具体的对象,特别是在嵌套的对象解构中。我的理解就是解构表达式中的变量表示中不管嵌套多少层,有多少标识符,第一个无法在对象中找到的标识符就是变量的名称,如果每一个标识符都能找到,那么就是隐藏了一个和最后一个标识符同名的变量。

let { foo: baz } = { foo: 'aaa', bar: 'bbb' }
baz // "aaa"
foo // error: foo is not defined
//foo是匹配的模式,baz才是变量,模式只是用来到对象中查找属性,而变量则是最后赋值的目标

let {
  foo: { bar }
} = { baz: 'baz' } //foo 无法在对象中找到,所以是 undefined,此时再想向下找属性就会报错

//对象的解构赋值可以找原型上的属性
const obj1 = {}
const obj2 = { foo: 'bar' }
Object.setPrototypeOf(obj1, obj2)

const { foo } = obj1
foo // "bar"

//数组是特殊的对象,所以可以对数组进行对象属性的解构
let arr = [1, 2, 3]
let { 0: first, [arr.length - 1]: last } = arr
first // 1
last // 3
javascript

解构赋值用引擎的内部方法 toObject()(我们无法在 runtime 访问到这个方法)强制将源数据转为对象。也就是说如果 source 是一个原始数据类型,会被转为对应的包装对象。由于 nullundefined 无法转为对象,所以会报错。

let { length } = 'foobar'
console.log(length) // 6

let { constructor: c } = 4
console.log(c === Number) // true

let { _ } = null // TypeError

let { _ } = undefined // TypeError
javascript

如果解构赋值语句不是变量声明语句(前面没有 varletconst),即对已经声明的变量进行进行解构赋值需要注意加上括号。

let x;
{x} = {x: 1}; //Uncaught SyntaxError: Unexpected token '='  行首的大括号会被引擎认为是代码块
({x} = {x: 1}); //这是正确写法,但是和立即执行函数一样,该语句的前面一行最好加上分号,否则可能会被当做函数调用。
javascript

嵌套的对象的解构赋值可以用来复制对象属性。

let person = {
  name: 'Matt',
  age: 27,
  job: {
    title: 'Software engineer'
  }
}
let personCopy = {}

;({ name: personCopy.name, age: personCopy.age, job: personCopy.job } = person)

// Because an object reference was assigned into personCopy, changing a property
// inside the person.job object will be propagated to personCopy:
person.job.title = 'Hacker'

console.log(person)
// { name: 'Matt', age: 27, job: { title: 'Hacker' } }

console.log(personCopy)
// { name: 'Matt', age: 27, job: { title: 'Hacker' } }
javascript

对于包含了多个属性赋值的解构赋值语句,多个属性的赋值是依次执行,相互独立的,也就是说如果第一个属性赋值成功,第二个失败报错的话,第一个赋值依然是成功的。

let person = {
  name: 'Matt',
  age: 27
}

let personName, personBar, personAge

try {
  // person.foo is undefined, so this will throw an error
  ;({
    name: personName,
    foo: { bar: personBar },
    age: personAge
  } = person)
} catch (e) {
  console.log(e) //TypeError: Cannot read property 'bar' of undefined
}

console.log(personName, personBar, personAge)
// Matt, undefined, undefined
javascript

结构赋值还可以配合 for ... of 进行使用:

var people = [
  {
    name: 'Mike Smith',
    family: {
      mother: 'Jane Smith',
      father: 'Harry Smith',
      sister: 'Samantha Smith'
    },
    age: 35
  },
  {
    name: 'Tom Jones',
    family: {
      mother: 'Norah Jones',
      father: 'Richard Jones',
      brother: 'Howard Jones'
    },
    age: 25
  }
]

for (var {
  name: n,
  family: { father: f }
} of people) {
  console.log('Name: ' + n + ', Father: ' + f)
}

// "Name: Mike Smith, Father: Harry Smith"
// "Name: Tom Jones, Father: Richard Jones"
javascript

解构赋值可以使用属性名表达式:

let key = 'z'
let { [key]: foo } = { z: 'bar' }

console.log(foo) // "bar"
javascript

剩余参数也可以运用到对象的解构赋值中:

let { a, b, ...rest } = { a: 10, b: 20, c: 30, d: 40 }
console.log(a) // 10
console.log(b) // 20
console.log(rest) // { c: 30, d: 40 }
javascript

解构赋值的属性查找会查找原型链上的属性:

// 声明对象 和 自身 self 属性
var obj = { self: '123' }
// 在原型链中定义一个属性 prot
obj.__proto__.prot = '456'
// test
const { self, prot } = obj
// self "123"
// prot "456"(访问到了原型链)
javascript

数组的解构赋值#

数组的解构赋值表达式的右值不是一个可遍历结构,则会报错。

// 报错 Uncaught TypeError: xxx is not iterable
let [foo] = 1
let [foo] = false
let [foo] = NaN
let [foo] = undefined
let [foo] = null
let [foo] = {}
javascript

数组的解构赋值也可以这样使用 let [,m] = [2,3]

数组的解构赋值还支持剩余模式:var [a, ...b] = [1, 2, 3];,但是要注意如果剩余元素右侧有逗号,会抛出 SyntaxError,因为剩余元素必须是数组的最后一个元素。

var [a, ...b] = [1, 2, 3]
// SyntaxError: rest element may not have a trailing comma
javascript

数值、字符串和布尔型#

解构赋值的规则是,只要等号右边的值不是对象或数组,就先将其转为对象,字符串被转为类数组对象,数值和布尔型则转为包装对象。由于 undefinednull 无法转为对象,所以对它们进行解构赋值,都会报错。

const [a, b, c, d, e] = 'hello'
a // "h"
b // "e"
c // "l"
d // "l"
e // "o"

//字符串转为的对象有length属性
let { length: len } = 'hello'
len // 5

let { toString: s } = 123
s === Number.prototype.toString // true

let { toString: s } = true
s === Boolean.prototype.toString // true

let { prop: x } = undefined // TypeError
let { prop: y } = null // TypeError
javascript

函数参数的解构赋值#

函数参数的解构赋值如果是一个对象,不会影响 argumentslength,它只是允许你在函数签名中声明变量,并且能够立即在函数体中使用它。

一个函数签名 (或类型签名,或方法签名) 定义了函数或方法 的输入与输出。一个签名可以包括:

  • 参数及参数的类型
  • 一个返回值及其类型
  • 可能会抛出或传回的异常
  • 有关面向对象程序中方法可用性的信息 (例如关键字 publicstaticprototype)。
let person = {
  name: 'Matt',
  age: 27
}

function printPerson(foo, { name, age }, bar) {
  console.log(arguments)
  console.log(name, age)
}

function printPerson2(foo, { name: personName, age: personAge }, bar) {
  console.log(arguments)
  console.log(personName, personAge)
}

printPerson('1st', person, '2nd')
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27

printPerson2('1st', person, '2nd')
// ['1st', { name: 'Matt', age: 27 }, '2nd']
// 'Matt', 27
javascript

函数参数的解构赋值需要注意的是默认参数设定的机制。

//设置解构赋值的默认值
function move({ x = 0, y = 0 } = {}) {
  return [x, y]
}

move({ x: 3, y: 8 }) // [3, 8]
move({ x: 3 }) // [3, 0]
move({}) // [0, 0]
move() // [0, 0]
move({ x: undefined, y: undefined }) //[0, 0]

//设置参数默认值
function move({ x, y } = { x: 0, y: 0 }) {
  return [x, y]
}

move({ x: 3, y: 8 }) // [3, 8]
move({ x: 3 }) // [3, undefined]
move({}) // [undefined, undefined]
move() // [0, 0]
move({ x: undefined, y: undefined }) //[undefined, undefined]

//注意下面这种会直接报错
function move({ x = 0, y = 0 }) {
  return [x, y]
}
move() //Uncaught TypeError: Cannot read property 'x' of undefined
javascript

这里说实话不是很好理解,阮一峰老师的书里也没有讲的非常清楚,是一笔带过。最后我说一下自己的理解。

ES6标准入门 中关于函数的解构赋值的例子中关于默认值的设定都是非对象的,比如 let {x = 10, y = 20} = {x: 3} 。但是在函数参数的情况里面并不是这样。函数参数中的例子是 function move({x = 0, y = 0} = {}){} 这样的形式,用之前的那种理解无法解释这种行为。

函数的参数是一个 arguments,一个迭代器,类数组对象,所以上面的函数可以类比为这样的一个式子:

function move({ x = 0, y = 0 } = {}) {}
;[{ x = 10, y = 20 } = {}] = arguments[({ x = 10, y = 20 } = {})] = []
javascript

经过这种转化之后似乎清晰一些,但是如何理解呢。我们把 {x = 10, y = 20} 看做一个整体,上面的式子变为 [obj = {}] = [],也就是我们设置 obj 的默认值为 {}(也可以理解为设置参数默认值,是另一种思路,不过本质也没有区别),只要我们传入的 arguments 中有参数的话,就不会用这个默认值 {},只有当我们的 arguments 的索引为 0 的元素严格等于 undefined 的时候才会用到默认值 {},使用默认值就相当于执行 {x = 10, y = 20} = {} 的解构赋值。当 arguments 的索引为 0 的元素不严格等于 undefined 的时候则会把这个元素转为一个对象 object,执行 {x = 10, y = 20} = object 的解构赋值,如果找不到,则使用默认值 10 ,20,不管传入的是数字还是字符串等,都能正常执行;唯一会报错的就是 null

有了这个逻辑,我们分析其他的两种情况就很简单了。比如 function move({x, y} = {x:0, y:0}){} 这种情况,就可以理解为 [obj = {x:0, y:0}] = [],只有当 arguments 索引为 0 的元素严格等于 undefined 的时候才会变成执行 {x, y} = {x:0, y:0},否则就是把索引为 0 的元素转为对象 object 执行 {x, y} = object,这也是为什么只有 move() 不传参的时候才能输出 [0, 0]

function move({x = 0, y = 0}){} 为什么在不传参的时候报错,它相当于执行 [{x = 0, y = 0}] = [],要在 arguments 索引为 0 的元素转为的对象上找 xy 属性,但这个值是 undefined,所以最终报错。

逻辑可能有点绕,最后我总结一下比较重要的规则:

  • 对于有嵌套结构(且嵌套的数组或对象没有设置初始值)的解构赋值,等号左右两边嵌套结构必须相同。属于模式匹配,所以等号两边模式要相同。
  • 对对象或数组设置初始值(也必须是一个对象或数组),则该对象和数组被看做一个整体,在完成第一步结构赋值以后才能确定目标进行第二次解构赋值。比如 [{x = 10, y = 20} = {}] = [{x: 1}] 先进行 [obj = {}] = [{x: 1}],确定了 obj 的解构赋值目标是 {x: 1} 之后进行第二步的解构赋值 {x = 10, y = 20} = {x: 1},最后的结果是 x: 1, y: 20
  • 对于嵌套结构中的对象,如果设置了初始值(也是对应的对象),则等号右边结构的对应位置可以是除 null 以外的任何值,因为 undefined 会将目标设置为初始值,而其他值都能转为对象。
  • 对于嵌套结构中的数组,如果设置了初始值(应是一个 Iterator),则等号右边结构对应位置必须是一个 Iterator,否则报错。比如 [[a = 1, b = 2]] = [1] 会报错,而 [[a = 1, b = 2]] = ['ab'] 则能正确解构赋值,结果为 a: 'a', b: 'b'
  • 多层的嵌套每一层都可以给任意结构设定初始值,逻辑和上面相同,不顾一般不会使用。

函数默认参数的使用也和解构赋值的默认值的生效类似,当传入的参数严格等于 undefined 即使用默认参数,[1, undefined, 3].map((x = 'yes') => x); // [ 1, 'yes', 3 ]

上面的函数用的是对象作为参数,其实数组作为参数的情形也是一样的。但是注意数组解构赋值的右值必须是一个可遍历结构。

//设置默认参数
function a([x, y] = [5, 6]) {
  console.log(x, y)
}
a() //5, 6
a([1]) //1, undefined
a('asdf') // a, s
a(1) //VM1241:1 Uncaught TypeError: undefined is not a function(不知道为什么不是not iterable的报错)

//设置默认值
function a([x = 5, y = 6] = []) {
  console.log(x, y)
}
a() // 5, 6
a([1]) //1, 6
a('asdf') // a, s
a(1) //VM1241:1 Uncaught TypeError: undefined is not a function
javascript

如果你希望能够在不提供任何参数的情况下调用该函数,就使用默认值模式。如果你只是想用解构赋值给函数一个默认参数则使用另一种。

解构赋值中的括号#

解构赋值虽然很方便,但是解析起来并不容易。对于编译器来说,一个式子到底是模式,还是表达式,没有办法从一开始就知道,必须解析到(或解析不到)等号才能知道。由此带来的问题是,如果模式中出现圆括号怎么处理。ES6 的规则是,只要有可能导致解构的歧义,就不得使用圆括号。但是,这条规则实际上不那么容易辨别,处理起来相当麻烦。因此,建议只要有可能,就不要在模式中放置圆括号。

总结起来就是两个规则: 1. 变量声明语句中不可以使用(函数参数也属于变量声明) 2. 不可以把模式(键)包含在小括号中(数组的模式是按位置匹配,所以把数组元素括起来可以)

// 变量声明 报错
let [(a)] = [1];

let {x: (c)} = {};
let ({x: c}) = {};
let {(x: c)} = {};
let {(x): c} = {};

let { o: ({ p: p }) } = { o: { p: 2 } };

//函数参数 报错
function f([(z)]) { return z; }
function f([z,(x)]) { return x; }

//模式括号 报错
({ p: a }) = { p: 42 };
([a]) = [5];

[({ p: a }), { x: c }] = [{}, {}];

//正确
[(b)] = [3]; // 正确
({ p: (d) } = {}); // 正确
[(parseInt.prop)] = [3]; // 正确
javascript

除了大括号在行首用括号将表达式括起来,其他情况尽量不要使用

用途#

  1. 将对象的方法赋值给某个变量

    // 例一
    let { log, sin, cos } = Math
    
    // 例二
    const { log } = console
    log('hello') // hello
    javascript
  2. 交换变量的值

    let x = 1
    let y = 2
    
    ;[x, y] = [y, x]
    javascript
  3. 函数返回多个值

    // 返回一个数组
    
    function example() {
      return [1, 2, 3]
    }
    let [a, b, c] = example()
    
    // 返回一个对象
    
    function example() {
      return {
        foo: 1,
        bar: 2
      }
    }
    let { foo, bar } = example()
    javascript
  4. 函数参数定义

    // 参数是一组有次序的值
    function f([x, y, z]) { ... }
    f([1, 2, 3]);
    
    // 参数是一组无次序的值
    function f({x, y, z}) { ... }
    f({z: 3, y: 2, x: 1});
    javascript
  5. 提取 JSON 数据

    let jsonData = {
      id: 42,
      status: 'OK',
      data: [867, 5309]
    }
    
    let { id, status, data: number } = jsonData
    
    console.log(id, status, number)
    // 42, "OK", [867, 5309]
    javascript
  6. 函数参数默认值

    jQuery.ajax = function (
      url,
      {
        async = true,
        beforeSend = function () {},
        cache = true,
        complete = function () {},
        crossDomain = false,
        global = true
        // ... more config
      } = {}
    ) {
      // ... do stuff
    }
    javascript
  7. 遍历 Map 解构

    jQuery.ajax = function (
      url,
      {
        async = true,
        beforeSend = function () {},
        cache = true,
        complete = function () {},
        crossDomain = false,
        global = true
        // ... more config
      } = {}
    ) {
      // ... do stuff
    }
    
    // 获取键名
    for (let [key] of map) {
      // ...
    }
    
    // 获取键值
    for (let [, value] of map) {
      // ...
    }
    javascript
  8. 输入模块的指定方法

    const { SourceMapConsumer, SourceNode } = require('source-map')
    javascript

参考文章#

  1. 《ECMAScript6入门》 —— 阮一峰
  2. MDN
解构赋值的一些细节
https://clloz.com/blog/destructure-details
Author Clloz
Published at July 8, 2020
Comment seems to stuck. Try to refresh?✨