在本章中,我们将学习如何在 JavaScript 中复制对象和数组。
复制数据有两种“深度”
接下来的部分将介绍这两种复制方式。不幸的是,JavaScript 只内置了对浅拷贝的支持。如果我们需要深拷贝,就需要自己实现。
让我们来看看几种浅拷贝数据的方法。
我们可以将 展开运算符用于对象字面量 和 数组字面量 来创建副本
然而,展开运算符有一些问题。这些问题将在接下来的小节中介绍。其中一些是真正的限制,而另一些仅仅是特殊情况。
例如
class MyClass {}
const original = new MyClass();
assert.equal(original instanceof MyClass, true);
const copy = {...original};
assert.equal(copy instanceof MyClass, false);
请注意,以下两个表达式是等效的
因此,我们可以通过为副本指定与原始数据相同的原型来解决这个问题
class MyClass {}
const original = new MyClass();
const copy = {
__proto__: Object.getPrototypeOf(original),
...original,
};
assert.equal(copy instanceof MyClass, true);
或者,我们可以在创建副本后,通过 Object.setPrototypeOf()
设置副本的原型。
此类内置对象的示例包括正则表达式和日期。如果我们复制它们,就会丢失存储在其中的大部分数据。
考虑到 原型链 的工作原理,这通常是正确的方法。但我们仍然需要注意这一点。在下面的例子中,original
的继承属性 .inheritedProp
在 copy
中不可用,因为我们只复制了自身属性,而没有保留原型。
const proto = { inheritedProp: 'a' };
const original = {__proto__: proto, ownProp: 'b' };
assert.equal(original.inheritedProp, 'a');
assert.equal(original.ownProp, 'b');
const copy = {...original};
assert.equal(copy.inheritedProp, undefined);
assert.equal(copy.ownProp, 'b');
例如,数组实例的自身属性 .length
不可枚举,因此不会被复制。在下面的例子中,我们通过对象展开运算符(A 行)复制数组 arr
const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
const copy = {...arr}; // (A)
assert.equal({}.hasOwnProperty.call(copy, 'length'), false);
这也很少是一个限制,因为大多数属性都是可枚举的。如果我们需要复制不可枚举的属性,可以使用 Object.getOwnPropertyDescriptors()
和 Object.defineProperties()
来复制对象(稍后将解释如何做到这一点)
value
),因此可以正确复制 getter、setter、只读属性等。Object.getOwnPropertyDescriptors()
检索可枚举和不可枚举的属性。有关可枚举性的更多信息,请参阅 §12 “属性的可枚举性”。
与 属性的特性 无关,其副本始终是可写和可配置的数据属性。
例如,这里我们创建了属性 original.prop
,其特性 writable
和 configurable
为 false
const original = Object.defineProperties(
{}, {
prop: {
value: 1,
writable: false,
configurable: false,
enumerable: true,
},
});
assert.deepEqual(original, {prop: 1});
如果我们复制 .prop
,则 writable
和 configurable
都为 true
const copy = {...original};
// Attributes `writable` and `configurable` of copy are different:
assert.deepEqual(
Object.getOwnPropertyDescriptors(copy),
{
prop: {
value: 1,
writable: true,
configurable: true,
enumerable: true,
},
});
因此,getter 和 setter 也不会被忠实地复制
const original = {
get myGetter() { return 123 },
set mySetter(x) {},
};
assert.deepEqual({...original}, {
myGetter: 123, // not a getter anymore!
mySetter: undefined,
});
前面提到的 Object.getOwnPropertyDescriptors()
和 Object.defineProperties()
始终会完整地传递自身属性及其所有特性(如下所示)。
副本中每个键值对都有原始数据的新版本,但原始数据的 value 本身不会被复制。例如
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {...original};
// Property .name is a copy: changing the copy
// does not affect the original
copy.name = 'John';
assert.deepEqual(original,
{name: 'Jane', work: {employer: 'Acme'}});
assert.deepEqual(copy,
{name: 'John', work: {employer: 'Acme'}});
// The value of .work is shared: changing the copy
// affects the original
copy.work.employer = 'Spectre';
assert.deepEqual(
original, {name: 'Jane', work: {employer: 'Spectre'}});
assert.deepEqual(
copy, {name: 'John', work: {employer: 'Spectre'}});
我们将在本章后面介绍深拷贝。
Object.assign()
进行浅拷贝(可选)Object.assign()
的工作原理与将数据展开到对象中基本相同。也就是说,以下两种复制方式基本等效
使用方法而不是语法的好处是,它可以通过库在较旧的 JavaScript 引擎上进行 polyfill。
不过,Object.assign()
与展开运算符并不完全相同。它们在一个相对微妙的点上有所不同:创建属性的方式不同。
Object.assign()
使用赋值来创建副本的属性。除其他外,赋值会调用自身和继承的 setter,而定义则不会(有关赋值与定义的更多信息)。这种差异很少被注意到。下面的代码是一个例子,但它是人为设计的
const original = {['__proto__']: null}; // (A)
const copy1 = {...original};
// copy1 has the own property '__proto__'
assert.deepEqual(
Object.keys(copy1), ['__proto__']);
const copy2 = Object.assign({}, original);
// copy2 has the prototype null
assert.equal(Object.getPrototypeOf(copy2), null);
通过在 A 行使用计算属性键,我们将 .__proto__
创建为一个自身属性,并且不调用继承的 setter。但是,当 Object.assign()
复制该属性时,它会调用 setter。(有关 .__proto__
的更多信息,请参阅 “面向急性子的程序员的 JavaScript”。)
Object.getOwnPropertyDescriptors()
和 Object.defineProperties()
进行浅拷贝(可选)JavaScript 允许我们通过 属性描述符 来创建属性,属性描述符是指定属性特性的对象。例如,通过 Object.defineProperties()
,我们已经在实际操作中看到过它。如果我们将该方法与 Object.getOwnPropertyDescriptors()
结合使用,就可以更忠实地进行复制
function copyAllOwnProperties(original) {
return Object.defineProperties(
{}, Object.getOwnPropertyDescriptors(original));
}
这消除了通过展开运算符复制对象时的两个问题。
首先,自身属性的所有特性都会被正确复制。因此,我们现在可以复制自身的 getter 和 setter
const original = {
get myGetter() { return 123 },
set mySetter(x) {},
};
assert.deepEqual(copyAllOwnProperties(original), original);
其次,由于使用了 Object.getOwnPropertyDescriptors()
,不可枚举的属性也会被复制
const arr = ['a', 'b'];
assert.equal(arr.length, 2);
assert.equal({}.hasOwnProperty.call(arr, 'length'), true);
const copy = copyAllOwnProperties(arr);
assert.equal({}.hasOwnProperty.call(copy, 'length'), true);
现在该解决深拷贝的问题了。首先,我们将手动进行深拷贝,然后我们将研究通用方法。
如果我们嵌套使用展开运算符,就会得到深拷贝
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = {name: original.name, work: {...original.work}};
// We copied successfully:
assert.deepEqual(original, copy);
// The copy is deep:
assert.ok(original.work !== copy.work);
这是一种技巧,但在紧急情况下,它提供了一种快速解决方案:为了深拷贝对象 original
,我们首先将其转换为 JSON 字符串,然后解析该 JSON 字符串
function jsonDeepCopy(original) {
return JSON.parse(JSON.stringify(original));
}
const original = {name: 'Jane', work: {employer: 'Acme'}};
const copy = jsonDeepCopy(original);
assert.deepEqual(original, copy);
这种方法的显著缺点是,我们只能复制 JSON 支持的键和值的属性。
一些不支持的键和值会被忽略
assert.deepEqual(
jsonDeepCopy({
// Symbols are not supported as keys
[Symbol('a')]: 'abc',
// Unsupported value
b: function () {},
// Unsupported value
c: undefined,
}),
{} // empty object
);
其他则会导致异常
assert.throws(
() => jsonDeepCopy({a: 123n}),
/^TypeError: Do not know how to serialize a BigInt$/);
以下函数可以对值 original
进行通用的深拷贝
function deepCopy(original) {
if (Array.isArray(original)) {
const copy = [];
for (const [index, value] of original.entries()) {
copy[index] = deepCopy(value);
}
return copy;
} else if (typeof original === 'object' && original !== null) {
const copy = {};
for (const [key, value] of Object.entries(original)) {
copy[key] = deepCopy(value);
}
return copy;
} else {
// Primitive value: atomic, no need to copy
return original;
}
}
该函数处理三种情况
original
是一个数组,我们会创建一个新的数组,并将 original
的元素深拷贝到其中。original
是一个对象,我们会使用类似的方法。original
是一个原始值,我们什么都不用做。让我们试试 deepCopy()
const original = {a: 1, b: {c: 2, d: {e: 3}}};
const copy = deepCopy(original);
// Are copy and original deeply equal?
assert.deepEqual(copy, original);
// Did we really copy all levels
// (equal content, but different objects)?
assert.ok(copy !== original);
assert.ok(copy.b !== original.b);
assert.ok(copy.b.d !== original.b.d);
请注意,deepCopy()
只解决了展开运算符的一个问题:浅拷贝。其他问题仍然存在:原型不会被复制,特殊对象只会被部分复制,不可枚举的属性会被忽略,大多数属性特性会被忽略。
完全通用的复制实现通常是不可能的:并非所有数据都是树形结构,有时我们不想复制所有属性,等等。
deepCopy()
版本如果我们使用 .map()
和 Object.fromEntries()
,就可以使之前的 deepCopy()
实现更加简洁
function deepCopy(original) {
if (Array.isArray(original)) {
return original.map(elem => deepCopy(elem));
} else if (typeof original === 'object' && original !== null) {
return Object.fromEntries(
Object.entries(original)
.map(([k, v]) => [k, deepCopy(v)]));
} else {
// Primitive value: atomic, no need to copy
return original;
}
}
.clone()
vs. 拷贝构造函数” 解释了基于类的复制模式。