用JavaScript手动实现深拷贝

前言

在编码的过程中,难免会遇到需要深拷贝一个对象的时候,这个时候,如果项目中没有深拷贝方法,而且也不允许使用三方库,例如lodash,就需要手动实现一个深拷贝功能了。

定义

那么,什么是深拷贝呢?与深拷贝对应的,还有浅拷贝。

浅拷贝:

创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝。如果属性是基本类型,拷贝的就是基本类型的值,如果属性是引用类型,拷贝的就是内存地址 ,所以如果其中一个对象改变了属性的值,就会影响到另一个对象。

深拷贝:

将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,且修改新对象不会影响原对象 

下面我们进入正题。

 乞丐版

在不使用第三方库的情况下,我们想要深拷贝一个对象,用的最多的就是下面这个方法。

JSON.parse(JSON.stringify());

这种写法非常简单,而且可以应对大部分的应用场景,但是它还是有很大缺陷的,比如拷贝其他引用类型、拷贝函数、循环引用等情况。

基础版

如果对象只有一层,那么我们很容易写出以下代码:

export function clone(target) {
  const cloneTarget = {}
  for (let key in target) {
    cloneTarget[key] = target[key]
  }
  return cloneTarget
}

但是,在实际开发中,我们要拷贝的对象往往不止一层,可能有很多层,这个时候,聪明的你肯定想到了,用递归!于是,我们又能写出如下代码:

target是否是对象?

——是:用递归进行拷贝

——否:直接返回

export function cloneDeep(target) {
  if (isObject(target)) {
    const cloneTarget = {}
    for (let key in target) {
      cloneTarget[key] = cloneDeep(target[key])
    }
    return cloneTarget
  } else {
    return target
  }
}

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

于是,我们用下面的对象测试一下:

const target = {
    a: 1,
    b: [ 1, 2, { x: 1, y: [1, 2, 3] }, 4 ],
    c: {
      apple: 1,
      orange: 2,
      verb: {
        angry: 1,
        smile: 2,
      },
    },
  }
  const result = deepClone(target)
  console.log(result)

可以看到:

 用JavaScript手动实现深拷贝_第1张图片

本来应该是数组的target.b,现在变成了对象。于是乎,你马上发现了上面代码的问题,没有对数据进行判断。

考虑数组

于是,你很快又进行了改写,判断一下target是否为数组:

target是否为数组?

——是:cloneTarget初始化为[]

——否:cloneTarget初始化为{}

export function cloneDeep(target) {
  if (isObject(target)) {
    const isArray = Array.isArray(target)
    const cloneTarget = isArray ? [] : {}
    for (let key in target) {
      cloneTarget[key] = cloneDeep(target[key])
    }
    return cloneTarget
  } else {
    return target
  }
}

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

再次运行,终于正确地拷贝了数组:

用JavaScript手动实现深拷贝_第2张图片

循环引用

我们执行一下下面的测试用例:

const target = {
    a: 1,
    b: [ 1, 2, { x: 1, y: [1, 2, 3] }, 4 ],
    c: {
      apple: 1,
      orange: 2,
      verb: {
        angry: 1,
        smile: 2,
      },
    },
  }
  target.d = target
  const result = cloneDeep(target)
  console.log(result)

会发现,浏览器报错了:

用JavaScript手动实现深拷贝_第3张图片

 很明显,因为递归进入死循环导致栈内存溢出了。

原因就是上面的对象存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况。

解决循环引用问题,我们可以额外开辟一个存储空间,来存储当前对象和拷贝对象的对应关系,当需要拷贝当前对象时,先去存储空间中找,有没有拷贝过这个对象,如果有的话直接返回,如果没有的话继续拷贝,这样就巧妙化解的循环引用的问题。

这个存储空间,需要可以存储 key-value 形式的数据,且 key 可以是一个引用类型,我们可以选择 Map 这种数据结构。

map 中是否已经拷贝过的对象?

——有,直接返回该对象

——没有,将当前对象作为 key,克隆对象作为 value 储存起来,继续拷贝

根据上面的思路,我们可以写出如下代码:

export function cloneDeep(target, map = new Map()) {
  if (isObject(target)) {
    const isArray = Array.isArray(target)
    const cloneTarget = isArray ? [] : {}
    if (map.has(target)) {
      return map.get(target)
    }
    map.set(target, cloneTarget)
    for (let key in target) {
      cloneTarget[key] = cloneDeep(target[key], map)
    }
    return cloneTarget
  } else {
    return target
  }
}

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

接下来,我们可以使用 WeakMap 代替 Map

export function cloneDeep(target, map = new WeakMap()) {
  // ...
}

使用 WeakMap 有什么好处呢?我们先来看看 WeakMap 的作用:

WeakMap 对象是一组键/值对的集合,其中的键是弱引用的。其键必须是对象,而值可以是任意的。

什么是弱引用?

在计算机程序设计中,弱引用与强引用相对,是指不能确保其引用的对象不会被垃圾回收器回收的引用。 一个对象若只被弱引用所引用,则被认为是不可访问(或弱可访问)的,并因此可能在任何时刻被回收。

 我们默认创建一个对象:const obj = {},就默认创建了一个强引用的对象,我们只有手动将obj = null,它才会被垃圾回收机制进行回收,如果是弱引用对象,垃圾回收机制会自动帮我们回收。

举个例子,如果我们使用 Map 的话,那么对象间是存在强引用关系的:

let obj = { name : 'MZHC'}
const target = new Map();
target.set(obj,'code秘密花园');
obj = null;

虽然我们手动将 obj,进行释放,然是 target 依然对 obj 存在强引用关系,所以这部分内存依然无法被释放。

再来看 WeakMap

let obj = { name : 'MZHC'}
const target = new WeakMap();
target.set(obj,'code秘密花园');
obj = null;

如果是 WeakMap 的话,target 和 obj 存在的就是弱引用关系,当下一次垃圾回收机制执行时,这块内存就会被释放掉。

设想一下,如果我们要拷贝的对象非常庞大时,使用 Map 会对内存造成非常大的额外消耗,而且我们需要手动清除 Map 的属性才能释放这块内存,而 WeakMap 会帮我们巧妙化解这个问题。

性能优化

上面的代码中,我们使用了 for...in 这种方式,实际上, for...in 的遍历效率是比较低的,如果对象层数很多,则效率会大打折扣,我们采用 for 循环来改写一下:

export function cloneDeep(target, map = new WeakMap()) {
  if (isObject(target)) {
    const isArray = Array.isArray(target)
    const cloneTarget = isArray ? [] : {}
    if (map.has(target)) {
      return map.get(target)
    }
    map.set(target, cloneTarget)
    if (isArray) {
      forEach(target, (item, index) => {
        cloneTarget[index] = cloneDeep(item, map)
      })
    } else {
      const keys = Object.keys(target)
      forEach(keys, key => {
        cloneTarget[key] = cloneDeep(target[key], map)
      })
    }
    return cloneTarget
  } else {
    return target
  }
}

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

function forEach(array, fn) {
  // 这里需要使用len记录一下数组的长度
  for (let i = 0, len = array.length; i < len; i++) {
    fn(array[i], i)
  }
}

这里采用 while 循环也是可以的,lodash 的深拷贝这里是采用while循环实现的,效率上其实和for循环差不多。

用JavaScript手动实现深拷贝_第4张图片

其他类型

在上面的代码中,我们其实只考虑了普通的 object 和 array 两种数据类型,实际上所有的引用类型远远不止这两个,还有很多,下面我们先尝试获取对象准确的类型。

合理判断引用类型

首先,判断是否为引用类型,我们还需要考虑 function 和 null 两种特殊的数据类型:

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

获取数据类型 

我们可以使用 toString 来获取准确的引用类型。

每一个引用类型都有 toString 方法,默认情况下,toString()方法被每个 Object 对象继承。如果此方法在自定义对象中未被覆盖,toString() 返回 "[object type]",其中 type 是对象的类型

注意,上面提到了如果此方法在自定义对象中未被覆盖,toString才会达到预想的效果,事实上,大部分引用类型比如Array、Date、RegExp等都重写了toString方法。

我们可以直接调用Object原型上未被覆盖的toString()方法,使用call来改变this指向来达到我们想要的效果。

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

下面我们抽离出一些常用的数据类型以便后面使用:

// 可迭代类型
const mapTag = '[object Map]'
const setTag = '[object Set]'
const arrayTag = '[object Array]'
const objectTag = '[object Object]'
const argsTag = '[object Arguments]'

// 不可迭代类型
const boolTag = '[object Boolean]'
const dateTag = '[object Date]'
const errorTag = '[object Error]'
const numberTag = '[object Number]'
const regexpTag = '[object RegExp]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const funcTag = '[object Function]'
const weakMapTag = '[object WeakMap]'

在上面的集中类型中,我们简单将他们分为两类:

  • 可以继续遍历的类型
  • 不可以继续遍历的类型

我们分别为它们做不同的拷贝。

可继续遍历类型

上面我们已经考虑的objectarray都属于可以继续遍历的类型,因为它们内存都还可以存储其他数据类型的数据,另外还有MapSet、argument等都是可以继续遍历的类型,这里我们只考虑这几种,如果你有兴趣可以继续探索其他类型。

对于objectargument,我们可以使用Object.create(Object.getPrototypeOf(Object(target)))的方法去创建初始化的克隆对象,对于mapset,我们可以使用new target.constructor的方式去创建初始化的克隆对象。

function getInit(target, tag) {
  const Ctor = target.constructor
  switch (tag) {
    case objectTag:
    case argsTag:
      return objectCreate(Object.getPrototypeOf(Object(target)))
    case arrayTag:
      return initCloneArray(target)
    case mapTag:
    case setTag:
      return new Ctor
  }
}

对于objectargument 如果直接使用new的方式,这样会调用一次构造函数,有一定的性能浪费,所以使用Object.create的方式,这样会生成一个空对象,不需要调用构造函数,Object.create会把target的原型自动关联到这个空对象上面。

对于mapset,不能直接使用Object.create的方式,因为这样生成的map或者set对象会丢失属性,需要使用new来创建初始化的克隆对象。

const map = new Map();
const set = new Set();

const map1 = Object.create(Object.getPrototypeOf(Object(map)));
const set1 = Object.create(Object.getPrototypeOf(Object(set)));

const map2 = new map.constructor();
const set2 = new set.constructor();

console.log(map1);
console.log(set1);

console.log(map2);
console.log(set2);

 用JavaScript手动实现深拷贝_第5张图片

 这个时候如果使用map1.set('name', 'mzhc'),还会报错:

用JavaScript手动实现深拷贝_第6张图片

对于数组,需要考虑正则表达式的exec函数执行后产生的数组,exec函数匹配到结果后返回的数组中多了indexinput两个属性:

const reg = /\d(mzhc)\d/;
const result = reg.exec('520mzhc1314');
console.log(result);

用JavaScript手动实现深拷贝_第7张图片 因此我们需要对正则表达式exec方法返回的数组进行克隆初始化:

function initCloneArray(array) {
  const length = array.length
  const result = new Array(length)

  // Add properties assigned by `RegExp#exec`.
  if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
    result.index = array.index
    result.input = array.input
  }
  return result
}

 不可继续遍历的类型

其他剩余的类型我们把它们统一归类成不可处理的数据类型,我们依次进行处理:

BooleanNumberStringDate这几种类型我们都可以直接用构造函数和原始数据创建一个新对象:

function cloneNonIterative(target, tag) {
  const Ctor = target.constructor
  switch (tag) {
    case numberTag:
    case stringTag:
      return new Ctor(target)
    case boolTag:
    case dateTag:
      return new Ctor(+target)
    case regexpTag:
      return cloneReg(target)
    case symbolTag:
      return cloneSymbol(target)
  }
}

克隆Symbol:

function cloneSymbol(symbol) {
  return Object(Symbol.prototype.valueOf.call(symbol))
}

 克隆正则:

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

不可拷贝类型 

lodash源码中,如果是error、functionWeakMap类型,首先会判断一下如果不存在父对象,就直接返回{},如果存在父对象,这直接返回父对象中该值的引用。

用JavaScript手动实现深拷贝_第8张图片

这里,我们也采用相同的方式:

const cloneableTags = {}
cloneableTags[funcTag] = cloneableTags[errorTag] = cloneableTags[weakMapTag] = false

export function cloneDeep(target) {
  return baseClone(target)
}

function baseClone(target, map = new WeakMap(), parentObj) {
  /* ... */
  let cloneTarget = {}
  /* ... */
  if (!cloneableTags[tag]) {
    return parentObj ? target : {}
  }
  /* ... */
  return cloneTarget
}

 完整代码

/**
 * 深拷贝
 */

const objectProto = Object.prototype
const objectToString = objectProto.toString
const objectCreate = Object.create
const getPrototype = Object.getPrototypeOf
const hasOwnProperty = objectProto.hasOwnProperty
 
// 可迭代类型
const mapTag = '[object Map]'
const setTag = '[object Set]'
const arrayTag = '[object Array]'
const objectTag = '[object Object]'
const argsTag = '[object Arguments]'
 
// 不可迭代类型
const boolTag = '[object Boolean]'
const dateTag = '[object Date]'
const errorTag = '[object Error]'
const numberTag = '[object Number]'
const regexpTag = '[object RegExp]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const funcTag = '[object Function]'
const weakMapTag = '[object WeakMap]'
 
// 可迭代类型
const iterableTags = {}
iterableTags[mapTag] = iterableTags[setTag] = 
iterableTags[arrayTag] = iterableTags[objectTag] = 
iterableTags[argsTag] = true
 
// 可拷贝类型
const cloneableTags = {}
cloneableTags[mapTag] = cloneableTags[setTag] = 
cloneableTags[arrayTag] = cloneableTags[objectTag] = 
cloneableTags[argsTag] = cloneableTags[boolTag] = 
cloneableTags[dateTag] = cloneableTags[numberTag] = 
cloneableTags[regexpTag] = cloneableTags[stringTag] = 
cloneableTags[symbolTag] = true
 
// 不可拷贝类型
cloneableTags[funcTag] = cloneableTags[errorTag] = cloneableTags[weakMapTag] = false
 
 /**
  * 深拷贝入口函数
  * @param target 要进行深拷贝的目标对象
  * @returns 深拷贝完成后的对象
  */
 export function cloneDeep(target) {
   return baseClone(target)
 }
 
 /**
  * 
  * @param target 要进行深拷贝的目标对象
  * @param {WeakMap} map 储存已经拷贝过的对象,
  * @param parentObj `target`的父对象
  * @returns 
  */
function baseClone(target, map = new WeakMap(), parentObj) {
   // 原始类型
   if (!isObject(target)) {
     return target
   }
 
   // 获取类型
   const tag = getTag(target)
 
   // 函数、Error和WeakMap
   if (!cloneableTags[tag]) {
     return parentObj ? target : {}
   }
 
   // 初始化
   let cloneTarget = {}
   if (iterableTags[tag]) {
     cloneTarget = getInit(target, tag)
   } else {
     return cloneNonIterative(target, tag)
   }
 
   // 防止循环引用引起的栈内存溢出
   if (map.has(target)) {
     return map.get(target)
   }
   map.set(target, cloneTarget)
 
   // 克隆set
   if (tag === setTag) {
    target.forEach((value) => {
      cloneTarget.add(baseClone(value, map, target))
     })
     return cloneTarget
   }
 
   // 克隆map
   if (tag === mapTag) {
    target.forEach((value, key) => {
      cloneTarget.set(key, baseClone(value, map, target))
    })
    return cloneTarget
   }
 
   // 克隆数组
   if (tag === arrayTag) {
     arrayEach(target, (item, index) => {
      cloneTarget[index] = baseClone(item, map, target)
     })
     return cloneTarget
   }
 
   // 克隆纯对象或者参数数组
   if (tag === objectTag || tag === argsTag) {
     const keys = Object.keys(target)
     arrayEach(keys, key => {
      cloneTarget[key] = baseClone(target[key], map, target)
     })
     return cloneTarget
   }
   return cloneTarget
 }
 
 /**
  * 检查是否是对象
  * @param obj 
  * @returns 
  */
 function isObject(obj) {
   return obj !== null && (typeof obj === 'object' || typeof obj === 'function')
 }
 
 /**
  * 使用`Object.prototype.toString`将一个值转换为string
  * @param target 需要转换的值
  * @returns {string} 返回转换后的值
  */
 function getTag(target) {
   return objectToString.call(target)
 }
 
 /**
  * 根据被克隆对象的类型初始化克隆对象
  * @param target 被克隆的目标对象
  * @param tag 被克隆的目标对象的类型
  * @returns 初始化后的克隆对象
  */
 function getInit(target, tag) {
   const Ctor = target.constructor
   switch (tag) {
     case objectTag:
     case argsTag:
       return objectCreate(getPrototype(Object(target)))
     case arrayTag:
       return initCloneArray(target)
     case mapTag:
     case setTag:
       return new Ctor
   }
 }
 
 /**
  * 初始化数组克隆
  * @param array 被克隆的数组
  * @returns 初始化后的克隆数组
  */
 function initCloneArray(array) {
   const length = array.length
   const result = new Array(length)
 
   // Add properties assigned by `RegExp#exec`.
   if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
     result.index = array.index
     result.input = array.input
   }
   return result
 }
 
 /**
  * 遍历数组的工具函数
  * @param {Array} array 遍历的目标数组
  * @param {Function} fn 每次迭代调用的函数
  */
 function arrayEach(array, fn) {
   for (let i = 0, len = array.length; i < len; i++) {
     fn(array[i], i)
   }
 }
 
 /**
  * 克隆不可迭代类型
  * @param target 
  * @param tag 
  * @returns 
  */
 function cloneNonIterative(target, tag) {
   const Ctor = target.constructor
   switch (tag) {
     case numberTag:
     case stringTag:
       return new Ctor(target)
     case boolTag:
     case dateTag:
       return new Ctor(+target)
     case regexpTag:
       return cloneReg(target)
     case symbolTag:
       return cloneSymbol(target)
   }
 }
 
 /**
  * 克隆正则表达式
  * @param target 被克隆的正则表达式
  * @returns 克隆后的正则表达式
  */
 function cloneReg(target) {
   const reFlags = /\w*$/
   const result = new target.constructor(target.source, reFlags.exec(target))
   result.lastIndex = target.lastIndex
   return result
 }
 
 /**
  * 克隆Symbol
  * @param symbol 被克隆的symbol
  * @returns 克隆后的symbol
  */
 function cloneSymbol(symbol) {
   return Object(Symbol.prototype.valueOf.call(symbol))
 }

参考

lodash

如何写出一个惊艳面试官的深拷贝? - 掘金

你可能感兴趣的:(JavaScript,javascript)