首页
学习
活动
专区
工具
TVP
发布
社区首页 >专栏 >JS 原生方法原理探究(九):如何手写实现浅拷贝和深拷贝?

JS 原生方法原理探究(九):如何手写实现浅拷贝和深拷贝?

作者头像
Chor
发布2021-12-24 14:13:05
1K0
发布2021-12-24 14:13:05
举报
文章被收录于专栏:前端之旅前端之旅

这是JS 原生方法原理探究系列的第九篇文章。本文会介绍如何手写实现浅拷贝和深拷贝。

实现浅拷贝

什么是浅拷贝?

对原对象进行浅拷贝,会生成一个和它“一样”的新对象。但是这种拷贝只会拷贝原对象第一层的基本类型属性,引用类型属性仍然和原对象共享同一个。

用一个简单的例子来理解:

let obj1 = {
    a: 'Jack',
    b: {
        c: 1
    }
}
let obj2 = Object.assign({},obj1)
obj1.a = 'Tom'
obj1.b.c = 2
console.log(obj1.a)      // 'Tom'
console.log(obj2.a)      // 'Jack'   
console.log(obj1.b.c)    // 2
console.log(obj2.b.c)    // 2

可以看到,因为新对象拷贝了原对象第一层的基本类型属性,所以修改 obj1.a 的值并不会影响 obj2.a 的值;同时,因为新对象和原对象共享同一个第一层的引用类型属性,所以修改 obj1.b 对象也会影响到 obj2.b 对象。

如何实现浅拷贝?

JS 中常见的浅拷贝方法有 Object.assign()... 展开运算符以及数组的 slice 方法。但是如果我们要自己实现一个浅拷贝,应该怎么做呢?

其实也很简单,因为浅拷贝只作用在第一层,所以只需要遍历原对象,将它的每一个成员添加到新对象上就行。这里说的原对象指的是对象字面量、数组、类数组对象、Set 以及 Map 这些可以遍历的对象。对于其它的不可遍历对象以及基本类型的值直接将其返回即可。

代码如下:

function getType(obj){
    return Object.prototype.toSrting.call(obj).slice(8,-1)
}
// 可以遍历的数据类型
let iterableList = ['Object','Array','Arguments','Set','Map']
// 浅拷贝
function shallowCopy(obj){
    let type = getType(obj)
    if(!iterableList.includes(type)) return obj
    let res = new obj.constructor()
    // 如果是 Set 或者 Map
    obj.forEach((value,key) => {
        type === 'Set' ? res.add(value) : res.set(key,value)
    })        
    // 如果是对象字面量、类数组对象或者数组
    Reflect.ownKeys(obj).forEach(key => {
        res[key] = obj[key]
    })        
    return res
}

一些要点:

  • 初始化新对象 res:获取原对象 obj 的构造函数,用于创建一个和原对象同类型的实例
  • 这里遍历对象或者数组有三种方式,第一种是使用 Reflect.ownKeys() 获取自身所有属性(无论是否可以枚举),第二种是使用 for……in + hasOwnProperty() 获取自身所有可枚举属性,第三种是使用 Object.keys() 一次性获取自身所有可枚举属性

实现深拷贝

什么是深拷贝?

对原对象进行深拷贝,会生成一个和它“一样”的新对象。深拷贝会拷贝原对象所有层级上的基本类型属性和引用类型属性。还是通过一个例子理解一下:

let obj1 = {
    a: 'Jack',
    b: {
        c: 1
    }
}
let obj2 = JSON.parse(JSON.stringify(obj1))
obj1.a = 'Tom'
obj1.b.c = 2
console.log(obj1.a)      // 'Tom'
console.log(obj2.a)      // 'Jack'   
console.log(obj1.b.c)    // 2
console.log(obj2.b.c)    // 1

可以看到,无论对 obj1 作出什么修改,都不会影响到 obj2,反之亦然,两者是完全独立的。

如何实现深拷贝?

常见的实现深拷贝的方式是 JSON.parse(JSON.stringify())。它可以应付一般的深拷贝场景,但是也存在着不少问题,这些问题基本都是出现在序列化的环节。

Date 类型的属性经过深拷贝之后会变成字符串:

正则类型和错误类型的属性经过深拷贝之后会变成空对象:

如果 key 的 value 是函数类型、 undefined 类型、Symbol 类型,则经过深拷贝之后会丢失:

如果 key 是 Symbol 类型,则经过深拷贝之后会丢失:

NaNInfinity-Infinity 经过深拷贝之后会变成 null

可能导致 constructor 指向丢失:

JSON.stringify() 只能序列化对象自身的可枚举属性,而 constructor 并不是实例对象自身的属性,而是实例的原型对象的属性。因此在序列化实例对象 obj1 的时候,实际上并不会去处理 constructor 的指向,这样,它的指向就成为了默认的 Object。

存在循环引用的问题

上面的 obj 对象存在循环引用,也就是说,它是一个环状结构(非树状)的对象,这样的对象是无法转化为 JSON 的,因此会报错:can’t convert circular structure to JSON。

此外,我们也可以考虑使用 Lodash 提供的深拷贝方法。不过,如果要自己实现深拷贝,应该怎么做呢?我们一步一步来看。

基础版本

深拷贝的核心其实就是浅拷贝 + 递归,不管层级嵌套有多深,我们总可以通过不断的递归到达对象的最里层,完成基本类型属性以及不可遍历的引用类型属性的拷贝。

下面是最基础的深拷贝版本:

function deepClone(target){
    if(typeof target === 'object'){
        let cloneTarget = Array.isArray(target) ? []:{}
        Reflect.ownKeys(target).forEach(key => {
            cloneTarget[key] = deepClone(target[key])
        })
        return cloneTarget
    } else {
        return target
    }
}

这里只考虑数组和对象字面量的情况。根据初始传入的 target 是一个对象字面量还是数组,决定最终返回的 cloneTarget 是对象还是数组。接着遍历 target 的每一个自身属性,递归调用 deepClone,如果属性已经是基本类型,则直接返回;如果还是对象或者数组,就和初始的 target 进行一样的处理。最后,把处理好的结果一一添加给 cloneTarget

解决循环引用导致的爆栈问题

但是,这里存在一个循环引用的问题。

假设深拷贝的目标是下面这样的对象:

let obj = {}
obj.a = obj

这样的对象,结构中存在回环,即存在循环引用:obj 通过属性 a 引用了自身,而 a 也一定会有一个属性 a 再次引用自身 …… 最终会导致 obj 无限嵌套下去。而深拷贝的过程中因为用到了递归,无限嵌套的对象就会导致无限的递归,不断地压栈最终会导致堆栈溢出。

如何解决循环引用带来的爆栈问题呢?其实也很简单,只需要给递归创建一个出口即可。对于初次传入的对象或者数组,会用一个 WeakMap 记录当前目标和拷贝结果的映射关系,当检测到再次传入相同的目标时,就不再进行重复的拷贝,而是直接从 WeakMap 中取出它对应的拷贝结果返回。

这里的“返回”其实就给递归创建了一个出口,因此不会无限递归下去,也就不会爆栈了。

因此改进后的代码如下:

function deepClone(target,map = new WeakMap()){
    if(typeof target === 'object'){
        let cloneTarget = Array.isArray(target) ? []:{}
        
        // 处理循环引用的问题
        if(map.has(target)) return map.get(target)
        map.set(target,cloneTarget)
        Reflect.ownKeys(target).forEach(key => {
            cloneTarget[key] = deepClone(target[key],map)
        })
        return cloneTarget
    } else {
        return target
    }
}
处理其它数据类型

始终记住,我们要处理的是三类目标:

  • 基本数据类型:直接返回即可
  • 可以继续遍历的引用数据类型:除了上面已经处理的对象字面量和数组,还有类数组对象、Set、Map 。它们都属于可以继续遍历的、可能存在嵌套的引用类型,因此在处理的时候就需要递归
  • 不能继续遍历的引用数据类型:包括函数、错误对象、日期对象、正则对象、基本类型的包装对象(String、Boolean、Symbol、Number)等。它们是不能继续遍历的,或者说是“没有层级嵌套”的,因此再处理的时候需要拷贝一份一样的副本返回
1)类型判断函数

为了更好地判断是引用数据类型还是基本数据类型,可以使用一个 isObject 函数:

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

为了更准确地判断具体是什么数据类型,可以使用一个 getType 函数:

function getType(o){
    return Object.prototype.toString.call(o).slice(8,-1)
}
// getType(1)      "Number"
// getType(null)   "Null"
2)初始化函数

之前深拷贝对象字面量或者数组的时候,首先会将最终返回的结果 cloneTarget 初始化为 [] 或者 {}。同样地,对于 Set、Map 以及类数组对象,也需要进行相同的操作,所以最好用一个函数统一实现 cloneTarget 的初始化。

function initCloneTarget(target){
    return new target.constructor()
}

通过 target.constructor 可以获得传进来的实例的构造函数,利用这个构造函数新创建一个同类型的实例并返回。

3)处理可以继续遍历的引用类型:类数组对象、Set、Map

类数组对象,其实和数组以及对象字面量的形式差不多,所以可以一块处理;处理 Set 和 Map 的流程也基本一样,但是不能采用直接赋值的方式,而要使用 add 方法或者 set 方法,所以稍微改进一下。

代码如下:

function deepClone(target,map = new WeakMap()){
    // 如果是基本类型,直接返回即可
    if(!isObject(target))    return target   
    
    // 初始化返回结果
    let type = getType(target)
    let cloneTarget = initCloneTarget(target)
    
    // 处理循环引用
    if(map.has(target))      return map.get(target)
    map.set(target,cloneTarget)
    
    // 处理 Set    
    if(type === 'Set'){
        target.forEach(value => {
            cloneTarget.add(deepClone(value,map))
        })
    }
    // 处理 Map
    else if(type === 'Map'){
        target.forEach((value,key) => {
            cloneTarget.set(key,deepClone(value,map))
        })
    }
    
    // 处理对象字面量、数组、类数组对象
    else if(type === 'Object' || type === 'Array' || type === 'Arguments'){
        Reflect.ownKeys(target).forEach(key => {
            cloneTarget[key] = deepClone(target[key],map)
        })
    }
    return cloneTarget
}
4)处理不能继续遍历的引用类型

现在来处理不能继续遍历的引用类型。对于这样的目标,我们不能像基本数据类型那样直接返回,因为它们本质上也是对象,直接返回将返回相同的引用,并没有达到拷贝的目的。正确的做法,应该是拷贝一份副本再返回。

如何拷贝呢?这里又分为两种情况。其中,String、Boolean、Number、错误对象、日期对象都可以通过 new 的方式返回一个实例副本;而 Symbol、函数、正则对象的拷贝则无法通过简单的 new 拷贝副本,需要单独处理。

拷贝 Symbol

function cloneSymbol(target){
	return Object(target.valueOf())    
    // 或者
    return Object(Symbol.prototype.valueOf.call(target))
    // 或者
    return Object(Symbol(target.description))
}

PS:这里的 target 是 Symbol 基本类型的包装类型,调用 valueOf 可以获得它对应的拆箱结果,再把这个拆箱结果传给 Object,就可以构造原包装类型的副本了;为了保险起见,可以通过 Symbol 的原型调用 valueOf;可以通过 .description 获得 symbol 的描述符,基于此也可以构造原包装类型的副本。

拷贝正则对象(参考 lodash 的做法)

function cloneReg(target) {
    const reFlags = /\w*$/;
    const result = new RegExp(target.source, reFlags.exec(target));
    result.lastIndex = target.lastIndex;
    return result;
}

拷贝函数(实际上函数没有必要拷贝)

function cloneFunction(target){
    return eval(`(${target})`)
    // 或者
    return new Function(`return (${target})()`)
}

PS:传给 new Function 的参数声明了新创建的函数实例的函数体内容

接下来,用一个 directCloneTarget 函数处理以上所有情况:

function directCloneTarget(target,type){
    let _constructor = target.constructor
	switch(type){
		case 'String':
        case 'Boolean':
        case 'Number':
        case 'Error':
        case 'Date':
        	return new _constructor(target.valueOf())
            // 或者
            return new Object(_constructor.prototype.valueOf.call(target))
        case 'RegExp':
            return cloneReg(target)        
        case 'Symbol':
            return cloneSymbol(target)        
        case 'Function':            
            return cloneFunction(target) 
        default:            
            return null               
    }             
}

PS:注意这里有一些坑。

  • 为什么使用 return new _constructor(target.valueOf()) 而不是 return new _constructor(target) 呢?因为如果传进来的 targetnew Boolean(false),那么最终返回的实际上是 new Boolean(new Boolean(false)) ,由于参数并非空对象,因此它的值对应的不是期望的 false,而是 true。所以,最好使用 valueOf 获得包装类型对应的真实值。
  • 也可以不使用基本类型对应的构造函数 _constructor,而是直接 new Object(target.valueOf()) 对基本类型进行包装
  • 考虑到 valueOf 可能被重写,为了保险起见,可以通过基本类型对应的构造函数 _constructor 去调用 valueOf 方法
最终版本

最后的代码如下:

let objectToInit = ['Object','Array','Set','Map','Arguments']

function deepClone(target,map = new WeakMap()){
    if(!isObject(target))    return target    
    // 初始化
    let type = getType(target)
    let cloneTarget
    if(objectToInit.includes(type)){
        cloneTarget = initCloneTarget(target)
    } else {
        return directCloneTarget(target,type)
    }
 	// 解决循环引用   
    if(map.has(target))       return map.get(target)
    map.set(target,cloneTarget)
    // 拷贝 Set
    if(type === 'Set'){
        target.forEach(value => {
            cloneTarget.add(deepClone(value,map))
        })
    }
    // 拷贝 Map
    else if(type === 'Map'){
        target.forEach((value,key) => {
            cloneTarget.set(key,deepClone(value,map))
        })
    }
    // 拷贝对象字面量、数组、类数组对象
    else if(type === 'Object' || type === 'Array' || type === 'Arguments'){
        Reflect.ownKeys(target).forEach(key => {
            cloneTarget[key] = deepClone(target[key],map)
        })
    }
    return cloneTarget
}
本文参与 腾讯云自媒体分享计划,分享自作者个人站点/博客。
原始发表:2021-07-09,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 作者个人站点/博客 前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体分享计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 实现浅拷贝
    • 什么是浅拷贝?
      • 如何实现浅拷贝?
      • 实现深拷贝
        • 什么是深拷贝?
          • 如何实现深拷贝?
            • 基础版本
            • 解决循环引用导致的爆栈问题
            • 处理其它数据类型
            • 最终版本
        领券
        问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档