JS 对象合并与克隆方法的分类与比较

4,368 阅读6分钟

对象的合并与拷贝(又称复制或克隆)是前端们平时工作中绕不开的基本操作,使用场景非常多。也许你已经有了自己用惯了的工具方法,但是对于这个话题,你确定自己已经完全了解了吗?

本文详细分析了js对象的合并与拷贝方法,并试图从几个维度对其进行分类和整理。


合并与克隆的关系

首先分析一下合并与克隆的关系。我认为,合并基本上是拷贝的超集

拷贝可以认为是一种特殊情况下的合并:将一个空对象 {} 作为目标,与一个非空对象合并。

但这里为什么说“基本上”,而不是“严格意义上”呢?那是因为,拷贝有时候会提出一些特殊的要求,而这些要求是普通的合并操作不关注的。举个例子,拷贝往往会要求目标对象和源对象 constructor 相同,一个 Person 类的实例被拷贝后应该还是一个 Person,不能变成 Dog,更不能变成一个不知道是什么东西的 Object

此外,合并和拷贝在方法调用上也有差别。合并一般要求支持多个源对象向目标对象合并,而拷贝的源对象只有一个。

常见的合并与拷贝的方法

社区以及规范里也有非常多合并和拷贝方法供码农们选择。比如,一个讨巧的方式是利用JSON,调用JSON.parse(JSON.stringify() )来实现对象复制。ES5 中增加了原生的 Object.assign 来实现合并。而利用 ES6 中的扩展运算符,调用形如 {…x, …y} 的声明,也能实现对象的合并。

除了这些原生的合并拷贝方法,我还找了jQuery@3.2.1,underscore@1.8.3,lodash@4.16.1三个大名鼎鼎库中的相关方法。我们接下来将以他们作为例子,详细从合并与拷贝方法的各个维度上来分析。

说明:jQuery.extend 方法有很多调用方式,既可以拷贝又可以合并,所以在两个列表中都出现。另外,lodashunderscore 都使用下划线_,这里根据“先来后到”的顺序,用 _ 指代underscore,用 l 指代 lodash

方法如下。
拷贝方法:
  • JSON.parse(JSON.stringify())
  • $.extend
  • _.clone
  • l.clone
  • l.cloneDeep
  • l.cloneWith
  • l.cloneDeepWith
合并方法:
  • {…x, …y}
  • Object.assign
  • $.extend
  • _.extend
  • _.extendOwn
  • l.assign
  • l.assignIn
  • l.assignWith
  • l.assignInWith
  • l.merge
  • l.mergeWith

拷贝方法分析

本文从这几个维度来分析拷贝方法:
1.是否支持处理特殊类型?

对于依赖 JSON 来拷贝对象的 JSON.parse(JSON.stringify()) 方法来说,undefinedfunction 类型的属性会被忽略,而 Date 类型的属性则会被转换为字符串,这可能不是我们想要的。

2.是否能够正确处理 constructor?

就像前文所述,Person 不能变为 Dog,也不能变为 Object。目标对象应该保留源对象的 constructor

3.是否是深拷贝?

当源对象的某个属性为引用类型时,对引用类型的处理决定了这次拷贝操作是深拷贝还是浅拷贝。浅拷贝直接把引用地址原样拿来,此时,不管源对象还是目标对象,修改引用属性后另一个对象的同名属性都会受到影响。深拷贝则会递归地在目标对象上创建值,目标对象和源对象之间将完全独立。

4.是否支持 customizer?

customizer 是指一个处理方法,允许用户定制拷贝中的处理过程,其作用类似 Array 系列方法中的遍历处理函数。一开始我也没想到这个维度,还是在研究 lodash 相关方法的时候才看到的。不得不说,这是一个很有用的特性。

例子:

function customizer(value) {  
    if (_.isElement(value)) {    
        return value.cloneNode(false);  
    }
}
var el = _.cloneWith(document.body, customizer);

现在,根据上面四个维度,我测试了上面列出的拷贝方法,总结成如下表:
方法
是否支持处理特殊类型
是否能够正确处理constructor
是否是深拷贝
是否支持customizer
JSON.parse(JSON.stringify())
$.extend
支持(第一个参数为true)
_.clone
l.clone
l.cloneDeep
l.cloneWith
l.cloneDeepWith

总结:

  1. underscoreclone 方法不支持深拷贝,比较弱。
  2. jqueryextend 方法默认不使用深拷贝,但当第一个参数传入 true 时则使用深拷贝来处理。
  3. lodash 提供了4个 clone 相关方法。只有 lodash 的 clone 方法正确处理了 constructor,而 customizer 也只有 lodash 一家独有(两个with 方法)。


合并方法分析:

对于合并,本文从以下维度来分析:

1.原型属性是否参与合并?

原型属性参与合并时,源对象原型上的属性会被作为目标对象上的普通属性。如:

function Foo() {
    this.a = 1;
}
Foo.prototype.b = 2;
let x = new Foo();
assign({}, x);
// {a: 1, b: 2}
2.undefined 值是否参与合并?

字面意思。值 为undefined 的属性是否参与合并。

3.是否递归合并?

递归合并的概念与拷贝的 深/浅 相似,但是又有所不同。

首先确认一点,所有的合并操作都不会是“浅”的,都不会直接把引用地址赋给目标对象。但在此基础上,又有不同的合并策略。比如:

let x = {a: {m: 1, n: 2}};
let y = {a: {m: 2, o: 3}};
assign(x ,y);

// 非递归合并
// {a: {m: 2, o: 3}}
// 递归合并
// {a: {m: 2 , n: 2, o: 3}}

非递归合并会直接用源对象上的值去替换/覆盖目标对象的值。而递归合并则会对其进行进一步的合并。

4.是否支持 customizer?

和上面一样,允许用户定制合并的处理过程。

下面对以上合并方法进行测试和分析,结果表如下:
方法
原型属性是否参与合并
undefined是否参与合并
是否递归合并
是否支持customizer
{…x, …y}
Object.assign
jQuery.extend
支持(第一个参数为true)
_.extend
_.extendOwn
l.assign
l.assignIn
l.assignWith
l.assignInWith
l.merge
l.mergeWith

总结:
  1. 原生方法中使用Object.assign方法和使用扩展操作符完全一样。
  2. 除了lodash 的 merge,其余方法都不支持递归合并。
  3. 除了lodash 的 merge,其余方法undefined都参与合并。
  4. 除了lodash 的三个 with 方法,其余方法都不支持 customizer

根据上面的两张表,读者可以自行选择合适的合并与拷贝方法了。如果有其他方法,也可以用这些维度来进行分析。

最后,欢迎拍砖~!