Menu

JavaScript 对象展开(...):克隆、合并与覆盖属性

JavaScript 对象展开运算符的用法:克隆、合并、覆盖属性,以及常让人踩坑的浅拷贝问题。

本页包含可运行的编辑器。编辑、运行,立即查看输出。

展开把一个对象铺进另一个对象

对象展开语法,也就是对象前面的三个点,会把这个对象自身可枚举的属性复制到外层的对象字面量中。要克隆、合并对象,或者在不改动原对象的情况下生成修改后的副本,这是最简短的写法。

copy 的键和值与 user 相同,但它是另一个对象。修改其中一个不会影响另一个,至少在顶层是这样。稍后我们会再谈这个限制。

可以这样理解:{ ...obj } 的意思是“把 obj 的属性倒进这个新的对象字面量”。在展开旁边写的任何内容都会成为结果的一部分。

一步完成克隆和覆盖

最常见的写法是先展开一个对象,再添加或覆盖几个属性。后面的键优先,所以先展开、后覆盖:

user 没有被改动。updated 是一个新对象,其中的 role 被替换了。这种不可变更新的写法在现代 JavaScript 中随处可见:React 的状态更新函数、Redux 的 reducer,以及任何不想原地修改数据的代码。

把顺序反过来,效果也会相反:

这里 role: "guest" 在前面,所以被 user.role 覆盖了。当你想提供可以被展开对象覆盖的默认值时,这种写法很方便。

合并对象

把两个(或更多)对象展开到一个新的字面量中,就能合并它们。遇到同名的键时,后面的对象会覆盖前面的:

theme 和 fontSize 来自 userPrefs;debug 则沿用 defaults 中的值。三个对象?四个?规则一样:从左往右读,最后写入的优先。

这是 Object.assign({}, defaults, userPrefs) 的现代替代写法。两者效果相同,但展开写法更易读,也不会诱使你犯 Object.assign(defaults, userPrefs) 这种常见错误,因为它会修改 defaults。

展开是浅拷贝

这正是让人上当的地方。展开只复制对象的顶层属性。如果某个属性的值本身是对象或数组,复制的是引用,而不是内容。

修改 copy.address.city 也改变了 user.address.city,因为两个对象共享同一个 address 对象。展开只给了我们一个新的外层包装。

要修改嵌套的内容时,需要展开你想修改的每一层:

要对任意数据真正深度克隆,就用 structuredClone(obj)。它能处理嵌套对象、数组、日期、Map 和 Set,而且所有现代运行时都内置了它。

展开与剩余

它们共用三个点,作用却相反。展开是拆开,剩余是收集。

经验法则:如果 ... 在 = 的左侧(解构),它就是剩余语法。如果它在右侧的对象或数组字面量中,它就是展开语法。

以不可变的方式删除属性

把剩余解构和展开结合起来,就能干净地生成一个删掉某个键的对象副本,不需要 delete,也不会修改原对象:

tempToken 被取出放进它自己的变量(你可以忽略它),其余的属性都进入 safe。原来的 user 没有被改动。

展开不会复制的东西

有几个值得了解的细节:

  • 不可枚举的属性不会被复制。你创建的属性大多默认是可枚举的,但用 Object.defineProperty 定义的属性和某些内置属性不是。
  • 原型不会被复制。 { ...instance } 给你的是一个普通对象,而不是原来那个类的实例。定义在类原型上的方法不会出现在副本上。
  • getter 会被求值。 展开一个带 getter 的对象时,会调用一次 getter,并把返回值作为普通属性存到新对象上。

copy 有 x 和 y,但它是一个普通对象:distance 位于 Point.prototype 上,而展开不会碰到它。如果需要克隆一个类的实例,通常要由这个类自己提供 clone 方法。

下一章:数组方法

展开是不可变数据工具箱中的一件工具。另一件重要工具是数组方法:map、filter、reduce 等,它们会生成新数组,而不是修改原数组。这就是下一页的内容。

常见问题

JavaScript 中 ...obj 有什么作用?

在对象字面量中,...obj 会把该对象自身所有可枚举的属性复制到新对象里。{ ...user } 会生成一个新对象,键和值都与 user 相同。这是在不修改原对象的前提下克隆或合并对象的标准写法。

JavaScript 中如何合并两个对象?

把两个对象都展开到一个新的对象字面量里:const merged = { ...a, ...b }。b 中的属性会覆盖 a 中同名的键,因为后面的属性优先。它等价于 Object.assign({}, a, b),但读起来更清晰。

对象展开是深拷贝吗?

不是。对象展开是浅拷贝:它复制顶层属性,但嵌套的对象和数组仍然按引用共享。修改 copy.address.city 也会修改 original.address.city。要真正深拷贝,请使用 structuredClone(obj)。

展开语法和剩余语法有什么区别?

它们看起来一样(...),作用却相反。展开把对象或数组拆开成单独的属性或元素:{ ...user }。剩余把剩下的部分收集到一个变量里:const { name, ...others } = user。看上下文就能分辨你面对的是哪一种。

Coddy 编程语言插图

和 Coddy 一起学编程

开始