β

三个点如何改变JavaScript对象的rest和spread属性

W3CPlus 150 阅读

在JavaScript中合并多个对象是一个很常见的事情。但在JavaScript中,到目前为止并没有一种很方便的语法来进行合并。

在ES5中,通过使用Lodash的 _.extend(target, [sources]) (或者其他选项),在ES2015中引入了 Object.assign(target, [sources])

幸运的是, 对象的 spread 语法 (ECMAScript第3阶段建议)是如何操作对象,提供了一个简短且易于遵循的语法。

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

在上面的例子中, ...cat cat 对象复制到一个新的 dog 对象中。 .sound 属性的值 woof 放在最后面。

这篇文章我们来了解对象的 rest spread 语法。来看如何实现对象的克隆、合并以及属性重写等。

下面对可枚举属性简短的概括,以及如何区分自己与继承属性。这些是理解对象如何 spread rest 语法的必要基础。

枚举和自己属性

在JavaScript中,对象是键( key )和值( value )之间的关联。

key 的类型通常是 string symbol 。值可以是一个基本类型( string boolean number undefined null ),也可以是一个 object function

下面的对象使用了对象字面符:

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

person 对象描述了一个人的 name surname

枚举属性

属性( property )有几个属性( attribute )来描述值,还有可写的、可枚举的和可配置的状态。有关于这方面更多的细节,请参阅 JavaScript中的对象属性

可枚举属性是一个布尔值,它指的是在枚举对象属性是否可访问。

可以使用 Object.keys() 来枚举对象属性(有于访问自己的和可枚举的属性)。也可以使用 for...in 语句(枚举所有可枚举属性),等等。

对象字面值声明的属性 {prop1: 'val1', prop2:'val2'} 是可枚举的。让我们看看 person 对象包含的可枚举的属性:

const keys = Object.keys(person);  
console.log(keys);

name surname person 对象可枚举的属性。

有趣的部分来了。 Object spread 从源可枚举属性中克隆过来:

console.log({ ...person })

现在我们给 person 对象创建一个不可枚举的属性 age 。然后再看看 Object spread 行为:

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

name surname 可枚举属从源对象 person 复制到 clone 对象中,但是不可枚举属性 age 并没有被复制进去。

自己的属性

JavaScript原型可以继承。因此对象属性可是以自己的,也可以是继承的。

对象字面符显式声明的属性是自己的,但是对象从原型接收的属性是继承的。

接下来创建一个 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 对象有一个自己的属性 profession 和从 person 属性中继承过来的 name surname 属性。

Object spread从自己的源属性中进行复制的时候,会忽略继承的属性。

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

...personB 对象从源对象 personB 中仅复制了自己的属性 profession ,但忽略了可继承的 name surname 属性。

Object spread可以从源对象中复制自己的和可枚举的属性。和 Object.keys() 相同。

传播对象属性

对象字符符中里的Object spread可以复制源对象自己的和可枚举的属性,并将其复制到目标对象中。

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

顺便说一下,在许多方面,Object spread相当于 Object.assign() 。上面的代码也可以这样实现:

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

一个对象字面符可以有多个Object spread,任何组合都可以使用规则属性声明:

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

Object spread规则:最后属性获胜

当多个对象被传播,有一些属性具有相同的键时,那么是如何计算最终的值呢?规则很简单: 后者扩展属性覆盖具有相同键的早期属性

来看几个示例。下面的对象字面符实例化了一只猫:

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

让我们扮演Frankenstein博士,把这只猫变成一只狗。注意它的 sound 属性值:

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

后面的属性值 woof 覆盖了前面的属性值 meow (来自 cat 对象的 sound 的值)。这符合使用相同的键值时,后一个属性值将覆盖最早的属性值的规则。

同样的规则也适用于对象初始化的规则属性:

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

sound: 'woof' 规则最终获胜,那是因为他在最后。

现在如果你交换传播对象的相对位置,结果是不同的:

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

猫仍然是猫。尽管第一个源对象提供了 sound 属性的值为 woof ,但它还是被后面的 cat 对象的 sound 的属性值 meow 覆盖了。

Object spread的位置和正则性质很重要。这种语法允许实现诸如对象克隆、合并和填充默认值之类的。

下面我们来看看。

对象克隆

使用Object Spread语法可以用一个简短而富有表现力的方式来克隆一个对象。下面的例子克隆了 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 目标。因此 birdClone bird 的克隆。

虽然克隆对象技术乍一看似乎很简单,但有一些细节的差异还是需要注意的。

浅拷贝

Object Spread只会做一个对象的浅拷贝。只有对象本身是克隆的,而嵌套的实例不是克隆的。

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.screen laptopClone.screen 引用相同的嵌套对象,但没有复制。

其实,你可以在任何级别上做传播。只需稍加努力,就可以克隆嵌套的对象:

const laptopDeepClone = {  
    ...laptop,
    screen: {
        ...laptop.screen
    }
};
console.log(laptop === laptopDeepClone);               // => false  
console.log(laptop.screen === laptopDeepClone.screen); // => false  

一个额外的 ...laptop.screen 就确保了嵌套对象也被克隆了。现在, laptopDeepClone 完整的克隆了 laptop 对象。

原型丢失

下面的代码片段声明了一个 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 是一个普通的JavaScript对象,其原型是 Object.prototype ,而不是 Game.prototype ,这是可以预期的。 Object Spread不保存源对象的原型

因此,调用 doomClone.getMessage() 会抛出一个 TypeError 错误,那是因为 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!"  

我保证,使用这种方法,原型也会克隆过来。

更新不可变对象

当一个对象在应用程序的多个地方共用时,直接修改这个对象可能会带来意想不到的副作用。而且跟踪这些修改也是极为蛋疼的事情。

更好的方法是使用操作不可变。不可变能更好的控制对象的修改,有利于编写 纯函数 。即使在一些复杂的场景中,也更容易确定对象更新的源和原因,因为数据流到一个单一的方向。

Object Spread方便以不可变的方式来修改对象。所设你有一个对象描述了一本书的版本信息:

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

然后这本书的第六版本出来了。Object Spread让我们可以以不可变的方式对这个场景进行编程:

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

...book 复制了 book 对象的属性。然后手动添加 edition:6 year:2011 来更新属性值。

在最后指定重要的属性值,因为相同的键值,后面的会覆盖前面的。

newBook 是一个具有更新属性的新对象。与此同时,原来的 book 对象仍然完好无损。达到我们的不变性需求。

合并对象

合并对象很简单,因为你可以扩展对象任意数量的属性。

让我们来合并三个对象:

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 对象的创建是由 part1 part2 part3 三个对象合并而来。

不要忘记,后者会覆盖前者的规则。它给出了合并多个具有相同键对象的理由。

让我们改变一下前面的例子。现在 part1 part3 具有一个新的属性 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);  

首先 ...part1 设置了 configuration 的值为 sedan ,但是后面的 ...part3 configuration 设置的 hatchback 覆盖了前面的。

使用默认值填充对象

对象可以在运行时拥有不同的属性集。一些属性可以被设置,另一些可能会丢失。

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

让我们实现一个 multiline(str, config) 函数,通过给定的宽度,将 str 分成多个行。

config 对象可能会接受下面几个参数:

  • width :要断开的字符数。默认为 10
  • newLine :在行尾添加字符串。默认为 \n
  • indent :打算的行。默认值为 ' '

multiline() 函数几个示例:

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 2 3 个属性,甚至没有属性。

使用Object Spread会非常简单,可以用默认值填充 config 对象。在对象字面符中首先展开默认对象,然后是 config 对象:

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;
}

让我们管理 safeConfig 对象字面量。

...defaultConfig 从默认值中提取属性,然后 ...config 配置将会覆盖以前的默认值和自定义属性值。

因此, safeConfig 具有 multiline() 函数可以使用的全部属性。无论输入的配置是否会遗漏一些属性, safeConfig 都会具备必要的值。

Object Spread能非常直观的使用默认值。

我们需要更进一步

Object Spread非常酷的地方在于可以在嵌套对象上使用。当更新一个大对象时,这是一个很好的优势,具有很好的可读性。但还是推荐使用 Object.assign() 来替代。

下面的 box 对象定义了 box 的标签:

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

box.size 描述了 box 的尺寸,以及 box.items 中包含了 box 中可枚举的 item

通过增加 box.size.height 使 box 变高。只需要在嵌套对象上扩展 height 属性:

const biggerBox = {  
    ...box,
    size: {
        ...box.size,
        height: 200
    }
};
console.log(biggerBox);  

...box 可以确保 biggerBox 接收来自 box 源的属性。

更新嵌套对象 box.size height ,只需要额外的一个对象字面量 {...box.size, height:200} 。这样一来, box.size height 属性就得到了一个新值,其值更新为 200

我喜欢通过一个语句执行多个更新的可能性。

如何将颜色改为黑色,将宽度增加到 400 ,并添加一个新的项目 ruler items 中(使用 扩展数组 )?这很简单:

const blackBox = {  
    ...box,
    color: 'black',
    size: {
        ...box.size,
        width: 400
    },
    items: [
        ...box.items,
        'ruler'
    ]
};
console.log(blackBox);  

传播 undefined null 和原始值

当扩展 undefined null 或原始值时,不会提取任何属性,也不会抛出任何错误。中会返回一个空的对象:

const nothing = undefined;  
const missingObject = null;  
const two = 2;
console.log({ ...nothing });       // => { }  
console.log({ ...missingObject }); // => { }  
console.log({ ...two });           // => { }  

nothing missingObject two 中,Object Spread没有扩展任何属性。当然,没有理由在原始值上使用Object Spread。

Object rest属性

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

这就是对象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 只会收集 marginLeft marginRight 属性。

Object rest只收集自己的和可枚举的属性。

注意,Object rest必须是结构性赋值中的最后一个元素。因此 const { ...margin , width } = style 将会报错: SyntaxError: Rest element must be last element

总结

Object spread有一些规则要记住:

  • 它从源对象中提取自己的和可枚举的属性
  • 扩展的属性具有相同键的,后者会覆盖前者

与此同时,Object spread是简短而且富有表现力的,同时在嵌套对象上也能很好的工作,同时也保持更新的不变性。它可以轻松的实现对象克隆、合并和填充默认属性。

在结构性赋值中使用Object rest语法,可以收集剩余的属性。

实际上,Object rest和Object spread是JavaScript的重要补充。

本文根据 @Dmitri Pavlutin 的《 How three dots changed JavaScript: object rest/spread properties 》所译,整个译文带有我们自己的理解与思想,如果译得不好或有不对之处还请同行朋友指点。如需转载此译文,需注明英文出处: https://dmitripavlutin.com/how-three-dots-changed-javascript-object-rest-spread-properties/

大漠

常用昵称“大漠”,W3CPlus创始人,目前就职于手淘。对HTML5、CSS3和Sass等前端脚本语言有非常深入的认识和丰富的实践经验,尤其专注对CSS3的研究,是国内最早研究和使用CSS3技术的一批人。CSS3、Sass和Drupal中国布道者。2014年出版《 图解CSS3:核心技术与案例实战 》。

如需转载,烦请注明出处: https://www.w3cplus.com/javascript/how-three-dots-changed-javascript-object-rest-spread-properties.html

作者:W3CPlus
原文地址:三个点如何改变JavaScript对象的rest和spread属性, 感谢原作者分享。

发表评论