深入剖析 JavaScript 的深复制
Category(分类): JavaScript Status: 已整理
本文保留了原文(2015 年 5 月)的完整结构与代码主线:对比 Underscore / jQuery / lodash 三家库的复制实现、JSON 方法的边界、作者自己 prototype 版深复制,以及对比表与性能测试。整理时修复了被富文本格式破坏的全部代码块、两处源码级 bug(环检测
index > 0、arr2笔误),并补充structuredClone()的现代视角。文末附勘误清单。
May 27, 2015 · JavaScript · 深复制
一年前我曾写过一篇 JavaScript 中的一种深复制实现,当时写这篇文章的时候还比较稚嫩,有很多地方没有考虑仔细。为了不误人子弟,我决定结合 Underscore、lodash 和 jQuery 这些主流的第三方库来重新谈一谈这个问题。
第三方库的实现
讲一句唯心主义的话,放之四海而皆准的方法是不存在的,不同的深复制实现方法和实现粒度有各自的优劣以及各自适合的应用场景,所以本文并不是在教大家该如何实现深复制,而是将一些在 JavaScript 中实现深复制所需要考虑的问题呈献给大家。我们首先从较为简单的 Underscore 开始:
Underscore —— _.clone()
在 Underscore 中有这样一个方法:_.clone(),这个方法实际上是一种浅复制,所有嵌套的对象和数组都是直接复制引用而并没有进行深复制。来看一下例子应该会更加直观:
var x = { a: 1, b: { z: 0 } }
var y = _.clone(x)
y === x // false
y.b === x.b // true
x.b.z = 100
y.b.z // 100
让我们来看一下 Underscore 的源码:
// Create a (shallow-cloned) duplicate of an object.
_.clone = function(obj) {
if (!_.isObject(obj)) return obj
return _.isArray(obj) ? obj.slice() : _.extend({}, obj)
}
如果目标对象是一个数组,则直接调用数组的 slice() 方法,否则就用 _.extend() 方法。想必大家对 extend() 方法不会陌生,它的作用主要是将从第二个参数开始的所有对象,按键值逐个赋给第一个对象。而在 jQuery 中也有类似的方法。关于 Underscore 中的 _.extend() 方法的实现可以参考 underscore.js #L1006。
Underscore 的 clone() 不能算作深复制,但它至少比直接赋值来得「深」一些,它创建了一个新的对象。另外,你也可以通过以下比较 tricky 的方法来完成单层嵌套的深复制:
var _ = require('underscore')
var a = [{ f: 1 }, { f: 5 }, { f: 10 }]
var b = _.map(a, _.clone) // <----
b[1].f = 55
console.log(JSON.stringify(a)) // [{"f":1},{"f":5},{"f":10}]
jQuery —— $.clone() / $.extend()
在 jQuery 中也有这么一个叫 $.clone() 的方法,可是它并不是用于一般的 JS 对象的深复制,而是用于 DOM 对象。这不是这篇文章的重点,所以感兴趣的同学可以参考 jQuery 的文档。与 Underscore 类似,我们也是可以通过 $.extend() 方法来完成深复制。值得庆幸的是,我们在 jQuery 中可以通过添加一个参数来实现递归 extend。调用 $.extend(true, {}, ...) 就可以实现深复制啦,参考下面的例子:
var x = {
a: 1,
b: { f: { g: 1 } },
c: [1, 2, 3]
}
var y = $.extend({}, x) // shallow copy
var z = $.extend(true, {}, x) // deep copy
y.b.f === x.b.f // true
z.b.f === x.b.f // false
在 jQuery 的源码 - src/core.js #L121 文件中我们可以找到 $.extend() 的实现,也是实现得比较简洁,而且不太依赖于 jQuery 的内置函数,稍作修改就能拿出来单独使用。
lodash —— _.clone() / _.cloneDeep()
在 lodash 中关于复制的方法有两个,分别是 _.clone() 和 _.cloneDeep()。其中 _.clone(obj, true)(lodash 3.x 的用法,第二个参数 isDeep)等价于 _.cloneDeep(obj)。
勘误与更新:
_.clone(obj, true)这个「第二参开关」只存在于 lodash 3.x。lodash 4.x 起已移除该参数——_.clone(value)只做浅复制,深复制必须用_.cloneDeep(value)。今天新项目里请直接用后者。
使用上,lodash 和前两者并没有太大的区别,但看了源码会发现,Underscore 的实现只有 30 行左右,而 jQuery 也不过 60 多行。可 lodash 中与深复制相关的代码却有上百行,这是什么道理呢?
var $ = require('jquery')
var _ = require('lodash')
var arr = new Int16Array(5)
var obj = { a: arr }
var obj2
arr[0] = 5
arr[1] = 6
// 1. jQuery
obj2 = $.extend(true, {}, obj)
console.log(obj2.a) // [5, 6, 0, 0, 0]
console.log(Object.prototype.toString.call(obj2.a)) // [object Int16Array]
obj2.a[0] = 100
console.log(obj.a[0]) // 100 —— jQuery 不能正确处理 Int16Array 的深复制!
// 2. lodash
obj2 = _.cloneDeep(obj)
console.log(obj2.a) // [5, 6, 0, 0, 0]
console.log(Object.prototype.toString.call(obj2.a)) // [object Int16Array]
obj2.a[0] = 100
console.log(obj.a[0]) // 5
(勘误:原文 lodash 段落的 Object.prototype.toString.call(arr2) 中 arr2 未定义,实际应为 obj2.a,已修正;jQuery 段最后误打印 console.log(obj) 的两处也已改为 obj.a[0]。)
通过上面这个例子可以初见端倪,jQuery 无法正确深复制 JSON 对象以外的对象,而我们可以从下面这段代码片段看出 lodash 花了大量的代码来实现 ES6 引入的大量新的标准对象。更厉害的是,lodash 针对存在环的对象的处理也是非常出色的。因此相较而言,lodash 在深复制上的行为反馈比前两个库好很多,是更拥抱未来的一个第三方库。
/** `Object#toString` result references. */
var argsTag = '[object Arguments]'
var arrayTag = '[object Array]'
var boolTag = '[object Boolean]'
var dateTag = '[object Date]'
var errorTag = '[object Error]'
var funcTag = '[object Function]'
var mapTag = '[object Map]'
var numberTag = '[object Number]'
var objectTag = '[object Object]'
var regexpTag = '[object RegExp]'
var setTag = '[object Set]'
var stringTag = '[object String]'
var weakMapTag = '[object WeakMap]'
var arrayBufferTag = '[object ArrayBuffer]'
var float32Tag = '[object Float32Array]'
var float64Tag = '[object Float64Array]'
var int8Tag = '[object Int8Array]'
var int16Tag = '[object Int16Array]'
var int32Tag = '[object Int32Array]'
var uint8Tag = '[object Uint8Array]'
var uint8ClampedTag = '[object Uint8ClampedArray]'
var uint16Tag = '[object Uint16Array]'
var uint32Tag = '[object Uint32Array]'
借助 JSON 全局对象
相比于上面介绍的三个库的做法,针对纯 JSON 数据对象的深复制,使用 JSON 全局对象的 parse 和 stringify 方法来实现深复制也算是一个简单讨巧的方法。然而使用这种方法会有一些隐藏的坑,它能正确处理的对象只有 Number、String、Boolean、Array、扁平对象,即那些能够被 json 直接表示的数据结构。
function jsonClone(obj) {
return JSON.parse(JSON.stringify(obj))
}
var clone = jsonClone({ a: 1 })
现代注解:这个方法的完整坑位清单(2015 年原文只点了「纯 JSON 数据」一句):函数与
undefined属性会被丢弃、NaN/Infinity变null、Date变 ISO 字符串、RegExp/Map/Set变空对象、BigInt直接抛TypeError、循环引用抛TypeError、丢失原型与不可枚举属性。只适合纯数据快照。
拥抱未来的深复制方法
我自己实现了一个深复制的方法,因为用到了 Object.create、Object.isPrototypeOf 等比较新的方法,所以基本只能在 IE9+ 中使用。而且,我的实现是直接定义在 prototype 上的,很有可能引起大多数的前端同行们的不适。(关于这个我还曾在知乎上提问过:为什么不要直接在 Object.prototype 上定义方法?)只是实验性质的,大家参考一下就好,改成非 prototype 版本也是很容易的,不过就是要不断地去判断对象的类型了。~
这个实现方法具体可以看我写的一个小玩意儿——Cherry.js,使用方法大概是这样的:
function X() {
this.x = 5
this.arr = [1, 2, 3]
}
var obj = {
d: new Date(),
r: /abc/ig,
x: new X(),
arr: [1, 2, 3]
}
var obj2, clone
obj.x.xx = new X()
obj.arr.testProp = 'test'
clone = obj.$clone() // <----
首先定义一个辅助函数,用于在预定义对象的 Prototype 上定义方法:
function defineMethods(protoArray, nameToFunc) {
protoArray.forEach(function(proto) {
var names = Object.keys(nameToFunc)
var i = 0
for (; i < names.length; i++) {
Object.defineProperty(proto, names[i], {
enumerable: false,
configurable: true,
writable: true,
value: nameToFunc[names[i]]
})
}
})
}
为了避免和原生方法冲突,我在方法名前加了一个 $ 符号。而这个方法的具体实现很简单,就是递归深复制。其中我需要解释一下两个参数:srcStack 和 dstStack。它们的主要用途是对存在环的对象进行深复制。比如源对象中的子对象 srcStack[7] 在深复制以后,对应于 dstStack[7]。该实现方法参考了 lodash 的实现。关于递归最重要的就是 Object 和 Array 对象:
/*=====================================
* Object.prototype
* - $clone()
*=====================================*/
defineMethods([Object.prototype], {
'$clone': function(srcStack, dstStack) {
var obj = Object.create(Object.getPrototypeOf(this))
var keys = Object.keys(this)
var index, prop
srcStack = srcStack || []
dstStack = dstStack || []
srcStack.push(this)
dstStack.push(obj)
for (var i = 0; i < keys.length; i++) {
prop = this[keys[i]]
if (prop === null || prop === undefined) {
obj[keys[i]] = prop
} else if (!prop.$isFunction()) {
if (prop.$isPlainObject()) {
index = srcStack.lastIndexOf(prop)
if (index > -1) { // 勘误:原文为 index > 0,漏掉了位于栈底的根对象自身,遇到 obj.self = obj 会无限递归
obj[keys[i]] = dstStack[index]
continue
}
}
obj[keys[i]] = prop.$clone(srcStack, dstStack)
}
}
return obj
}
})
/*=====================================
* Array.prototype
* - $clone()
*=====================================*/
defineMethods([Array.prototype], {
'$clone': function(srcStack, dstStack) {
var thisArr = this.valueOf()
var newArr = []
var keys = Object.keys(thisArr)
var index, element
srcStack = srcStack || []
dstStack = dstStack || []
srcStack.push(this)
dstStack.push(newArr)
for (var i = 0; i < keys.length; i++) {
element = thisArr[keys[i]]
if (element === undefined || element === null) {
newArr[keys[i]] = element
} else if (!element.$isFunction()) {
if (element.$isPlainObject()) {
index = srcStack.lastIndexOf(element)
if (index > -1) { // 勘误:同上,原文为 index > 0
newArr[keys[i]] = dstStack[index]
continue
}
}
newArr[keys[i]] = element.$clone(srcStack, dstStack)
}
}
return newArr
}
})
接下来要针对 Date 和 RegExp 对象的深复制进行一些特殊处理:
/*=====================================
* Date.prototype
* - $clone
*=====================================*/
defineMethods([Date.prototype], {
'$clone': function() {
return new Date(this.valueOf())
}
})
/*=====================================
* RegExp.prototype
* - $clone
*=====================================*/
defineMethods([RegExp.prototype], {
'$clone': function() {
var pattern = this.valueOf()
var flags = ''
flags += pattern.global ? 'g' : ''
flags += pattern.ignoreCase ? 'i' : ''
flags += pattern.multiline ? 'm' : ''
return new RegExp(pattern.source, flags)
}
})
接下来就是 Number、Boolean 和 String 的 $clone 方法,虽然很简单,但这也是必不可少的。这样就能防止像单个字符串这样的对象错误地去调用 Object.prototype.$clone。
/*=====================================
* Number / Boolean / String.prototype
* - $clone()
*=====================================*/
defineMethods([Number.prototype, Boolean.prototype, String.prototype], {
'$clone': function() {
return this.valueOf()
}
})
教学边界(现代视角):这套 2015 年的实现有几个天然局限——不处理
Map/Set(会走Object.create变成空对象)、不复制Symbol键与不可枚举属性(Object.keys只拿可枚举字符串键)、原型只对纯对象成立。今天的标准答案是structuredClone()(见下节),生产环境深复制优先用它或_.cloneDeep。
(现代补充)structuredClone()
2022 年起,浏览器(Chrome 98+/Firefox 94+/Safari 15.4+)与 Node.js(17+)内置了全局函数 structuredClone(),采用与 Worker postMessage 相同的结构化克隆算法:
const original = {
d: new Date(0),
r: /abc/g,
m: new Map([['a', 1]]),
s: new Set([1, 2]),
typed: new Int16Array([5, 6, 0, 0, 0]),
nested: { x: { y: [1, 2, 3] } }
}
original.self = original // 循环引用
const copy = structuredClone(original)
console.log(copy.typed instanceof Int16Array) // true —— 本文开头 jQuery 处理不了的 TypedArray,原生搞定
console.log(copy.self === copy) // true —— 环正确还原
console.log(copy.nested.x === original.nested.x) // false
能力:循环引用、Date/RegExp/Map/Set/TypedArray/ArrayBuffer/BigInt、原始值。限制:函数、DOM 节点、Symbol() 抛 DataCloneError;不保留原型(类实例退化为普通对象)与属性描述符。选型一句话:纯数据深复制优先 structuredClone();需要保留原型等复杂语义用 _.cloneDeep;JSON 方法只用于纯 JSON 数据。
比较各个深复制方法
| 特性 | jQuery | lodash | JSON.parse | 所谓「拥抱未来的深复制实现」 |
|---|---|---|---|---|
| 浏览器兼容性 | IE6+ (1.x) & IE9+ (2.x) | IE6+ (Compatibility) & IE9+ (Modern) | IE8+ | IE9+ |
| 能够深复制存在环的对象 | 抛出异常 RangeError: Maximum call stack size exceeded | 支持 | 抛出异常 TypeError: Converting circular structure to JSON | 支持 |
| 对 Date、RegExp 的深复制支持 | × | 支持 | × | 支持 |
| 对 ES6 新引入的标准对象的深复制支持 | × | 支持 | × | × |
| 复制数组的属性 | × | 仅支持 RegExp#exec 返回的数组结果 | × | 支持 |
| 是否保留非原生对象的类型 | × | × | × | 支持 |
| 复制不可枚举元素 | × | × | × | × |
| 复制函数 | × | × | × | × |
(表格为 2015 年的历史对比,版本对应当时的 jQuery 1.x/2.x 与 lodash 3.x;lodash 4.x 的 _.cloneDeep 行为已比表中所列更完善。加入今天的 structuredClone() 一列的话:环 ✓、Date/RegExp ✓、Map/Set/TypedArray ✓、数组属性 ×、保留自定义类型 ×、不可枚举 ×、函数直接抛 DataCloneError。)
执行效率
为了测试各种深复制方法的执行效率,我使用了如下的测试用例:
var x = {}
for (var i = 0; i < 1000; i++) {
x[i] = {}
for (var j = 0; j < 1000; j++) {
x[i][j] = Math.random()
}
}
var start = Date.now()
var y = clone(x) // clone 是占位符:分别替换为被测的 jQuery / lodash / JSON / 自定义实现
console.log(Date.now() - start)
下面来看看各个实现方法的具体效率如何,我所使用的浏览器是 Mac 上的 Chrome 43.0.2357.81 (64-bit) 版本,可以看出来在 3 次的实验中,我所实现的方法比 lodash 稍逊一筹,但比 jQuery 的效率也会高一些。希望这篇文章对你们有帮助~
| 深复制方法 | jQuery | lodash | JSON.parse | 所谓「拥抱未来的深复制实现」 |
|---|---|---|---|---|
| Test 1 | 475 | 341 | 630 | 320 |
| Test 2 | 505 | 270 | 690 | 345 |
| Test 3 | 456 | 268 | 650 | 332 |
| Average | 478.7 | 293 | 656.7 | 332.3 |
(表中数字为 2015 年 Chrome 43 下的毫秒数,仅作历史参考;今天的引擎实现、库版本与硬件下数值不可比。structuredClone 是原生实现,通常也是同类方案里最快的之一。)
原文勘误与现代化说明
- 修复了被富文本格式破坏的全部代码块:原文代码被
**加粗标记与换行丢失打散成一团,已按语义完整还原(Underscore/jQuery/lodash/JSON/Cherry.js 各段及源码摘录)。 - 修复 Cherry.js 环检测 bug:原文
if (index > 0)会漏掉位于栈底(index 0)的根对象自身,obj.self = obj这类最常见的自引用会无限递归爆栈,已更正为index > -1并在注释中标注。 - lodash 对比示例中
Object.prototype.toString.call(arr2)的arr2未定义(应为obj2.a)、jQuery 段两处误打印console.log(obj),均已修正。 _.clone(obj, true)标注为 lodash 3.x 用法;lodash 4.x 起第二参已移除,深复制应使用_.cloneDeep。- JSON 方法补充完整坑位清单;Cherry.js 实现补充教学边界(Map/Set/Symbol 键/不可枚举属性不覆盖)。
- 新增
structuredClone()一节(2022 起的标准深复制 API),并在对比表中给出其行为对照。 - 性能测试数据标注为 2015 年 Chrome 43 的历史结果;lodash 文档链接更新为 4.17.15 固定版本页。
- 知乎、StackOverflow 的讨论链接因站点反爬在本机校验返回 403,浏览器可正常访问,予以保留。
图片来源与授权说明
本文原文无插图,未产生图片本地化。
原文归属
作者:JerryZou(邹润阳)。历史来源:jerryzou.com《深入剖析 JavaScript 的深复制》。原文链接仅用于保留历史出处,当前语义以本文列出的规范和官方文档为准。