手撕JS对象深拷贝

1 阅读14分钟

前言

对象深拷贝的实现需要对各种数据类型以及各种遍历方法有着系统深刻的理解。所以这也是前端面试经常考察的原因。 对于初级前端开发者来说,手撕实现对象深拷贝对技术进阶有着非常显著的提升。

先实现一个简陋的版本

此版本实现了基本的深拷贝功能:
1、原始类型及函数直接返回
2、引用类型区分对象和数组初始化相应的实例
3、然后遍历自有属性递归拷贝

function deepCopy(value: any): any {
  if (typeof value !== 'object' || value === null) {
    return value;
  }
  
  const result = Array.isArray(value) ? [] : {};
  
  for (let key in value) {
    if (!value.hasOwnProperty(key)) { continue }

    result[key] = deepCopy(value[key]);
  }
  
  return result;
}

对于简单普通的数据结构,实现了深拷贝 但是对于其他更复杂的引用类型未作考虑,对于循环引用也未做处理,对象的原型链也未继承 后续将针对以上不足做进一步优化迭代

循环引用问题的处理

添加一个缓存集合cache用于缓存拷贝过的对象类型的属性,这里还配合做了如下改造: 1、内部封装的改造,_deepCopy函数成为实际执行和递归的主体 2、从语意角度考虑,将原来的 result 改名为 instance

具体步骤如下所示:

1、创建缓存集合, 使用 WeakMap 很符合缓存集合的要求,而且便于垃圾回收.

function deepCopy(value: any): any {
  const cache = new WeakMap();                                 
  
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
  })(value, cache);
}

2、缓存检查,若命中则使用缓存并退出本次拷贝.

function deepCopy(value: any): any {
  const cache = new WeakMap();
  
  return (function _deepCopy(value: any, cache: WeakMap) {
    if (typeof value !== 'object' || value === null) {
      return value;
    }
    
    if (cache.has(value)) {                                                                                             
      return cache.get(value);                                                                                          
    }                                                                                                                   
    
    // 其他逻辑...
  })(value, cache);
}

3、若未命中说明还未添加过缓存,在实例初始化后添加.

function deepCopy(value: any): any {
  const cache = new WeakMap();
  
  return (function _deepCopy(value: any, cache: WeakMap) {
    if (typeof value !== 'object' || value === null) {
      return value;
    }
    
    if (cache.has(value)) {
      return cache.get(value);
    }
    
    const instance = Array.isArray(value) ? [] : {};
    
    cache.set(value, instance);                                                                                         
    
    // 其他逻辑...
  })(value, cache);
}

此阶段的完整代码如下:

function deepCopy(value: any): any {
  // 缓存已处理的对象,防止循环引用.
  const cache = new WeakMap();
  
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 基本类型及函数直接返回.
    if (typeof value !== 'object' || value === null) {
      return value;
    }

    // 检查缓存,防止循环引用.
    if (cache.has(value)) {
      return cache.get(value);
    }
    
    // 初始化新对象.
    const instance = Array.isArray(value) ? [] : {};

    // 写入缓存.
    cache.set(value, instance);
    
    // 遍历源对象.
    for (let key in value) {
      // 过滤掉非自有属性.
      if (!value.hasOwnProperty(key)) { continue }

      // 传入属性值递归调用 deepCopy,实现深拷贝.
      instance[key] = _deepCopy(value[key], cache);
    }

    // 返回拷贝好的新对象.
    return instance;
  })(value, cache);
}

此版本在基础版本上解决了由对象循环引用造成死循环的问题

引用类型的细化处理

目前引用类型只对对象和数组做了处理,还有其它类型(包括 Date, RegExp, 包装对象、Map、Set 等)需要兼容

类型判断的优化补充

由于typeof运算符的返回值无法区分各引用类型,决定用Object.prototype.toString方法的返回值构建一个类型字典TYPE用于准确判断类型.

function deepCopy(value: any): any {
  const TYPE = {                                                                      
    'NULL': '[object Null]',                                                          
    'UNDEFINED': '[object Undefined]',                                                
    'STRING': '[object String]',                                                      
    'NUMBER': '[object Number]',                                                      
    'BIGINT': '[object BigInt]',                                                      
    'BOOLEAN': '[object Boolean]',                                                    
    'SYMBOL': '[object Symbol]',                                                      
    'OBJECT': '[object Object]',                                                      
    'ARRAY': '[object Array]',                                                        
    'FUNCTION': '[object Function]',                                                  
    'ARGUMENTS': '[object Arguments]',                                                
    'MAP': '[object Map]',                                                            
    'WEAK_MAP': '[object WeakMap]',                                                   
    'SET': '[object Set]',                                                            
    'WEAK_SET': '[object WeakSet]',                                                   
    'DATE': '[object Date]',                                                          
    'REGEXP': '[object RegExp]',                                                      
    'ERROR': '[object Error]',                                                        
  };                                                                                  
  
  const cache = new WeakMap();
  
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
  })(value, cache);
}

注意:此集合并没有完全覆盖Object.prototype.toString方法的返回值,但在这里用于深拷贝判断足够.

Date类型的处理

先确定值的准确类型,后续其他引用类型的处理都用得上type.

const type = Object.prototype.toString.call(value);

由于 Date 实例不可枚举,无法遍历,需要特殊处理 先实例化一个空的 Date 对象,然后用 getTime() 方法获取时间戳填入,就完成了 Date 类型的拷贝 新实例在内存空间上与原值完全隔离。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    if (typeof value !== 'object' || value === null) {
      return value;
    }
    
    const type = Object.prototype.toString.call(value);                               
                                                                                      
    if (type === TYPE.DATE) {                                                         
      return new Date(value.getTime());                                               
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

RegExp类型的处理

RegExp 实例与 Date 类似,不可枚举,无法遍历,需要特殊处理。 正则构造函数接受两个参数:必选参数 pattern (匹配文本) 和可选参数 flags (修饰符)。 所以拿到这两个值就可以完成正则的拷贝。

const newRegExp = new RegExp(value.source, value.flags);

注意lastIndex 是正则表达式的一个可读可写的整型属性,用来指定下一次匹配的起始索引。 只有正则表达式带有 g(全局匹配) 或 y(粘性匹配) 修饰符时,lastIndex 属性才会起作用。 新实例化的正则对象的 lastIndex 属性默认会被重置为 0,所以为了保证深拷贝后的正则表达式与原正则表达式的匹配状态完全一致,我们必须手动把原对象的 lastIndex 赋值给新对象。

newRegExp.lastIndex = value.lastIndex;

最后将新实例返回即可。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    if (typeof value !== 'object' || value === null) {
      return value;
    }
    
    const type = Object.prototype.toString.call(value);

    if (type === TYPE.REGEXP) {                                                       
      const newRegExp = new RegExp(value.source, value.flags);                        
      newRegExp.lastIndex = value.lastIndex;                                          
      return newRegExp;                                                               
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

包装对象的处理

包装对象在类型上与原始值不同,也不同于普通对象,它存在特有的(静态/实例)属性及方法。

如果按照普通对象去迭代,无法实现拷贝,原因如下: 1、只有 new String() 的值可枚举,new Number()new Boolean() 都不行

Object.entries(new String('hello')); // 返回 [['0', 'h'], ['1', 'e'], ['2', 'l'], ['3', 'l'], ['4', 'o']].
Object.entries(new Number(12345));   // 返回 [].
Object.entries(new Boolean(true));   // 返回 [].

2、会丢失特有的(静态/实例)属性及方法

所以一个方便又好用的办法就是用原始值重新实例化,value.constructor 属性拿到对应构造函数,value.valueOf() 方法拿到原始值。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
    
    if ([TYPE.STRING, TYPE.NUMBER, TYPE.BOOLEAN].includes(type)) {                    
      return new value.constructor(value.valueOf());                                  
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

DOM 类型的处理

DOM 类型的判断方式与众不同,这是因为不同类型标签的 Object.prototype.toString 返回值都不一样。

Object.prototype.toString.call(window);        // "[object Window]"
Object.prototype.toString.call(document);      // "[object HTMLDocument]"
Object.prototype.toString.call(document.body); // "[object HTMLBodyElement]"
Object.prototype.toString.call(div);           // "[object HTMLDivElement]"

DOM 提供了原生的克隆方法 cloneNode()。此方法会返回调用该方法的节点的一个副本。该副本会复制它的所有属性及其对应的值,包括内联事件监听器。它不会复制通过 addEventListener() 添加的事件监听器,或赋值给元素的事件处理器(例如 node.onclick = someFunction)。此外,对于 <canvas> 元素,绘制的图像不会被复制。该方法同时接收一个布尔类型的参数 deep 用于控制是否克隆所有后代节点。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
    
    if (typeof value.nodeType === 'number' && typeof value.cloneNode === 'function') {
      return value.cloneNode(true);                                                   
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

注意:这里有一个问题,那就是有些 DOM 属性是不应该复制的,比如元素的 id。还有就是拷贝的节点在插入之前都是脱离文档流而存在的。

设置拷贝白名单

随着JS应用场景越来越多,其标准化程度也越来越高。当前已知的类型已经十分繁杂,后续肯定还要继续丰富扩充。 如果不设置白名单,代码就可能在处理某些类型的数据时意外报错。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...

    if (![TYPE.OBJECT, TYPE.ARRAY, TYPE.ARGUMENTS, TYPE.MAP, TYPE.SET].includes(type)) {
      return value;                                                                   
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

实例对象的优化处理

之前为了递归调用而实例化的 instance 只考虑了对象和数组两种情况,这是不够的。

一个比较好的办法是通过 constructor 属性找到对应的构造函数,然后根据值的类型动态实例化。

const instance = new value.constructor();

其实我们还可以更进一步,顺便解决对象原型链继承的问题。

Object.create() 方法可以指定原型创造对象,Object.getPrototypeOf() 方法可以返回指定对象的原型。 这样我们就可以实例化一个有原型的空对象。

Object.create(Object.getPrototypeOf(value));

最后将这两处对 instance 的优化合并:

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...

    const instance = type === TYPE.OBJECT                                             
      ? Object.create(Object.getPrototypeOf(value))                                   
      : new value.constructor();                                                      
    
    // 其他逻辑...
  })(value, cache);
}

Map、Set类型的处理

MapSet 本身拥有用于遍历的实例方法 forEach,我们可以直接使用。

value.forEach((val: any, key: any) => { ... });  // Map的遍历.
value.forEach((val: any) => { ... });            // Set的遍历.

注意MapSet 存储的(键)值可以是引用类型,所以需要调用 _deepCopy() 递归处理。

instance.set(_deepCopy(key, cache), _deepCopy(val, cache)); // Map的赋值.
instance.add(_deepCopy(val, cache));                        // Set的赋值.

完整的处理逻辑如下:

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...

    const instance = type === TYPE.OBJECT
      ? Object.create(Object.getPrototypeOf(value))
      : new value.constructor();
    
    cache.set(value, instance);
    
    if (type === TYPE.MAP) {                                                          
      value.forEach((val: any, key: any) => {                                         
        instance.set(_deepCopy(key, cache), _deepCopy(val, cache));                   
      });                                                                             
    } else if (type === TYPE.SET) {                                                   
      value.forEach((val: any) => {                                                   
        instance.add(_deepCopy(val, cache));                                          
      });                                                                             
    } else {
      // 其他逻辑...
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

Array类型的处理

常规数组的拷贝没什么问题,直接遍历递归调用并注意循环引用即可。有两个特殊情况要考虑:

稀疏数组的拷贝

稀疏数组如下所示:

const arr = [1, , 3]; // 索引 1 是空洞(根本没有这个 key).

由于 for...of[...]Array.from()等方法会将空洞自动补全为 undefined; 而 forEach()map()for...in 等方法会跳过空洞将其忽略。 所以,以上这些方法都不符合拷贝稀疏数组的要求,我们需要的是精准判断哪些下标有值,哪些下标是空洞

我们可以用 for 循环加上 in 运算符实现上述目标:

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
    
    if (type === TYPE.MAP) {
      // 其他逻辑...                                                                       
    } else if (type === TYPE.SET) {
      // 其他逻辑...                                                                        
    } else if (type === TYPE.ARRAY) {                                                 
      for (let i = 0; i < value.length; i += 1) {                                     
        if (i in value) {                                                             
          instance[i] = _deepCopy(value[i], cache);                                   
        }                                                                             
      }                                                                               
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

for 循环根据 length 遍历,in 运算符判断对应下标是否存在,最后只给存在的下标赋值,完成稀疏数组的拷贝。

自添加属性的拷贝

Array 属于 Object,所以同样可以挂载属性,而且键名类型同样既可以是 string 也可以是 symbol

先通过 Reflect.ownKeys 拿到所有自有属性,再通过 Object.prototype.propertyIsEnumerable 只保留可枚举属性,最后过滤掉有效数组下标,剩下的就是自添加属性和 length 属性。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
    
    if (type === TYPE.MAP) {
      // 其他逻辑...                                                                       
    } else if (type === TYPE.SET) {
      // 其他逻辑...                                                                        
    } else if (type === TYPE.ARRAY) {                                                 
      for (let i = 0; i < value.length; i += 1) {                                     
        if (i in value) {                                                             
          instance[i] = _deepCopy(value[i], cache);                                          
        }                                                                             
      }

      Reflect.ownKeys(value)                                                            
        .filter(key => Object.prototype.propertyIsEnumerable.call(value, key))          
        .forEach((key: string | symbol) => {                                          
          if (!/^\d+$/.test(key) || Number(key) >= arr.length) {                      
            instance[key] = _deepCopy(value[key], cache);                             
          }                                                                           
        });                                                                           
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

合二为一更简洁

拿到所有的自有可枚举属性,这其中就包括有效下标和 length 属性,都是我们要拷贝的属性。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
    
    if (type === TYPE.MAP) {
      // 其他逻辑...                                                                       
    } else if (type === TYPE.SET) {
      // 其他逻辑...                                                                        
    } else if (type === TYPE.ARRAY) {
      Reflect.ownKeys(value)                                                            
        .filter(key => Object.prototype.propertyIsEnumerable.call(value, key))          
        .forEach((key: string | symbol) => {                                          
          instance[key] = _deepCopy(value[key], cache);                               
        });                                                                           
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

至此,数组拷贝的问题讨论完毕。

Object类型的处理

对象要考虑的情况跟数组一样,直接复用即可。其中 Arguments 是函数内部可访问实参的类数组对象。

function deepCopy(value: any): any {
  // 其他逻辑...
  return (function _deepCopy(value: any, cache: WeakMap) {
    // 其他逻辑...
    
    if (type === TYPE.MAP) {
      // 其他逻辑...                                                                       
    } else if (type === TYPE.SET) {
      // 其他逻辑...                                                                        
    } else if ([TYPE.OBJECT, TYPE.ARRAY, TYPE.ARGUMENTS].includes(type)) {            
      Reflect.ownKeys(value)                                                            
        .filter(key => Object.prototype.propertyIsEnumerable.call(value, key))          
        .forEach((key: string | symbol) => {
          instance[key] = _deepCopy(value[key], cache);
        });                                                                           
    }                                                                                 
    
    // 其他逻辑...
  })(value, cache);
}

完整版本

  • 原始类型(Null、Undefined、Number、String、Boolean、BigInt、Symbol)返回原值
  • 函数类型(包括传统函数、箭头函数、生成器函数、异步函数)返回原引用
  • 不可枚举属性忽略不拷贝
  • 日期(Date),正则(RegExp),包装对象(Number, String, Boolean)特殊处理
  • Object、Array、Arguments、Map、Set 等引用类型递归深拷贝
  • 其余引用类型(包括 WeakMap、WeakSet、TypedArray、ArrayBuffer、Error、DOM对象等)因情况复杂或成本较高只返回空实例,不做拷贝,下文会就此展开可行性技术讨论。
function deepCopy(value: any): any {
  const TYPE = {
    'NULL': '[object Null]',
    'UNDEFINED': '[object Undefined]',
    'STRING': '[object String]',
    'NUMBER': '[object Number]',
    'BIGINT': '[object BigInt]',
    'BOOLEAN': '[object Boolean]',
    'SYMBOL': '[object Symbol]',
    'OBJECT': '[object Object]',
    'ARRAY': '[object Array]',
    'FUNCTION': '[object Function]',
    'ARGUMENTS': '[object Arguments]',
    'MAP': '[object Map]',
    'WEAK_MAP': '[object WeakMap]',
    'SET': '[object Set]',
    'WEAK_SET': '[object WeakSet]',
    'DATE': '[object Date]',
    'REGEXP': '[object RegExp]',
    'ERROR': '[object Error]',
  };
  
  const cache = new WeakMap();
  
  return (function _deepCopy(value: any, cache: WeakMap): any {
    if (typeof value !== 'object' || value === null) {
      return value;
    }

    const type = Object.prototype.toString.call(value);
    
    if (type === TYPE.DATE) {
      return new Date(value.getTime());
    }
    if (type === TYPE.REGEXP) {
      const newRegExp = new RegExp(value.source, value.flags);
      newRegExp.lastIndex = value.lastIndex;
      return newRegExp;
    }
    if ([TYPE.STRING, TYPE.NUMBER, TYPE.BOOLEAN].includes(type)) {
      return new value.constructor(value.valueOf()); // 修复包装对象错误.
    }
    if (typeof value.nodeType === 'number' && typeof value.cloneNode === 'function') {
      return value.cloneNode(true);
    }
    
    if (cache.has(value)) {
      return cache.get(value);
    }

    if (![TYPE.OBJECT, TYPE.ARRAY, TYPE.ARGUMENTS, TYPE.MAP, TYPE.SET].includes(type)) {
      return value;
    }
    
    const instance = type === TYPE.OBJECT
      ? Object.create(Object.getPrototypeOf(value))
      : new value.constructor();
    
    cache.set(value, instance);
    
    if (type === TYPE.MAP) {
      value.forEach((val: any, key: any) => {
        instance.set(_deepCopy(key, cache), _deepCopy(val, cache));
      });
    } else if (type === TYPE.SET) {
      value.forEach((val: any) => {
        instance.add(_deepCopy(val, cache));
      });
    } else if ([TYPE.OBJECT, TYPE.ARRAY, TYPE.ARGUMENTS].includes(type)) {
      Reflect.ownKeys(value)
        .filter(key => Object.prototype.propertyIsEnumerable.call(value, key))
        .forEach((key: string | symbol) => {
          instance[key] = _deepCopy(value[key], cache);
        });
    }

    return instance;
  })(value, cache); 
}

此代码在实现上有一些不同,主要体现在: 1、cache 引用缓存未使用外部传参方式 2、内部使用 _deepCopy 进行递归迭代.

这是因为我们这里只是单独地实现一个深拷贝功能的函数,不存在其他考虑;而一个完备的工具库,需要封装、抽象、复用,不仅要考虑整体的体积、性能,还要照顾到后续方便迭代。

后续其他问题讨论

不可枚举属性的拷贝

属性设置为不可枚举的初衷就是不希望被遍历到。比如DOM节点上的大量指针,或者工业级框架中的内部属性等。 一是不希望暴露,二是体积庞大。

极少数的元编程场景会需要拷贝不可枚举属性,直接拷贝会改变不可枚举性,应该用属性描述符去定义:

Object.defineProperty(obj, '_token', {
    value: 'secret_123',
    enumerable: false,
    writable: true,
    configurable: true,
});

Reflect.ownKeys(obj)
  .filter(key => !Object.prototype.propertyIsEnumerable.call(obj, key))
  .forEach((key: string | symbol) => {
    const descriptor = Object.getOwnPropertyDescriptor(obj, key);
    
    if (descriptor) {
      if ('value' in descriptor) {
        descriptor.value = _deepCopy(descriptor.value, cache);
      }
      
      Object.defineProperty(instance, key, descriptor);
    }
  });

先通过 Reflect.ownKeys()Object.prototype.propertyIsEnumerable() 找到不可枚举属性。 然后通过 Object.getOwnPropertyDescriptor() 获取到属性的描述符并递归深拷贝其中的 value 值。 最后再通过 Object.defineProperty() 将属性赋值到新实例。

函数类型的拷贝

函数类型有着JS世界“一等公民”的称号,是日常开发学习常用的高频类型。所以在讨论深拷贝问题的时候,函数类型是绕不开的。前面手撕实现的时候,我们对函数类型的处理方式是直接返回原引用。那现在我们就深入探究一下函数类型的深拷贝问题。

应不应该拷贝

答案是 不应该! 原因有以下两点: 1、函数作为一段封装的执行逻辑,其内部函数体的内容是不会变的。拷贝一份出来只会浪费内存。 2、函数在创建时可能会绑定闭包,闭包环境的变量无法访问到。

可不可以拷贝

答案是无法做到真正的深拷贝。即是勉强拷贝了,也是不完善的,没有实用价值。

但在技术层面确实值得讨论一下,对边界情况的掌握也是技术升阶的重要部分:

1、先通过 Function.prototype.toString() 拿到函数的字符串形式。

const fn = async function(a, b) {
  return a + b;
};
fn.toString(); // 'async function(a, b) {\n  return a + b;\n}'.

2、再通过正则或其他方式过滤出形参列表和代码体。 这一步的情况会稍微复杂一些,尤其是箭头函数,还有生成器函数,各种标识,形态各异。

(a => a + 1).toString(); // 'a => a + 1'
(a => {a + 1}).toString(); // 'a => {a + 1}'
((a) => {a + 1}).toString(); // '(a) => {a + 1}'
(async(a) => {a + 1}).toString(); // 'async(a) => {a + 1}'

3、最后将形参列表和代码体传入 new Function() 拷贝出新函数。

const clonedFn = new Function(paramsStr, bodyStr);

使用 new Function() 构造出的函数有一个问题,就是它只能识别全局变量和函数体内部定义的变量,其他作用域的变量一概不认,并且会报错。

函数挂载的属性

函数属于对象,可以挂载属性。比如某些工业级框架会使用函数作为核心并往上挂载大量属性。或者构造函数。 函数挂载的属性在技术上确实可以实现拷贝,但主函数既然没法拷贝,拷贝其挂载属性又有什么意义呢~

WeakMap 和 WeakSet 的拷贝

WeakMapWeakSet 无法被遍历,也没有任何办法能够拿到 WeakMapWeakSet 里究竟存了哪些键值对,除非提前知道去单个精准获取,因此无法做到深拷贝。

与函数类型不同,WeakMapWeakSet 属于集合,因此前面手撕实现的时候,我们对 WeakMapWeakSet 类型的处理方式是返回空实例