JS对象的-restspread-属性指南

9次阅读

共计 9502 个字符,预计需要花费 24 分钟才能阅读完成。

作者:Dmitri Pavlutin
译者:前端小智
来源:dmitripavlutin

为了保证的可读性,本文采用意译而非直译。

想阅读更多优质文章请猛戳 GitHub 博客, 一年百来篇优质文章等着你!

在 ES5 中,咱们合并对象通常使用 Lodash_.extend(target, [sources]) 方法,在 ES6 中咱们使用 Object.assign(target, [sources])来合并对象,当然现在最常用应该是使用 Rest/Spread(展开运算符与剩余操作符)。

来个例子:

const cat = {
  legs: 4,
  sound: 'meow'
};
const dog = {
  ...cat,
  sound: 'woof'
};

console.log(dog); // => {legs: 4, sounds: 'woof'}

在上面的示例中,...catcat 的属性复制到新的对象 dog 中,.sound属性接收最终值'woof'

本文将介绍对象 spreadrest 语法,包括对象传播如何实现对象克隆、合并、属性覆盖等方法。

下面简要介绍一下可枚举属性,以及如何区分自有属性和继承属性。这些是理解对象 spreadrest 工作原理的必要基础。

1. 属性描述对象

JS 提供了一个内部数据结构,用来描述对象的属性,控制它的行为,比如该属性是否可写、可遍历等等。这个内部数据结构称为“属性描述对象”。每个属性都有自己对应的属性描述对象,保存该属性的一些元信息。

下面是属性描述对象的一个例子。

{

  value: 123,

  writable: false,

  enumerable: true,

  configurable: false,

  get: undefined,
  
  set: undefined 
}

属性描述对象提供 6 个元属性。

(1)value

value是该属性的属性值,默认为 undefined。

(2)writable

writable是一个布尔值,表示属性值(value)是否可改变(即是否可写),默认为 true。

(3)enumerable

enumerable是一个布尔值,表示该属性是否可遍历,默认为 true。如果设为false,会使得某些操作(比如for...in 循环、Object.keys())跳过该属性。

(4)configurable

configurable是一个布尔值,表示可配置性,默认为 true。如果设为false,将阻止某些操作改写该属性,比如无法删除该属性,也不得改变该属性的属性描述对象(value 属性除外)。也就是说,configurable属性控制了属性描述对象的可写性。

(5)get

get是一个函数,表示该属性的取值函数(getter),默认为undefined

(6)set

set是一个函数,表示该属性的存值函数(setter),默认为undefined

2. 可枚举和自有属性

JS 中的对象是键和值之间的关联。 类型通常是字符串或 symbol可以是基本类型(string、boolean、number、undefined 或 null)、对象或函数。

下面使用对象字面量来创建对象:

const person = {
  name: 'Dave',
  surname: 'Bowman'
};

2.1 可枚举的属性

enumerable 属性是一个布尔值,它表示在枚举对象的属性时该属性是否可访问。

咱们可以使用 object .keys()(访问自有和可枚举的属性) 枚举对象属性,例如,在 for..in 语句中 (访问所有可枚举属性) 等等。

在对象字面量 {prop1:'val1',prop2:'val2'} 中显式声明的属性是可枚举的。来看看 person 对象包含哪些可枚举属性:

const keys = Object.keys(person);
console.log(keys); // => ['name', 'surname']

.name.surnameperson对象的可枚举属性。

接下来是有趣的部分,对象展开来自源可枚举属性的副本:

onsole.log({...person};// => {name: 'Dave', surname: 'Bowman'}

现在,在 person 对象上创建一个不可枚举的属性.age。然后看看展开的行为:

Object.defineProperty(person, 'age', {
  enumerable: false, // 让属性不可枚举
  value: 25
})
console.log(person['age']); // => 25

const clone = {...person};
console.log(clone); // => {name: 'Dave', surname: 'Bowman'}

.name.surname 可枚举属性从源对象 person 复制到 clone,但是不可枚举的.age 被忽略了。

2.2 自有属性

JS 包含原型继承。因此,对象属性既可以是 自有的 ,也可以 是继承的

在对象字面量显式声明的属性是自有的。但是对象从其原型接收的属性是继承的。

接着创建一个对象 personB 并将其原型设置为person

const personB = Object.create(person, {  
  profession: {
    value: 'Astronaut',
    enumerable: true
  }
});

console.log(personB.hasOwnProperty('profession')); // => true
console.log(personB.hasOwnProperty('name'));       // => false
console.log(personB.hasOwnProperty('surname'));    // => false

personB对象具有自己的属性 .professional,并从原型person 继承 .name.surname属性。

展开运算只展开自有属性,忽略继承属性。

const cloneB = {...personB};
console.log(cloneB); // => {profession: 'Astronaut'}

对象展开 ...personB 只从源对象 personB 复制,继承的 .name.surname被忽略。

3. 对象展开属性

对象展开语法从源对象中提取自有和可枚举的属性,并将它们复制到目标对象中。

const targetObject = {
  ...sourceObject,
  property: 'Value'
};

在许多方面,对象展开语法等价于object.assign(),上面的代码也可以这样实现

const targetObject = Object.assign({},
 sourceObject,
 {property: 'Value'}
)

对象字面量可以具有多个对象展开,与常规属性声明的任意组合:

const targetObject = {
  ...sourceObject1,
  property1: 'Value 1',
  ...sourceObject2,
  ...sourceObject3,
  property2: 'Value 2'
};

3.1 对象展开规则:后者属性会覆盖前面属性

当多个对象展开并且某些属性具有相同的键时,最终值是如何计算的?规则很简单:后展开属性会覆盖前端相同属性。

来看看几个盒子,下面有一个对象 cat

const cat = {
  sound: 'meow',
  legs: 4
};

接着把这只猫变成一只狗,注意 .sound 属性的值

const dog = {
  ...cat,
  ...{sound: 'woof' // <----- Overwrites cat.sound}
};
console.log(dog); // => {sound: 'woof', legs: 4}

后一个值“woof”覆盖了前面的值“meow”(来自 cat 源对象)。这与后一个属性使用相同的键覆盖最早的属性的规则相匹配。

相同的规则适用于对象初始值设定项的常规属性:

const anotherDog = {
  ...cat,
  sound: 'woof' // <---- Overwrites cat.sound
};
console.log(anotherDog); // => {sound: 'woof', legs: 4}

现在,如果您交换展开对象的相对位置,结果会有所不同:

const stillCat = {
  ...{sound: 'woof' // <---- Is overwritten by cat.sound},
  ...cat
};
console.log(stillCat); // => {sound: 'meow', legs: 4}

对象展开中,属性的相对位置很重要。展开语法可以实现诸如对象克隆,合并对象,填充默认值等等。

3.2 拷贝对象

使用展开语法可以很方便的拷贝对象,来创建 bird 对象的一个副本。

const bird = {
  type: 'pigeon',
  color: 'white'
};

const birdClone = {...bird};

console.log(birdClone); // => {type: 'pigeon', color: 'white'}
console.log(bird === birdClone); // => false

...bird将自己的和可枚举的 bird 属性复制到 birdClone 对中。因此,birdClonebird 的克隆。

3.3 浅拷贝

对象展开执行的是对象的浅拷贝。仅克隆对象本身,而不克隆嵌套对象。

laptop一个嵌套的对象laptop.screen。让咱们克隆laptop,看看它如何影响嵌套对象:

const laptop = {
  name: 'MacBook Pro',
  screen: {
    size: 17,
    isRetina: true
  }
};
const laptopClone = {...laptop};

console.log(laptop === laptopClone);               // => false
console.log(laptop.screen === laptopClone.screen); // => true

第一个比较laptop === laptopClone 结果为false, 表明正确地克隆了主对象。

然而 laptop.screen === laptopClone.screen 结果为 true,这意味着 laptop.screenlaptopClone.screen引用了相同对象。

当然可以在嵌套对象使用展开属性,这样就能克隆嵌套对象。

const laptopDeepClone = {
  ...laptop,
  screen: {...laptop.screen}
};

console.log(laptop === laptopDeepClone);               // => false
console.log(laptop.screen === laptopDeepClone.screen); // => false

3.4 原型丢失

下面的代码片段声明了一个类 Game,并创建了这个类doom 的实例

class Game {constructor(name) {this.name = name;}

  getMessage() {return `I like ${this.name}!`;
  }
}

const doom = new Game('Doom');
console.log(doom instanceof Game); // => true
console.log(doom.name);            // => "Doom"
console.log(doom.getMessage());    // => "I like Doom!"

现在克隆从构造函数调用创建的 doom 实例,这里会有点小意外:

const doomClone = {...doom};

console.log(doomClone instanceof Game); // => false
console.log(doomClone.name);            // => "Doom"
console.log(doomClone.getMessage());
// TypeError: doomClone.getMessage is not a function

...doom仅仅将自己的属性 .name 复制到 doomClone 中,其它都没有。

doomClone是一个普通的 JS 对象,原型是Object.prototype,但不是Game.prototype。所以对象展开不保留源对象的原型。

因此,调用 doomClone.getMessage() 会抛出一个类型错误,因为 doomClone 不继承 getMessage() 方法。

要修复缺失的原型,需要手动指定 __proto__

const doomFullClone = {
  ...doom,
  __proto__: Game.prototype
};

console.log(doomFullClone instanceof Game); // => true
console.log(doomFullClone.name);            // => "Doom"
console.log(doomFullClone.getMessage());    // => "I like Doom!"

对象内的 __proto__ 确保 doomFullClone 具有必要的原型Game.prototype

不要在项目中使用__proto__,这种是很不推荐的。这边只是为了演示而已。

对象展开构造函数调用创建的实例,因为它不保留原型。其目的是以一种浅显的方式扩展自己的和可枚举的属性,因此忽略原型的方法似乎是合理的。

另外,还有一种更合理的方法可以使用 Object.assign() 克隆doom

const doomFullClone = Object.assign(new Game(), doom);

console.log(doomFullClone instanceof Game); // => true
console.log(doomFullClone.name);            // => "Doom"
console.log(doomFullClone.getMessage());    // => "I like Doom!"

3.5 不可变对象更新

当在应用程序的许多位置共享同一对象时,对其进行直接修改可能会导致意外的副作用。追踪这些修改是一项繁琐的工作。

更好的方法是使操作不可变。不变性保持在更好的控制对象的修改和有利于编写纯函数。即使在复杂的场景中,由于数据流向单一方向,因此更容易确定对象更新的来源和原因。

对象的展开操作有便于以不可变的方式修改对象。假设咋样有一个描述书籍版本的对象:

const book = {
  name: 'JavaScript: The Definitive Guide',
  author: 'David Flanagan',
  edition: 5,
  year: 2008
};

然后出现了新的第 6 版。对象展开操作可快以不可变的方式编写这个场景:

const newerBook = {
  ...book,
  edition: 6,  // <----- Overwrites book.edition
  year: 2011   // <----- Overwrites book.year
};

console.log(newerBook);
/*
{
  name: 'JavaScript: The Definitive Guide',
  author: 'David Flanagan',
  edition: 6,
  year: 2011
}
*/

newerBook是一个具有更新属性的新对象。与此同时,原 book 对象保持不变,不可变性得到满足。

3.6 合并对象

使用展开运算合并对象很简单,如下:

const part1 = {color: 'white'};
const part2 = {model: 'Honda'};
const part3 = {year: 2005};

const car = {
  ...part1,
  ...part2,
  ...part3
};
console.log(car); // {color: 'white', model: 'Honda', year: 2005}

car对象由合并三个对象创建:part1part2part3

来改变前面的例子。现在 part1part3有一个新属性.configuration

const part1 = {
  color: 'white',
  configuration: 'sedan'
};
const part2 = {model: 'Honda'};
const part3 = {
  year: 2005,
  configuration: 'hatchback'
};

const car = {
  ...part1,
  ...part2,
  ...part3 // <--- part3.configuration overwrites part1.configuration
};
console.log(car); 
/*
{ 
  color: 'white', 
  model: 'Honda', 
  year: 2005,
  configuration: 'hatchback'  <--- part3.configuration
}
*/

第一个对象展开 ...part1.configuration的值设置为 ’sedan‘。然而,...part3 覆盖了之前的 .configuration 值,使其最终成为“hatchback”。

3.7 使用默认值填充对象

对象可以在运行时具有不同的属性集。可能设置了一些属性,也可能丢失了其他属性。

这种情况可能发生在配置对象的情况下。用户只指定需要属性,但未需要的属性取自默认值。

实现一个 multiline(str, config) 函数,该函数将 str 在给定的宽度上分成多行。

config对象接受以下可选参数:

  • width:达到换行字符数,默认为10
  • newLine:要在换行处添加的字符串,默认为\n
  • indent: 用来表示行的字符串, 默认为空字符串 ''

示例如下:

multiline('Hello World!');
// => 'Hello Worl\nd!'

multiline('Hello World!', { width: 6});
// => 'Hello \nWorld!'

multiline('Hello World!', { width: 6, newLine: '*'});
// => 'Hello *World!'

multiline('Hello World!', { width: 6, newLine: '*', indent: '_'});
// => '_Hello *_World!'

config参数接受不同的属性集:可以给定 1,23个属性,甚至不指定也是可等到的。

使用对象展开操作用默认值填充配置对象相当简单。在对象字面量,首先展开缺省对象,然后是配置对象:

function multiline(str, config = {}) {
  const defaultConfig = {
    width: 10,
    newLine: '\n',
    indent: ''
  };
  const safeConfig = {
    ...defaultConfig,
    ...config
  };
  let result = '';
  // Implementation of multiline() using
  // safeConfig.width, safeConfig.newLine, safeConfig.indent
  // ...
  return result;
}

对象展开...defaultConfig 从默认值中提取属性。然后...config 使用自定义属性值覆盖以前的默认值。

因此,safeConfig具有 multiline() 函数所需要所有的属性。无论 multiline 有没有传入参数,都可以确保 safeConfig 具有必要的值。

3.8 深入嵌套属性

对象展开操作的最酷之处在于可以在嵌套对象上使用。在更新嵌套对象时,展开操作具有很好的可读性。

有如下一个 box 对象

const box = {
  color: 'red',
  size: {
    width: 200, 
    height: 100 
  },
  items: ['pencil', 'notebook']
};

box.size描述了 box 的大小,box.items枚举了中 box 包含的项。

const biggerBox = {
  ...box,
  size: {
    ...box.size,
    height: 200
  }
};
console.log(biggerBox);
/*
{
  color: 'red',
  size: {
    width: 200, 
    height: 200 <----- Updated value
  },
  items: ['pencil', 'notebook']
}
*/

...box确保 greaterBoxbox接收属性。

更新嵌套对象的高度 box.size 需要一个额外的对象字面量 {... box.size,height:200}。此对象将box.size 的属性展开到新对象,并将高度更新为200

如果将 color 更改为 black,将width 增加到 400 并添加新的 ruler 属性,使用展开运算就很好操作:

const blackBox = {
  ...box,
  color: 'black',
  size: {
    ...box.size,
    width: 400
  },
  items: [
    ...box.items,
    'ruler'
  ]
};
console.log(blackBox);
/*
{
  color: 'black', <----- Updated value
  size: {
    width: 400, <----- Updated value
    height: 100 
  },
  items: ['pencil', 'notebook', 'ruler'] <----- A new item ruler
}
*/

3.9 展开 undefined,null 和基本类型

当展开的属性为 undefinednull 或基本数据类型时,不会提取属性,也不会抛出错误,返回结果只是一个纯空对象:

const nothing = undefined;
const missingObject = null;
const two = 2;

console.log({...nothing});       // => { }
console.log({...missingObject}); // => { }
console.log({...two});           // => { }

对象展开操作没有从 nothingmissingObjecttwo中提取属性。也是,没有理由在基本类型值上使用对象展开运算。

4. 对象剩余操作运算

在使用解构赋值将对象的属性提取到变量之后,可以将剩余属性收集到 rest 对象中。

const style = {
  width: 300,
  marginLeft: 10,
  marginRight: 30
};

const {width, ...margin} = style;

console.log(width);  // => 300
console.log(margin); // => {marginLeft: 10, marginRight: 30}

解构赋值定义了一个新的变量 width,并将其值设置为style.width。对象剩余操作...margin 将解构其余属性 marginLeftmarginRight收集到margin

对象剩余 (rest) 操作只收集自有的和可枚举的属性。

代码部署后可能存在的 BUG 没法实时知道,事后为了解决这些 BUG,花了大量的时间进行 log 调试,这边顺便给大家推荐一个好用的 BUG 监控工具 Fundebug。

原文:https://dmitripavlutin.com/ob…

交流

干货系列文章汇总如下,觉得不错点个 Star,欢迎 加群 互相学习。

https://github.com/qq44924588…

我是小智,公众号「大迁世界」作者,对前端技术保持学习爱好者。我会经常分享自己所学所看的干货,在进阶的路上,共勉!

关注公众号,后台回复 福利,即可看到福利,你懂的。

正文完
 0