通过lodash的cloneDeep学习深拷贝

3,392 阅读5分钟

简介

lodash 版本 5.0 。 主要分析如何深克隆,代码为简化后核心代码。基本类型,自定义clone,错误处理不考虑。

区分对象类型

通过 Object.prototype.toString.call 来获取详细类型 tag 。 数组会通过 Array.isArray

const toString = Object.prototype.toString
function getTag(value) {
  if (value == null) {
    return value === undefined ? '[object Undefined]' : '[object Null]'
  }
  return toString.call(value)
}

克隆引用类型

在拷贝对象时,有2种方法:

  • 构造函数
    • new targetObject.constructor()
      • 优点:
        • 构造函数生成对象,保证属性完整。
          • 在拷贝内置对象,例如 ArrayMap 等,有无法创建的JS内部属性。只能通过构造函数生成。
        • 原型链正确。
      • 缺点:
        • 执行一次函数,性能开销。
  • 原型
    • Object.create(Object.getPrototypeOf(targetObject))
      • 优点:
        • 性能开销小。
        • 原型链正确。
      • 缺点:
        • 无法拷贝JS内部属性。

克隆数组

数组的 length 是特殊的JS内部属性,必须用构造函数创建。 特殊情况:regex.exec() 的返回值数组。 注意:为什么不直接用 new Array(length) ?

  • 因为有可能是一个继承了 Arrayclass 创建的数组。
    • 例如:Vue2 中响应式原理,所创建的数组。
      • class MyArray extends Array {},代理一些数组方法。
function initCloneArray(array) {
  const { length } = array
  const result = new array.constructor(length)
  // regex.exec()的返回值数组, 需要特殊处理index input
  if (length && typeof array[0] === 'string' && hasOwnProperty.call(array, 'index')) {
    result.index = array.index
    result.input = array.input
  }
  return result
}

克隆普通对象 和 Arguments

要注意:constructor 和 对象原型 都可被修改,要进行判断。

function initCloneObject(object) {
  //constructor是函数,原型不是它本身,则原型创建
  return (typeof object.constructor === 'function' && !isPrototype(object))
    ? Object.create(Object.getPrototypeOf(object))
    : {} // 特殊情况,直接创建对象 原型链丢失
}

还有一个特殊情况:chrome 已经支持 class 私有属性,方法。通过原型克隆,私有属性无法创建,调用时报错。

// 在支持原生私有属性的浏览器中调用。  babel转换后的无此问题。但是那不算原生私有属性。
class Test {
  #a=1
  print(){
    console.log(this.#a)
  }
}
let o1 = new Test()
o1.print()// 1

let o2 = initCloneObject(o1)
o2.print()//报错没有私有属性。 Uncaught TypeError: Cannot read private member #a from an object whose class did not declare it

通过构造函数创建,则无此问题。

let o3 = new o1.constructor()
o3.print() // 1

lodash 用原型创建,是考虑性能问题(减少构造函数执行)。 ​

此特殊情况 lodash 还没处理,目前还是 BUG 状态。

克隆RegExp对象

取出正则,正则表达式标志。

function cloneRegExp(regexp) {
  const result = new regexp.constructor(regexp.source, reFlags.exec(regexp))
  // g标志时有用,开始下一个匹配的起始索引值
  result.lastIndex = regexp.lastIndex
  return result
}

克隆Symbol对象

Symbol.prototype.valueOf 返回原始值。 在调用 Object ,对于基本类型的值,会构造其保证类型的对象。

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

其余引用类型

不一一分析了,看代码,原理都是构造函数。

function initCloneByTag(object, tag, isDeep) {
  const Ctor = object.constructor
  switch (tag) {
    case arrayBufferTag:
      return cloneArrayBuffer(object)

    case boolTag:
    case dateTag:
      return new Ctor(+object)

    case dataViewTag:
      return cloneDataView(object, isDeep)

    case float32Tag: case float64Tag:
    case int8Tag: case int16Tag: case int32Tag:
    case uint8Tag: case uint8ClampedTag: case uint16Tag: case uint32Tag:
      return cloneTypedArray(object, isDeep)

    case mapTag:
      return new Ctor

    case numberTag:
    case stringTag:
      return new Ctor(object)

    case setTag:
      return new Ctor
  }
}
function cloneArrayBuffer(arrayBuffer) {
  const result = new arrayBuffer.constructor(arrayBuffer.byteLength)
  new Uint8Array(result).set(new Uint8Array(arrayBuffer))
  return result
}
function cloneDataView(dataView, isDeep) {
  const buffer = cloneArrayBuffer(dataView.buffer)
  return new dataView.constructor(buffer, dataView.byteOffset, dataView.byteLength)
}
function cloneTypedArray(typedArray, isDeep) {
  const buffer = cloneArrayBuffer(typedArray.buffer) 
  return new typedArray.constructor(buffer, typedArray.byteOffset, typedArray.length)
}


递归克隆

递归克隆,数组和对象

数组是遍历数组项。 对象是遍历属性。

获取属性

获取对象属性。**注意:**类数组对象,symbol 作为属性key

function getAllKeys(object) {
  // 取keys
  const result = keys(object)
  if (!Array.isArray(object)) {
    // 处理特殊性的symbol 属性
    result.push(...getSymbols(object))
  }
  return result
}
//对象则用Object.keys
//类数组对象,eg: arguments 则for in 取key
//都返回keys数组
function keys(object) {
  return isArrayLike(object)
    ? arrayLikeKeys(object)
    : Object.keys(Object(object))
}
// 属性是否可枚举
const propertyIsEnumerable = Object.prototype.propertyIsEnumerable
// 对象自身的所有 Symbol 属性的数组。
const nativeGetSymbols = Object.getOwnPropertySymbols
// 获取symbol作为key的属性
function getSymbols(object) {
  if (object == null) {
    return []
  }
  // 包装基本类型,
  object = Object(object)
  return nativeGetSymbols(object).filter((symbol) => propertyIsEnumerable.call(object, symbol))
}

遍历

// value是被克隆对象
// 数组遍历自身。 对象遍历属性数组。
const props = isArr ? undefined : getAllKeys(value)
// 
arrayEach(props || value, (subValue, key) => {
    // subValue是array[index],key是index
    if (props) {
      // 对于对象 key是array[index]
      key = subValue
      // value 是 value[lkey]
      subValue = value[key]
    }
    // 将递归遍历的值 赋值到 key中。 处理特殊情况,eg: NAN也要赋值为NAN 
    // 基本等于 object[key] = baseClone(subValue, bitmask, customizer, key, value, stack)
    assignValue(result, key, baseClone(subValue, bitmask, customizer, key, value, stack))
})

递归克隆,Map和Set

同理遍历。但是用 set / add 来赋值。

if (tag == mapTag) {
  value.forEach((subValue, key) => {
    result.set(key, baseClone(subValue, bitmask, customizer, key, value, stack))
  })
}
if (tag == setTag) {
  value.forEach((subValue) => {
    result.add(baseClone(subValue, bitmask, customizer, subValue, value, stack))
  })
}

处理循环引用

原理:缓存每个引用,然后调用克隆函数时去缓存判断是否已经克隆。有则将克隆的引用返回。无则继续克隆。 lodash 有2种模式来缓存引用。

缓存引用

怎么才能存储引用呢? ArrayMap

数组缓存引用 ListCache

每个数组项数组(长度为2)[旧对象引用,克隆对象引用] 。 具体大概如右: [[旧对象引用,克隆对象引用], [旧对象引用,克隆对象引用], [旧对象引用,克隆对象引用],...] 然后判断缓存时,遍历数组即可。 ​

这里实现一个最简单的demo

class ListCache {
  __data__ = []
  size = 0

  get(key) {
    const data = this.__data__
    const index = data.findIndex(([cacheKey])=>cacheKey===key)
    return index < 0 ? undefined : data[index][1]
  }

  has(key) {
    const data = this.__data__
    return data.findIndex(([cacheKey])=>cacheKey===key) > -1
  }

  set(key, value) {
    const data = this.__data__
    const index = data.findIndex(([cacheKey])=>cacheKey===key)
    if (index < 0) {
      ++this.size
      data.push([key, value])
    } else {
      data[index][1] = value
    }
    return this
  }
}

loadsh 中小于200的缓存,用 ListCatch 超过则用 MapCatch

MapCatch

使用原生 Map 对象。

克隆函数

loadsh 是不克隆函数的,将返回 {}。 因为克隆函数没有实际意义,公用同一个函数也没问题。 而且涉及到 科里化,this,闭包等问题,无法克隆相等价值的函数。 ​

但是面试会问,还是要克隆一个。

克隆函数

new Function('return ' + fn.toString())() 
// 或者
eval(`(${fn.toString()})`)

总结

  • 区分对象tag
  • 克隆对象通过,构造函数 或者 原型链
  • ArrayMap 解决循环引用
  • 递归
  • 获取对象属性
  • 克隆函数