先记住这个答案
对象展开只复制源对象自身的、可枚举的属性。字符串键和 Symbol 键都会复制,但原型链上继承来的属性不复制,用 Object.defineProperty 定义的不可枚举属性也不复制。如果属性是 getter,展开时会调用一次 getter,把返回值作为普通数据属性放进新对象,getter 本身不会被带过去。另外它是浅拷贝,嵌套对象还是同一个引用。
- 只复制自身的可枚举属性,
Symbol键也复制 - 原型链上的属性、不可枚举属性都不复制
- getter 会被执行,新对象里存的是值
- 类实例展开后方法会丢,因为方法在原型上
把各种属性放在一起展开一次
构造一个同时有继承属性、不可枚举属性、Symbol 属性和 getter 的对象,看展开后留下了哪些。
const src = Object.create({ inherited: 1 }) // 原型上有 inherited
src.own = 2
Object.defineProperty(src, 'hidden', { value: 3 }) // 默认不可枚举
src[Symbol('id')] = 4
Object.defineProperty(src, 'computed', {
enumerable: true,
get() { console.log('getter 执行'); return 5 }
})
const copy = { ...src }
console.log(Object.keys(copy).join(','))
console.log('inherited' in copy, 'hidden' in copy)
console.log(Object.getOwnPropertySymbols(copy).length)
console.log(JSON.stringify(Object.getOwnPropertyDescriptor(copy, 'computed')))查看输出与解释
getter 执行
own,computed
false false
1
{"value":5,"writable":true,"enumerable":true,"configurable":true}第一行说明展开的那一刻 getter 就被调用了。inherited 在原型上,hidden 不可枚举,两个都没过来。Symbol 键复制成功了,这点和 Object.keys、for...in 不一样,它们都会跳过 Symbol。最后一行能看到 computed 在新对象里已经是一个值为 5 的普通属性,以后原对象的数据怎么变,它都不会再更新。
为什么类实例展开后方法没了
这是项目里最常踩的坑。比如有一个 class User,实例上有 name,类里定义了 getName() 方法。{ ...user } 之后,name 还在,调用 copy.getName() 却报错 TypeError。
原因是类里写的方法都挂在 User.prototype 上,不是实例自己的属性,展开时不会复制。新对象的原型是普通的 Object.prototype,所以 copy instanceof User 也是 false。用 getter 定义的计算属性同理,它们在原型上,展开后直接消失,连值都没有。
所以 Redux、React 的 state 里通常只放普通对象。放了类实例,每次用展开写不可变更新,就会悄悄把它变成普通对象。
- 想完整复制一个对象怎么办
- 要连原型、不可枚举属性、getter 一起保留,用描述符来复制:
Object.create(Object.getPrototypeOf(src), Object.getOwnPropertyDescriptors(src))。getOwnPropertyDescriptors会拿到所有自身属性的完整描述,getter 以函数的形式保留,不会被执行。
容易答错的地方
- 以为展开和
Object.assign完全一样 - 它们复制的属性范围相同,都是自身可枚举属性。区别在写入方式:
Object.assign(target, src)是对target赋值,如果target上有同名 setter 会被触发;展开是直接在新对象上定义属性,不触发 setter。另外Object.assign会修改第一个参数,展开总是返回新对象。 - 以为展开
null会报错 { ...null }和{ ...undefined }都返回空对象,不会报错。这和数组展开不同,[...null]会抛TypeError,因为数组展开要求值可迭代。- 以为数组的
length会被复制 { ...[1, 2] }得到{ 0: 1, 1: 2 },没有length,因为数组的length是不可枚举的。
面试官还会怎么问?
属性同名时谁覆盖谁?
后面的覆盖前面的。{ ...defaults, ...options } 里 options 的值会覆盖 defaults,所以默认值要写在前面。
展开字符串会得到什么?
{ ...'hi' } 得到 { 0: 'h', 1: 'i' }。字符串被包装成对象后,每个字符都是一个可枚举的索引属性。数字、布尔值展开后是空对象。
展开会复制属性的顺序吗?
会按源对象的属性顺序复制:先是整数键按从小到大,再是字符串键按添加顺序,最后是 Symbol 键。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。