【循序渐进】JavaScript中的深拷贝

1,082 阅读19分钟

一、什么是深拷贝?

1.1 不得不说的数据类型

在JavaScript(EcmaScript)中,只有两种数据类型,一种是基本数据类型:StringNumberBooleanUndefinedNullSymbolBigInt,而另一种就是复杂数据类型:Object。所有值都是上述两种数据类型之一。

我们在区分真值(truthy)和假值(falsy)时,由于在JavaScript中存在很多类型转换(主要是隐式的类型转换)的场景,真值的情况数不胜数,因此我们只需要记住几个固定的假值情况就可以了:''0falseundefinednull,而剩下的全是真值。同理,我们也只需要记住这几个固定的基本数据类型即可,剩下的全是复杂数据类型。

值得注意的是,在这几个基础数据类型中,null是一个特殊的存在。在除了null之外的其他基本数据类型都可以使用typeof操作符来获得其对应的正确数据类型,如typeof true的结果是'boolean',而typeof null的结果却是'object'

正确的结果应该是'null',但这个 bug 由来已久,在 JavaScript 中已经存在了将近二十年,也许永远也不会修复,因为这牵涉到太多的 Web 系统,“修复”它会产生更多的bug,令许多系统无法正常工作。

1.2 浅拷贝和深拷贝

什么是浅拷贝呢?我个人总结为:针对基本数据类型的数据的复制行为就是浅拷贝。如:

let A = 1;
let B = A;
console.log(B) // 1

其中变量B的值就是变量A经过浅拷贝之后的结果。

浅拷贝的实质就是我把这个基本数据类型的值原样返回给你。

那么如果我使用相同的方式浅拷贝一个复杂数据类型的值就怎样呢?

let A = { color: 'red', weight: 300 };
let B = A;
console.log(B) // {color: 'red', weight: 300}

可以看到变量B同样也完全复制了变量A的值。但是如果此时我们修改了A对象的某个属性值:

A.color = 'green';
console.log(B) // {color: 'green', weight: 300}

我们会发现变量Bcolor属性值也变成了green

看到这里大家应该就已经明白了,我们在使用浅拷贝的方式去拷贝一个复杂数据类型的数据时,会将拷贝前和拷贝后的数据之间建立关系,修改其中一个数据的属性值的同时也会反应到另一个数据上,这就是我们不能使用浅拷贝、需要实现深拷贝的原因。

其实,在JavaScript中(Java也是如此),基本数据类型和复杂数据类型有一个本质上的区别:

  • 基本数据类型的值是固定的、不变的、存储在栈中的。在对变量A进行赋值(值为基本数据类型的变量B)时,变量A都会变成一个变量B的值的副本;
  • 复杂数据类型的值是可变的、存储在堆中的(也叫引用型数据)。变量表示的是指向这个堆地址的指针,在对变量A进行赋值(值为复杂数据类型的变量B)时,只是单纯的把变量B也指向了变量A所指向的堆地址。

因此,我们需要一个可以拷贝复杂数据类型数据的方法,这个方法对应基本数据类型的浅拷贝,尝尝被我们称为深拷贝。

二、JSON.stringify()和JSON.parse()

有一定JavaScript编程经验的同学应该都很熟悉这两个方法,它们经常被用来实现复杂数据类型的拷贝,其原理也很简单,就是先使用JSON.stringify()将一个复杂数据A序列化为一个标准的JSON格式的字符串,然后再使用JSON.parse()将这个字符串反序列化为一个新的复杂数据B,你会发现此时A和B之间的关联关系已经断开了,修改其中一个数据的属性值并不会影响到另一个。

这套组合在应用到一些简单、已知、可控的数据结构时还是挺好用的,但是一旦应用到复杂的、不可控的说有就有结构时,会发生难以预测的结果。有这样一个例子:

const A = { name: 'ZhangSan', sex: 'male', age: undefined };
const B = JSON.parse(JSON.stringify(A))
console.log(B) // {name: "ZhangSan", sex: "male"}

你会发现A中的age属性并没有出现在B中,诸如此类的例子比比皆是,而且表象也不一定如此,其主要原因是JSON.stringify()在执行时,有如下限制:

  • 转换值如果有 toJSON() 方法,该方法定义什么值将被序列化
  • 非数组对象的属性不能保证以特定的顺序出现在序列化后的字符串中
  • 布尔值、数字、字符串的包装对象在序列化过程中会自动转换成对应的原始值
  • undefined、任意的函数以及Symbol,出现在数组中时会被序列换为null,作为非数组的属性值时会被忽略,它们单独被转化时会转化为undefined
  • 当在循环引用时会抛出错误TypeError('cyclic object value')
  • 等等。具体规则参见MDN - JSON.stringify()

因此,使用这套组合拳去实现复杂数据类型变量的拷贝要慎之又慎,尤其是对要拷贝的数据结构不可控的情况下最好不要使用该方式,否则会造成很多难以解决的问题。

那么,我们到底要怎样才能实现对复杂数据类型变量的拷贝呢?最简单的办法,只需要一行代码:

import { cloneDeep } from 'lodash'

cloneDeeplodash.js中专门提供用于解决深拷贝的问题的,也是社区中实现最齐全、最完善的解决方案,但是本着深入学习、提升自己的态度,我们也可以自己实现一个相对简单的深拷贝方案,下面我们就进入正题。

三、自己动手实现深拷贝

在自己动手实现之前,我们要记得深拷贝的本质:“使得复杂数据类型在进行拷贝时,可以表现得和基本数据类型一致”。

但这毕竟只是指导思想,更具体一点就是,我们要切断拷贝前的变量A和拷贝后的变量B之间的关联关系,即:使得拷贝后的变量B指向一个新创建的堆地址,并且这个堆地址里面存储着和拷贝前变量A指向的堆地址里“一模一样”的数据。

有了这样的思想觉悟准备后,我们就可以开始动手了.

3.1 先定个小目标

JavaScript代码写多了,你会发现我们最常用的复杂数据类型就只有三个:数组、对象和函数。基本数据类型再加上这三个复杂的数据类型基本上可以实现我们90%的功能需求。

因此,我们可以先定个小目标,实现一个简易的深拷贝,可以完美支持这三个复杂数据类型数据的拷贝:

function cloneDeep(target) {
    // 如果是null或函数或基础数据类型,则直接返回源数据
    if (target === null || typeof target === 'function' || typeof target !== 'object') {
        return target
    }
    let result
    // 是数组
    if (Array.isArray(target)) {
        result = []
    } else {
        result = {}
    }
    // 遍历对象或数组的键,逐个赋值(值为经过深拷贝之后的返回值)
    [...Object.keys(target)].forEach(key => {
        result[key] = cloneDeep(target[key])
    })
    return result
}

从代码中,我们可以看到,针对函数,我们采用了和基本数据类型一样的处理方式,直接返回了源数据,因为考虑到现实编程过程中,很少会二次编辑函数对象(即使编辑了,也应该保存关联关系、保持行为上的一致,lodash也是原样返回);
而针对数组和对象,则是通过遍历目标值的键的数组,逐个对其进行赋值操作,即递归操作。

表面上看上去,以上代码可以很完美的拷贝数组、对象和函数这三类复杂数据类型的数据,但是如果拷贝以下这种数据结构就会出问题了:

const A = { name: 'LiLei' }
const B = { name: 'HanMeiMei', love: A }
A.love = B
const C = cloneDeep(B)

执行以上代码,你会发现报错了:VM90441:8 Uncaught RangeError: Maximum call stack size exceeded,为什么会爆栈呢? 你会发现变量B和变量A存在循环引用,而我们的cloneDeep方法在深拷贝B的love时,需要深拷贝A,而又在深拷贝A的love赋值时又需要深拷贝B,循环往复,陷入了死循环。

那么怎么解决这个问题呢,其实也很简单,只需要给我们的cloneDeep方法加上一个缓存就好了,如下:

function cloneDeep(target, cache = new WeakMap()) {
    // 如果是null或函数或基础数据类型,则直接返回源数据
    if (target === null || typeof target === 'function' || typeof target !== 'object') {
        return target
    }
    if (cache.get(target)) {
        return cache.get(target)
    }
    let result
    // 是数组
    if (Array.isArray(target)) {
        result = []
    } else {
        result = {}
    }
    cache.set(target, result);
    // 遍历对象或数组的键,逐个赋值(值为经过深拷贝之后的返回值)
    [...Object.keys(target)].forEach(key => {
        result[key] = cloneDeep(target[key], cache)
    })
    return result
}

此时,再执行以上的拷贝操作,你会发现变量C成功拷贝了变量B:

console.log(C) // {name: "HanMeiMei", love: {…}}

循环引用的问题完美解决了,我们继续看代码是否还有需要完善的地方呢?

let result
if (Array.isArray(target)) {
    result = []
} else {
    result = {}
}

这几行代码是不是看上去很别扭?那么我们有没有更好的书写方法实现同样的效果呢?答案是有的:

let result = new target.constructor()

这种写法其实是利用原型链的知识,即每个对象上面都有一个原型链_proto_指向创建它的构造函数的原型对象prototype,而prototype对象上的constructor指向的就是它所对应的构造函数。因此我们可以通过使用new操作符来执行这个constructor来创建一个和目标对象原型一致的对象。

该方式不仅限于对象和函数,还适用于其他复杂数据类型(不包括null)

继续细读代码你会发现,我们只针对对象的字符串类型的键进行了枚举,并没有枚举到它可能拥有的Symbol类型的键值对:

[...Object.keys(target)].forEach(...)

Object.keys(target)只能获取到目标对象的可枚举、非Symbol类型的键。此外Object.keys(target)也不能获取目标对象不可枚举的属性名,小目标版本先暂不考虑此情况

让我们使用Object.getOwnPropertySymbols()来解决这个问题:

[...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach(key => {
    result[key] = cloneDeep(target[key], cache)
})

至此,我们小目标版本的深拷贝方法已经相对比较完备了,虽然还有很多不足和未涉及到的细节,我们在接下来的完整版一起解决。但是它现在已经是一个完善的有针对性场景的解决方案了,小目标版本的最终版本:

function deepCopy (target, cache = new WeakMap()) {
    if (target === null || typeof target === 'function' || typeof target !== 'object') {
        return target
    }
    let result = new target.constructor()
    if (cache.get(target)) {
        return cache.get(target)
    }
    cache.set(target, result);
    [...Object.keys(target), ...Object.getOwnPropertySymbols(target)].forEach(key => {
        result[key] = deepCopy(target[key], cache)
    })
    return result
}

3.2 完整版

在开始我们的完整版解决方案之前,我们可以先试着从小目标版本的方案中提取一些有用的经验:

  • null和基本数据类型的数据直接返回源数据即可;
  • 可以使用new target.constructor()创建一个和源数据原型相同的对象;
  • 使用WeakMap创建一个缓存,以存储并返回源数据中可能会出现的循环引用;
  • 在遍历对象的键时,需要留意那些不可枚举的、类型是Symbol类型的键名。

有了这些经验基础,我们就可以在小目标版本的基础上去循序渐进地完善代码、处理边界情况、解决更多场景等,很快,你就会发现你已经实现了一个足够健壮且完备的深拷贝究极解决方案。

3.2.1 增加深拷贝场景(数据类型)

在小目标版本中,我们只考虑了基本数据类型、数组、对象和函数这些数据类型,然而除此之外还有许多数据类型会出现在我们的拷贝对象中,如MapSetDateRegExp等数据类型;同时我们也没有处理一些特殊的数据类型,如基本数据类型的包装对象,如new Number(1),诸如此类。

因此,在完整版中,我们需要事先明确其所需要处理的所有需要进行深拷贝的场景,即对应的数据类型。

在该版本中,并不会像lodash一样尽善尽美的适配所有数据类型,而会处理一些在日常且普通的JavaScript编码过程中遇到的大众类型以及一些经典数据类型,如果想了解全面的、更细致的处理方式可以参考lodash中的实现,

在设定需要处理的数据类型之前,这里就不得不提到一个方法Object.property.toString.call(target),该方法会返回targettype,而这个type就是这个目标对象的具体数据类型的固定格式的字符串表示,如[object String][Object Array][Object Object]等。具体原理可以参考MDN上的描述:如果此方法在自定义对象中未被覆盖,toString() 返回 "[object type]"

// 基本数据类型
// undefined和null因为没有构造函数且只有一个值,因此无需处理;
// BigInt 因为并不常用,也暂不考虑
const STRING_TAG = '[object String]'
const NUMBER_TAG = '[object Number]'
const BOOLEAN_TAG = '[object Boolean]'
const SYMBOL_TAG = '[object Symbol]'

// 复杂数据类型,这里是最常用的一些对象类型。
const DATE_TAG = '[object Date]'
const REGEXP_TAG = '[object RegExp]'
const FUNCTION_TAG = '[object Function]'
const MAP_TAG = '[object Map]'
const SET_TAG = '[object Set]'
const ARRAY_TAG = '[object Array]'
const OBJECT_TAG = '[object Object]'
const ARGS_TAG = '[object Arguments]'
3.2.2 各个击破
  1. StringNumberBoolean这些类型的数据可能有两种情况:原始值(primitive value)或包装对象。

因此在处理这些类型的数据之前,需要先使用typeof操作符判断过滤出原始值(即typeof target !== 'object'),将其直接返回源数据即可。

剩下的未被过滤掉的就是复杂对象了,对于这些基本数据类型的包装对象的处理方式和其他复杂数据类型的处理方式不同,主要是因为这些数据是通过特定的创建方式生成的,即:new Constructor(value),而value又是和target【息息相关】的,因此我们可以通过以下方式创建一个和目标对象“长的一样却没关联”的新对象:

const newTarget = new target.constructor(target)

息息相关指的是在使用new调用构造函数时,传入的参数如果是一个对象,在JavaScript中会进行一次"隐式转换"成为构造函数所需要的参数格式,即调用对象的valueOf()toString()方法,视情况而定。

不信我的话,你试一下。

试完之后你就会发现,StringNumber的构造函数可以正常创建和目标对象“长得一样”的对象,但是当目标对象的valuefalse的时候Boolean却不行了,甚至把"性别"都搞错了!

const boolean1 = new Boolean(false)
const boolean2 = new Boolean(boolean1)
console.log(boolean2)  // Boolean {true}

为什么会这样呢?其实这是其中一个JavaScript饱受诟病的Bug:如果给Boolean构造函数传入一个"真值",那么它就会给你一个valuetrue的Boolean封装对象(MDN)。也就意味着,我们在执行new Boolean(target)时,target并没有进行隐式转换并丢失了真正的value值,而被当成了普通的对象来处理,所以此时的返回值也就一定是Boolean {true}了。

解决这个问题的办法也很简单,就是在处理Boolean的包装对象时,我们手动帮它进行“隐式转换”:

// 手动调用包装对象的valueOf()就额可以换取到其真正的value值了
const boolean2 = new Boolean(boolean1.valueOf())
console.log(boolean2)  // Boolean {false}
  1. Symbol作为基本数据类型,只有一种创建方式,即const sy = Symbol('sy'),也就是“原始值”,和其它原始值一样,直接返回即可;
  2. Date,仔细阅读MDN中对于参数的说明,其参数支持value,而我们的日期对象通过隐式转换后(valueOf())就可以得到一个value的数值,所以可以把Date归入和StringNumber一样的处理方式中去;
  3. RegExp作为一个特殊的内置函数,其构造函数支持多种参数形式来创建一个正则对象,而我们在此只关注new RegExp('ab+c', 'i')这种方式,因为如果我们可以从目标正则对象对象上拿到我们想要的这两个参数值,即target.sourcetarget.flags:
const newReg = new RegExp(target.source, target.flags)
// lastIndex对于正则表达式来说是一个重要的标识,必须要拷贝到新的正则对象上
newReg.lastIndex = target.lastIndex
  1. Function,我们依旧保持小目标版本中的做法,即原样返回即可。原因有二:
    • 如果要拷贝一个函数对象,那么对源函数对象的修改可以反应到新函数对象上是符合大部分的编程正向思维的;
    • lodash也是这样处理的,经手了千万开发者的编程考验。
  2. MapSet也有自己的构造函数,那么我们继续const m = new Map(target)这样创建新的对象呢?答案是肯定的,但是不是深拷贝!因为目标对象target中可能会嵌套包含着【对象】,而我们在操作这些【对象】的同时还是会反应到新对象上。而上面那些可以使用构造函数传参的方式创建对象之所以没有这个问题,是因为它们传入的参数都是“原始值”,记住这一点很重要。因此,我们需要单独的方式去处理MapSet:

在ES6中,引入了一个个人认为很强力的概念,那就是Iterator即迭代器。迭代器是一个接口,为不同的数据结构提供统一的访问机制,任何数据结构只要部署Iterator接口就可以完成遍历操作。

最棒的就是MapSet原生就部署了Iterator接口,那我就可以使用forEach对它们进行遍历逐个、逐层深拷贝了哈哈哈哈...话说forEach好像和Iterator没半毛钱关系!这里只是拓宽下思路,我们也可以使用for...of遍历它们啊是不是~

那么对于MapSet对象的处理方式也基本设计好了:

// 使用对应的构造函数创建一个新的空的对象
const cloneTarget = new target.constructor()
const type = Object.prototype.toString.call(target)
// 遍历子元素逐个进行深拷贝
if (type === MAP_TAG) {
    target.forEach((value, key) => {
        cloneTarget.set(key, cloneDeep(value, map))
    })
}

if (type === SET_TAG) {
    target.forEach(value => {
        cloneTarget.add(cloneDeep(value, map))
    })
}

ES6中同时配备的还有WeakMapWeakSet,这里之所以不进行处理是由于这两个家伙有一个特性:弱引用,即垃圾回收机制不考虑WeakMapWeakSet对它们数据结构内对象的引用。数据内的对象随时可能失去对应的堆地址,也就没办法克隆的有效性和准确性了。

  1. ArrayObjectArguments,之所以把这三个数据类型放在一起是因为它们拥有共同的特性:键都是基本数据类型,值都需要单独赋值(尽管它们原型上一些方法也可以变相实现赋值,但是并不直观,或者太过复杂)。

在这里我们要充分考虑对Object对象上某些键的特殊处理,如键为Symbol类型时、键不可枚举时是否需要进行拷贝(简单起见,我们默认拷贝);而对于使用new XX(YY)方式创建的对象(其中XX是自定义构造函数或自定义的一个Class类),我们又该如何保证新对象和源对象之间具有一致的原型方法和原型属性呢?如何保证它们的原型链__proto__都指向相同的原型对象prototype呢? 针对这种情况,虽然听上去很复杂,但是做起来超简单:const obj = new target.constructor()

是不是还在担心上面的方法太过于简单不太靠谱?确实很简单,但是如果你了解new的底层操作就会放下心来了:

function myNew (fn, ...args) {
    // 使用构造函数的原型对象来提供新创建的对象的__proto__
    const obj = Object.create(fn.prototype)
    const result = fn.apply(obj, args)
    if (typeof result === 'object') {
        return result
    } else {
        return obj
    }
} 

是不是悬着的心落下来了?只要你知道了new会帮我们做这么多事情之后,以后再遇到原型链、原型对象什么的就会简单很多。

至此,我们针对ArrayObjectArguments的处理方式也应声而出了:

const cloneTarget = new target.constructor()
const type = Object.prototype.toString.call(target)
// 通用的遍历方法
const forEach = (array, callback) => {
    let index = -1
    const length = array.length
    while (++index < length) {
        callback(array[index], index)
    }
}

if ([ARRAY_TAG, OBJECT_TAG, ARGS_TAG].includes(type)) {
    // 把Arguments按照Object的处理方式进行处理
    const keys = [OBJECT_TAG, ARGS_TAG].includes(type) ? Reflect.ownKeys(target) : undefined
    forEach(keys || target, (value, key) => {
        if (keys) {
            key = value
        }
        // 逐个进行深拷贝
        cloneTarget[key] = cloneDeep(target[key], map)
    })
    return cloneTarget
}

Arguments的构造函数是Object(),而通过new Object()创建的对象是没有lengthcalleeSymbol(Symbol.iterator)的,因此我个人更偏向把这些属性原样拷贝到新对象上。

3.2.3 完整版全貌

经过各个击破、逐个厘清和分析,最终我们终于可以把各种场景整合到一起了形成一个最终版本:

function deepCopyX(target) {

    const STRING_TAG = '[object String]'
    const NUMBER_TAG = '[object Number]'
    const BOOLEAN_TAG = '[object Boolean]'

    const DATE_TAG = '[object Date]'
    const REGEXP_TAG = '[object RegExp]'
    const FUNCTION_TAG = '[object Function]'

    const MAP_TAG = '[object Map]'
    const SET_TAG = '[object Set]'
    const ARRAY_TAG = '[object Array]'
    const OBJECT_TAG = '[object Object]'
    const ARGS_TAG = '[object Arguments]'

    const deepTag = [MAP_TAG, SET_TAG, ARRAY_TAG, OBJECT_TAG, ARGS_TAG]

    function getType (target) {
        return Object.prototype.toString.call(target)
    }

    function isObject (target) {
        return target !== null && (typeof target === 'object' || typeof target === 'function')
    }

    function forEach (array, callback) {
        let index = -1
        const length = array.length
        while (++index < length) {
            callback(array[index], index)
        }
    }

    function cloneRegExp (target) {
        const result = new RegExp(target.source, target.flags)
        result.lastIndex = target.lastIndex
        return result
    }

    function cloneOtherType (target, type) {
        const Ctor = target.constructor
        switch (type) {
            case STRING_TAG:
            case NUMBER_TAG:
            case DATE_TAG:
                return new Ctor(target)
            case BOOLEAN_TAG:
                return new Boolean(Boolean.prototype.valueOf.call(target))
            case REGEXP_TAG:
                return cloneRegExp(target)
            case FUNCTION_TAG:
                return target
            default:
                return null
        }
    }


    function cloneDeep (target, map = new WeakMap()) {
        if (!isObject(target)) {
            return target
        }
        const type = getType(target)
        let cloneTarget = null
        // 不是需要进行深拷贝的元素,则直接进到 cloneOtherType 处理
        if (!deepTag.includes(type)) {
            return cloneTarget = cloneOtherType(target, type)
        }
        const Ctor = target.constructor
        cloneTarget = new Ctor()
        // 先校验缓存中是否存在,有则直接返回(避免循环依赖)
        if (map.get(target)) {
            return map.get(target)
        }
        map.set(target, cloneTarget)
        
        if (type === MAP_TAG) {
            target.forEach((value, key) => {
                cloneTarget.set(key, cloneDeep(value, map))
            })
            return cloneTarget
        }

        if (type === SET_TAG) {
            target.forEach(value => {
                cloneTarget.add(cloneDeep(value, map))
            })
            return cloneTarget
        }

        if ([ARRAY_TAG, OBJECT_TAG, ARGS_TAG].includes(type)) {
            const keys = [OBJECT_TAG, ARGS_TAG].includes(type) ? Reflect.ownKeys(target) : undefined
            forEach(keys || target, (value, key) => {
                if (keys) {
                    key = value
                }
                cloneTarget[key] = cloneDeep(target[key], map)
            })
            return cloneTarget
        }
    }
    return cloneDeep(target)
}

最后,你会发现最终版这么一大堆代码其实是我们在原来那个很小很小的【小目标版本】的基础上把我们逐个分析的数据类型场景一点点加上去的,而最终版只是这些各个场景之间的累加和磨合的结果。

这就是渐进式的魅力!

四、总结

整个深拷贝搞下来,花了很大力气,也收获了很多基础知识和学习方法(用好MDN是一个很好的巩固基础的方法),这也是【循序渐进】系列的由来。

做很多事情,只要一步一个脚印,循序渐进地解决你要解决的问题,完善你想要的东西,那么总有一天,一个不经意间你就会发现你已经走在了你要做的事情的前面!