深拷贝渐进式解决方案

1,459 阅读10分钟

前言

大家都知道Vue是渐进式JavaScript框架,渐进式咋理解?

Vue渐进式是指先使用vue核心库,在vue核心库的基础上,根据自己需要再去选择其他功能。

  • Vue的核心的功能,是一个视图模板引擎,在视图模板引擎的基础上,我们可以根据自己的需要添加组件系统、路由、状态管理等,这些功能是相互独立的,不一定非要搞个Vue全家桶。
  • 渐进式框架拥有足够的灵活性,和扩展性,主张最少,也就是弱主张,这是Vue设计的理念。

那深拷贝渐进式解决方案又是啥意思?

背景

深拷贝是大家搜索和应用的比较频繁的一个知识点,几乎每篇文章想要强调的都是,怎么写深拷贝才能覆盖场景更广、更健壮,看了那么多的文章也没有一个终极方案,对于我这种有点强迫症的人来说,怎么能允许这种没有章法的事情存在呢(有章可循对前端开发来说很重要),难道除了lodash就没有更好的方案了么?

为啥不用lodash?

lodash的deepClone方法虽然健壮,稳妥,但是它的内部逻辑很复杂,代码繁多,光是定义数据类型就这么多

/** `Object#toString` result references. */
const argsTag = '[object Arguments]'
const arrayTag = '[object Array]'
const boolTag = '[object Boolean]'
const dateTag = '[object Date]'
const errorTag = '[object Error]'
const mapTag = '[object Map]'
const numberTag = '[object Number]'
const objectTag = '[object Object]'
const regexpTag = '[object RegExp]'
const setTag = '[object Set]'
const stringTag = '[object String]'
const symbolTag = '[object Symbol]'
const weakMapTag = '[object WeakMap]'

const arrayBufferTag = '[object ArrayBuffer]'
const dataViewTag = '[object DataView]'
const float32Tag = '[object Float32Array]'
const float64Tag = '[object Float64Array]'
const int8Tag = '[object Int8Array]'
const int16Tag = '[object Int16Array]'
const int32Tag = '[object Int32Array]'
const uint8Tag = '[object Uint8Array]'
const uint8ClampedTag = '[object Uint8ClampedArray]'
const uint16Tag = '[object Uint16Array]'
const uint32Tag = '[object Uint32Array]'

工具函数方法,就封装了二十多个,一个深拷贝的函数代码加起来可能有接近千行了。

import Stack from './Stack.js'
import arrayEach from './arrayEach.js'
import assignValue from './assignValue.js'
import cloneBuffer from './cloneBuffer.js'
import copyArray from './copyArray.js'
import copyObject from './copyObject.js'
import cloneArrayBuffer from './cloneArrayBuffer.js'
import cloneDataView from './cloneDataView.js'
import cloneRegExp from './cloneRegExp.js'
import cloneSymbol from './cloneSymbol.js'
import cloneTypedArray from './cloneTypedArray.js'
import copySymbols from './copySymbols.js'
import copySymbolsIn from './copySymbolsIn.js'
import getAllKeys from './getAllKeys.js'
import getAllKeysIn from './getAllKeysIn.js'
import getTag from './getTag.js'
import initCloneObject from './initCloneObject.js'
import isBuffer from '../isBuffer.js'
import isObject from '../isObject.js'
import isTypedArray from '../isTypedArray.js'
import keys from '../keys.js'
import keysIn from '../keysIn.js'

对于代码size要求较高的应用很不友好,而且业务中大多数情况都是一些简单对象拷贝,用lodash反而不划算,而且你真的有精力去扒它的代码么,如果你只是拿过来就用,那对自己能力的提升也有限,深拷贝还是有必要自己动手写一写的。

第一版

一开始我们设计了这样一个方法,我们暂且叫它第一版

  /**
    * 深拷贝
    * @param {Object|Array} target  拷贝对象
    * @returns {Object|Array} result 拷贝结果
    */
  function deepCopy(target) {
    if (Array.isArray(target)) { // 处理数组
      return target.map(item => deepCopy(item));
    }

    if (Object.prototype.toString.call(target) === '[object Object]') { // 处理对象
      // 先将对象转为二维数组,再将二维数组转回对象(这个过程还是浅拷贝)
      // 所以使用map方法将二维数组里的元素进行深拷贝完了再转回对象
      return Object.fromEntries(Object.entries(target).map(([k, v]) => [k, deepCopy(v)]));
    }
    return target; // 深拷贝要处理的就是引用类型内部属性会变化的情况,像正则、Error、函数、Date这种一般不会发生变化的直接返回原数据就可以
  }

上面的方法足够覆盖你的大多数场景了,但是该版本性能较差,fromEntries entries map 哪个方法都不是省油的灯,性能优化后面会讲。

慢的原因:

理论上 for 循环没有额外的函数调用栈和上下文,它的实现最为简单,也最快,map 不但有函数调用开销而且还会返回一个新的数组,数组的创建和赋值会分配内存空间因此带来较大的性能开销

entries 和 fromEntries 就更别提了,内部有对象与二维数组之间的转换,性能消耗更大。

试下效果:

const obj = {
  nan: NaN,
  infinityMax: 1.7976931348623157E+10308,
  infinityMin: -1.7976931348623157E+10308,
  undef: undefined,
  fun: () => 'func',
  date: new Date(),
  reg: /\d/,
  smb: Symbol(),
  nul: null,
  obj: {a: 1},
  err: new Error('error'),
  map: new Map([['a', '3']]),
  arr: ['a', 'b', 'c'],
};

const objClone = deepCopy(obj);
obj.fun = ()=> 'newfunc';
obj.obj.a = 66;
obj.map.set('b',6); // 更改下原Map值
console.log(objClone)

image.png

可以看到,除了Map外别的都基本符合预期,因为我们并没有处理Map。

以上方法当遇到Map,Set等时还是浅拷贝,而且当对象中存在循环引用会出现栈溢出,总之,这个方法还存在很多问题。

什么是循环引用?

把上例改一下:

const obj1 = {};
const obj = {
  nan: NaN,
  infinityMax: 1.7976931348623157E+10308,
  infinityMin: -1.7976931348623157E+10308,
  undef: undefined,
  fun: () => 'func',
  date: new Date(),
  reg: /\d/,
  smb: Symbol(),
  nul: null,
  obj: {a: 1},
  err: new Error('error'),
  map: new Map([['a', '3']]),
  arr: ['a', 'b', 'c'],
  obj1: obj1, // 加入循环引用obj里引用了obj1,obj1里也引用了obj
};
obj1.a = obj;

const objClone = deepCopy(obj);
console.log(objClone)

你的对象会一直无限嵌套下去,递归进入死循环就导致栈内存溢出了。

image.png

第二版

我们先把循环引用的问题解决掉,这版就叫作第二版,

// 获取数据类型 (将获取数据类型方法封装起来,以备复用,并增强可读性)
function getDataType(val) {
  return Object.prototype.toString.call(val).slice(8,-1)
    .toLowerCase();

}

// 深拷贝
function deepCopy(target, cache = new WeakMap()) {
  // 如果已经拷贝过该对象,则直接返回拷贝结果,不再进入递归逻辑,防止循环引用
  if (cache.get(target)) return cache.get(target);
  // 处理数组和对象
  if (['object', 'array'].includes(getDataType(target))) {
    const cloneTarget = new target.constructor();
    cache.set(target, cloneTarget);
    for (const key of Reflect.ownKeys(target)) {
      cloneTarget[key] = deepCopy(target[key], cache); // 递归拷贝每一层
    }
    return cloneTarget;
  }

  return target; // 深拷贝要处理的就是引用类型内部属性会变化的情况,像正则、Error、函数、Date这种一般不会发生变化的直接返回原数据就可以
}

小伙伴们已经发现了,这个版本我们弃用了通过Object.fromEntries和Object.entries互转来实现深拷贝的方式,因为在改造时我们要加入一个缓存已拷贝过的对象的逻辑,以用来解决循环引用的问题,一方面是为了减少重复代码,另一方面是因为只能遍历对象的可枚举属性键值对且不包含symbol为键的情况。

方法中有几个细节:

  • WeakMap: 我们做缓存的方式可以用对象,可以用Map,这里用了WeakMap是因为它的键名所引用的对象都是弱引用,只要所引用的对象的其他引用都被清除,垃圾回收机制就会释放该对象所占用的内存。也就是说,一旦不再需要,WeakMap 里面的键名对象和所对应的键值对会自动消失,不用手动删除引用,这有利于节省我们的内存消耗。(其他WeakMap的细节可以看阮一峰老师的文章,写的很通俗易懂了)

  • new target.constructor() : 该方式可以省去判断数组还是对象的步骤,直接取目标的构造器,相当于

    console.log(new {}.constructor()) // {} 
    等价于 
    console.log(new Object()) // {}
    const obj = new Object();
    obj[key] = '';
    
    console.log(new [].constructor()) // []
    等价于 
    console.log(new Array()) // []
    const arr = new Array();
    arr[key] = '';
    
    
  • Reflect.ownKeys(target):Reflect.ownKeys() 方法是 ES6 新增的静态方法,该方法返回对象自身所有属性名组成的数组,包括不可枚举的属性和 Symbol 属性,我们需要遍历到Symbol属性,所以使用这个方法。

    可遍历键的方法对比:

    方法基本属性原型链不可枚举Symbol
    for in
    Object.keys()
    Object.getOwnPropertyNames()
    Object.getOwnPropertySymbols()
    Reflect.ownKeys()

ok, 目前为止,循环引用的问题解决完了,我们进一步优化,下面支持一下Map。

等等,思路好像不太对,这么优化下去那就没有头了,js的引用类型那么多。

或许我们思考的方向就错了,大家都在纠结如何让深拷贝支持的数据类型更全,但是你真的能用到么,等你真的有需要的话再去扩展就可以了啊,回到我们最开始说的渐进式思想,不必一下子搞的大而全,代码size变大的同时性能可能也会有所降低,对于深拷贝方法也是一样,根据自己的需要往里加功能就可以了,就像webpack的loader一样。

其实,上面的写法就已经为可扩展打好了基础,例如我们加一个对Map的处理

// 获取数据类型 (将获取数据类型方法封装起来,以备复用,并增强可读性)
function getDataType(val) {
  return Object.prototype.toString.call(val).replace(/\[object (\w+)\]/, '$1')
    .toLowerCase();
}

// 深拷贝
function deepCopy(target, cache = new WeakMap()) {
  // 如果已经拷贝过该对象,则直接返回拷贝结果,不再进入递归逻辑,防止循环引用
  if (cache.get(target)) return cache.get(target);
  
  // 处理数组和对象
  if (['object', 'array'].includes(getDataType(target))) {
    const cloneTarget = new target.constructor();
    cache.set(target, cloneTarget);
    for (const key of Reflect.ownKeys(target)) {
      cloneTarget[key] = deepCopy(target[key], cache); // 递归拷贝每一层
    }
    return cloneTarget;
  }

  // 处理Map
  if (getDataType(target) === 'map') {
    const cloneTarget = new Map();
    cache.set(target, cloneTarget);
    for (const [key, val] of target) {  // 等同于target.entries(),Map 结构的默认遍历器接口(Symbol.iterator属性),就是entries方法。 
      cloneTarget.set(key, deepCopy(val, cache));
    }
    return cloneTarget;
  }

  return target; // 深拷贝要处理的就是引用类型内部属性会变化的情况,像正则、Error、函数、Date这种一般不会发生变化的直接返回原数据就可以
}

小知识点:map[Symbol.iterator] === map.entries

看下效果

const obj = {
  map: new Map([['a', '3']]),
};

const objClone = deepCopy(obj);
obj.map.set('b',6); // 更改下原Map值
console.log(objClone)

image.png

可以看到,我们新加的“loader”已经生效,拷贝过来的Map没有发生改变。

类似地,你还可以加入对Set的处理:

  // 处理Set
  if (getDataType(target) === 'set') {
    const cloneTarget = new Set();
    cache.set(target, cloneTarget);
    for (const val of target) { // 等同于target.values(),Set 结构的默认遍历器接口(Symbol.iterator属性),就是values方法。 
      cloneTarget.add(deepCopy(val, cache));
    }
    return cloneTarget;
  }

如果你担心Date对象也可能会被改变,那你也可以把Date拷贝一下

  // 处理Date
  if (getDataType(target) === 'date') {
    const cloneTarget = new target.constructor(obj.getTime());
    return cloneTarget;
  }

其他数据类型也是一样,大家灵活地自己加入就可以了。

性能优化版

/**
 * 判断是否是普通对象
 *
 * 直接调用constructor属性判断对象,替代调用函数转成字符串,减少性能开销,
 * 一般情况都可以通过constructor来判断,但是constructor属性不稳定,容易被更改,
 * 而且没有原型的对象(如Object.create(null)创建的纯净对象)是没有constructor属性的,
 * 此时仍然需要使用toString()方法来判断。
 *
 * @param {unknown} val
 * @returns {val is object}
 */
const isPlainObject = (val: unknown): val is object => {
  if (val?.constructor) return val.constructor === Object;
  return Object.prototype.toString.call(val) === '[object Object]';
};

const isPlainObjectOrArray = (val: unknown): boolean => isPlainObject(val) || isArray(val);



/**
 * 深拷贝
 *
 * @param {*} target 拷贝目标
 * @param {WeakMap} [cache=new WeakMap()] 内部缓存,无需传入
 * @returns {*}
 */
export function deepClone(target: any, cache = new WeakMap()): any {
  // 如果已经拷贝过该对象,则直接返回拷贝结果,不再进入递归逻辑,提高性能,并且能够防止循环引用
  // WeakMap可在target不被引用时自动垃圾回收,节省内存消耗
  if (cache.get(target)) return cache.get(target);

  // 处理数组和对象
  // 存在constructor直接调用constructor属性判断对象和数组,替代调用函数,减少性能开销
  if (isPlainObjectOrArray(target)) {
    const cloneTarget = new target.constructor(); // 直接获取数组或对象的构造器,并实例化
    cache.set(target, cloneTarget);
    const keys = Reflect.ownKeys(target); // ownKeys 可遍历不可枚举属性和symbol
    for (let i = keys.length; i--;) { // for-- 比for of循环快很多
      const key = keys[i];
      cloneTarget[key] = deepClone(target[key], cache); // 递归拷贝每一层
    }
    return cloneTarget;
  }

  // ... 其他数据类型处理
  return target;
}

以上优化了类型判断方法和循环方法。

总结

思路捋清后,深拷贝其实是一个很简单的事,拷贝数组和对象为核心功能,然后你还需要啥你就往上加。

如果仅需要拷贝对象和数组而且也没有循环引用和 Symbol 为键的情况那 entries 和 fromEntries 实现的第一版就够用了。

如果需要处理更多类型的数据,就用扩展性更好的第二版或性能优化版。

以上就是深拷贝渐进式解决方案的整体思路,以深拷贝数组和对象为核心功能,其他类型的处理根据需要自行添加,无需引入庞大的库,也无需把精力放在如何把你的深拷贝方法写的更完整,适合自己的才是最好的

更多前端开发思想、思路推荐看一看优秀前端工程师必备的基本素养、代码规范、开发技巧查缺补漏攻略,共同交流学习。