深浅拷贝

237 阅读10分钟

JavaScript 包含两种不同数据类型的值:

  • 基本类型(原始值):stringnumberbooleannullundefinedsymbolbigInt
  • 引用类型:Object(object、array、function)

在将一个值赋给变量时,解析器必须确定这个值是基本类型还是引用类型

  • 基本数据类型是按值访问的,因为可以操作保存在变量中的实际的值
  • 引用类型是存储在内存中的对象,栈内存储的是变量的标识符、对象在堆內的存储地址(JS 不允许直接访问内存中的位置,即不能直接操作对象的内存空间,因此在操作对象时实际上是对操作对象的引用而不是实际的对象),当需要访问引用类型(如对象、数组等)的值时,首先从栈中获得该对象的地址指针,然后再从对应的堆内存中取得所需数据

JavaScript 的变量存储方式:

  • 栈(stack):自动分配内存空间,系统自动释放,里面存放的是基本类型的值和引用类型的地址指针
  • 堆(heap):动态分配内存,大小不定,也不会自动释放,里面存放引用类型的值 image.png

基本类型与引用类型最大的区别实际就是传值与传址的区别

  • 值传递:基本类型采用的是值传递

    let a = 1;
    let b = a;
    b++;
    console.log(a, b) // 1, 2
    
  • 址传递:引用类型则是地址传递,将存放在栈内存中的地址赋值给接收的变量

    let a = ['a', 'b', 'c'];
    let b = a; 
    b.push('d');
    console.log(a) // ['a', 'b', 'c', 'd']
    console.log(b) // ['a', 'b', 'c', 'd']
    

    上面代码中,a 是数组是引用类型,赋值给 b 就是将 a 的地址赋值给 b,因此 ab 指向同一个地址(该地址都指向了堆内存中引用类型的实际的值)

    b 改变这个值时,因为 a 的地址也指向了这个值,故 a 的值也跟着变化,就好比 a 租了一间房,将房间的地址给了 bb 通过地址找到了房间,则 b 对房间做的任何改变对 a 来说肯定同样是可见的

这里就引出了浅拷贝深拷贝了,JS 的基本类型不存在浅拷贝还是深拷贝的问题,主要是针对引用类型

浅拷贝与深拷贝

  • 浅拷贝:级别浅,指的是创建一个新对象,这个对象有着原始对象属性值的一份精确拷贝,若属性是基本类型,拷贝的就是基本类型的值,若属性是引用类型,拷贝的就是内存地址(即,浅拷贝是指复制对象时只对第一层键值对进行复制,若对象内还有对象则只能复制嵌套对象的地址指针,新旧对象还是共享同一块内存image.png

    浅拷贝的缺点:当有一个属性是引用值(数组或对象)时,按照这种克隆方式,只是把这个引用值的地址指针赋给了新的目标对象,一旦改变了源对象或目标对象的该引用值属性,另一个也会跟着改变

  • 深拷贝:拷贝级别更深,指复制对象时是完全拷贝,将一个对象从内存中完整的拷贝一份出来,从堆内存中开辟一个新的区域存放新对象,新对象跟原对象不共享内存,因此拷贝后两者也相互不影响,修改新对象不会影响原对象 image.png

总结:前提都是拷贝类型为引用类型,从上面可以看出,深浅拷贝都创建出一个新对象,但在复制对象属性时行为不一样

  • 浅拷贝:拷贝前后对象的基本数据类型互不影响,但因只复制属性指向某个对象的内存指针,而不是复制对象本身,拷贝前后对象的引用类型因共享同一块内存,修改引用属性值会互相影响

  • 深拷贝:对对象中的子对象进行递归拷贝,另外开辟一块新区域存放,拷贝前后的两个对象指向不同的地址,不共享内存,修改互不影响

浅拷贝的实现

Object.assign()

该方法可以把任意多个的源对象自身的可枚举属性拷贝给目标对象,然后返回目标对象,Object assign() 对对象的拷贝还是浅拷贝

let arr = {
  a: 'one', 
  b: 'two', 
  c: 'three'
};

let newArr = Object.assign({}, arr)
newArr.d = 'four'
console.log(arr);    // {a: "one", b: "two", c: "three"}
console.log(newArr); // {a: "one", b: "two", c: "three", d: "four"}
  
let arr = {
  a: 'one', 
  b: 'two', 
  c: {a: 1}
};

let newArr = Object.assign({}, arr);
newArr.c.a = 3;
console.log(arr);    // {a: "one", b: "two", c: {a: 3}}
console.log(newArr); // {a: "one", b: "two", c: {a: 3}}

具体详见 Object.assign 原理及其实现

Array.prototype.slice()Array.prototype.concat()

若是数组,数组元素均为基本数据类型,可利用数组的一些方法如 sliceconcat 返回一个新数组的特性来实现拷贝;若数组的元素是引用类型(Object,Array),sliceconcat 对对象数组的拷贝还是浅拷贝,拷贝之后数组各个元素的指针还是指向相同的存储地址

let arr = ['one', 'two', 'three'];
let newArr = arr.concat();
newArr.push('four');

console.log(arr);    // ["one", "two", "three"]
console.log(newArr); // ["one", "two", "three", "four"]

let arr = ['one', 'two', 'three'];
let newArr = arr.slice();
newArr.push('four');

console.log(arr);    // ["one", "two", "three"]
console.log(newArr); // ["one", "two", "three", "four"]

let arr = [{a:1}, 'two', 'three'];
let newArr = arr.concat();
newArr[0].a = 2;
console.log(arr);    // [{a: 2},"two","three"]
console.log(newArr); // [{a: 2},"two","three"]

展开运算符 ...

展开运算符是一个 ES6 特性,它提供了一种非常方便的方式来执行浅拷贝,与 Object.assign()的功能相同

let obj1 = {
 name: 'Kobe', 
 address:{x:100, y:100}
};
let obj2= {...obj1};
obj1.address.x = 200;
obj1.name = 'wade';
console.log(obj1); // {name: "wade", address: {x: 200, y: 100}}
console.log(obj2); // {name: "Kobe", address: {x: 200, y: 100}}

lodash 库的 _.clone 方法

该函数库也有提供 _.clone 用来做 ShallowCopy

var _ = require('lodash');
var obj1 = {
  a: 1,  
  b: { f: { g: 1 } },  
  c: [1, 2, 3]
};
var obj2 = _.clone(obj1);
console.log(obj1.b.f === obj2.b.f); // true

浅拷贝手写封装

创建一个新的对象,遍历需要克隆的对象,将需要克隆对象的属性依次添加到新对象上,返回

function shallowClone(obj) {
  if(typeof obj !== 'object') return;
  // 根据 obj 类型判断是新建一个数组还是对象
  let newObj = Object.prototype.toString.call(obj) == '[objecj Array]' ? [] : {};
  for(let prop in obj) {
    if(obj.hasOwnProperty(prop)) {
      newObj[prop] = obj[prop];
    }
  }
  return newObj;
}

深拷贝的实现

JSON.parse(JSON.stringify(arr))

不仅适用于数组还适用于对象。利用 JSON.stringify 将对象转成 JSON 字符串,再用 JSON.parse 把字符串解析成对象,一去一来,新的对象产生了,而且会开辟新的内存空间存放,开辟新的栈存放不同的内存指针,实现深拷贝

let a = {
  name: "tn",
  book: {
    title: "JS",
    price: "45"
  }
};
let b = JSON.parse(JSON.stringify(a));
console.log(b); // {name: "tn", book: {title: "JS", price: "45"}} 

a.name = "change";
a.book.price = "55";
console.log(a); // {name: "change", book: {title: "JS", price: "55"}} 
console.log(b); // {name: "tn", book: {title: "JS", price: "45"}} 

// 对数组深拷贝后,改变原数组也不会影响到拷贝后的数组
let a = [0, "1", [2, 3]];
let b = JSON.parse(JSON.stringify( a.slice(1) ));
console.log(b); // ["1", [2, 3]]

a[1] = "99";
a[2][0] = 4;

console.log(a); // [0, "99", [4, 3]]
console.log(b); //  ["1", [2, 3]]

由上代码可以看出,改变变量 a 中的引用属性后对 b 没有任何影响,这就是深拷贝的魔力

这种方法虽然可以实现数组或对象深拷贝,但该方法有局限性:

  • 会忽略 undefinedsymbol
  • 不能序列化函数
  • 不能解决循环引用的对象
  • 不能正确处理 new Date()
  • 不能处理正则
let obj = {
  name: "tn",
  a: undefined,
  b: Symbol("tn"),
  c: function() {}
}

let b = JSON.parse(JSON.stringify(obj));
console.log(b); // {name: "tn"}

// 数组中函数会变成 null
let arr = [1, 3, {username: 'kobe'}, function(){}];
let newArr = JSON.parse(JSON.stringify(arr));
newArr[2].username = 'duncan';  
console.log(arr); // [1, 3, {username: "kobe"}, ƒ ()]
console.log(newArr); // [1, 3, {username: 'duncan'}, null]

// 循环引用情况下会报错
let obj = {
  a: 1,
  b: {c: 2, d: 3}
}
obj.a = obj.b;
obj.b.c = obj.a;
let b = JSON.parse(JSON.stringify(obj)); // Uncaught TypeError: Converting circular structure to JSON

// `new Date` 情况下转换结果不正确
new Date(); // Fri Nov 18 2022 02:04:11 GMT+0800 (China Standard Time)
JSON.stringify(new Date()); // '"2022-11-17T18:04:16.747Z"'
JSON.parse(JSON.stringify(new Date())); // '2022-11-17T18:04:20.190Z'

// 以上解决方法转成字符串或时间戳
let date = (new Date()).valueOf();
JSON.parse(JSON.stringify(date));

// 正则情况下,变为空对象
let obj = {name: "tn", a: /'123'/};
console.log(obj); // {name: "tn", a: /'123'/}
let b = JSON.parse(JSON.stringify(obj));
console.log(b); // {name: "tn", a: {}}

lodash 库 _.cloneDeep 方法

var _ = require('lodash');
var obj1 = {a: 1, b: {f: {g: 1}}, c: [1, 2, 3]};
var obj2 = _.cloneDeep(obj1);
console.log(obj1.b.f === obj2.b.f); // false

lodash 的深拷贝函数

jQuery.extend() 方法

用法

$.extend(deepCopy, target, object1, [objectN]) // 第一个参数为 true 就是深拷贝
var $ = require('jquery');
var obj1 = {a: 1, b: {f: {g: 1}}, c: [1, 2, 3]};
var obj2 = $.extend(true, {}, obj1);
console.log(obj1.b.f === obj2.b.f); // false

深拷贝手写封装

在拷贝时判断一下属性值的类型,若是对象则递归调用深拷贝函数,深拷贝是完全拷贝了原对象的内容并寄存在新的内存空间,指向新的内存地址

function deepClone(obj) {
  if (obj === null) return obj;
  if (typeof obj !== "object") return obj;
  let target = Object.prototype.toString.call(obj) == '[object Array]' ? [] : {};
  for (let prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      target[prop] = deepClone(obj[prop]);
    }
  }
  return target;
}

// test
var a = {
  name: "tn",
  book: {
    title: "JS",
    price: "45"
  },
  a1: undefined,
  a2: null,
  a3: 123,
  field: [2, 4, 8]
};
var b = deepClone(a);
console.log(b);
// {
//   a1: undefined,
//   a2: null,
//   a3: 123,
//   book: {
//     title: "JS", 
//     price: "45"
//   },
//   name: "tn",
//   field: [2, 4, 8]
// }

解决循环引用

有种特殊情况需注意就是对象可能存在循环引用的情况,即对象的属性间接或直接的引用了自身的情况,结果是容易进入死循环导致栈内存溢出

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

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

  • 检查 map 中有无克隆过的对象
  • - 直接返回
  • 没有 - 将当前对象作为 key,克隆对象作为value 进行存储
  • 继续克隆
function deepClone(obj, map = new Map()) {
  if (obj === null) return obj;
  if (typeof obj !== "object") return obj;
  let target = Object.prototype.toString.call(obj) == '[object Array]' ? [] : {};
  if (map.get(obj)) { 
    return map.get(obj); 
  }
  map.set(obj, target);
  for (let prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      target[prop] = deepClone(obj[prop], map);
    }
  }
  return target;
}

接下来,可以使用 WeakMap 代替 Map。为什么要这样做?先来看看 WeakMap 的作用:WeakMap 对象是一组键/值对的集合,其键必须是对象,值可以是任意的,且键是弱引用

什么是弱引用?

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

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

若使用 Map 则对象间是存在强引用关系的:

let obj = {name : 'tn'};
const target = new Map(); 
target.set(obj,'code'); 
obj = null;

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

再来看 WeakMap

let obj = {name : 'tn'};
const target = new WeakMap(); target.set(obj,'code'); 
obj = null;

若是 WeakMaptargetobj 存在的就是弱引用关系,当下一次垃圾回收机制执行时这块内存就会被释放掉

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

因此可以使用 WeakMap 来解决循环引用问题

function deepClone(obj, map = new WeakMap()) {
  if (obj === null) return obj;
  if (typeof obj !== "object") return obj;
  let target = Object.prototype.toString.call(obj) == '[object Array]' ? [] : {};
  if (map.has(obj)) { 
    return map.get(obj); 
  }
  map.set(obj, target);
  for (let prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      target[prop] = deepClone(obj[prop], map);
    }
  }
  return target;
}

上面使用了 ES6 中的 WeakMap 来处理,在 ES5 下可以使用数组来处理

function deepClone(obj, uniqueList) {
  if (obj === null) return obj;
  if (typeof obj !== "object") return obj;
  let target = Object.prototype.toString.call(obj) == '[object Array]' ? [] : {};
  if (!uniqueList) uniqueList = []; // 新增代码,初始化数组
  // 数据已经存在,返回保存的数据
  var uniqueData = find(uniqueList, src);
  if (uniqueData) {
    return uniqueData.target;
  };
  // 数据不存在,保存源数据,以及对应的引用
  uniqueList.push({
    source: src,
    target: target
  });
  for (let prop in obj) {
    if (obj.hasOwnProperty(prop)) {
      target[prop] = deepClone(obj[prop], uniqueList);
    }
  }
  return target;
}
// 用上面用例测试 OK

拷贝 Symbol

这里主要针对对象的键是 Symbol 的情况

SymbolES6 下才有,需要一些方法来检测出 Symble 类型

  • 方法一:Object.getOwnPropertySymbols(...)

    该方法可以查找一个给定对象的符号属性时返回一个 symbol 类型的数组。注意:每个初始化的对象都是没有自己的 symbol 属性的,因此这个数组可能为空,除非你已经在对象上设置了 symbol 属性 -- MDN

    var obj = {};
    var a = Symbol("a"); // 创建新的 symbol 类型
    var b = Symbol.for("b"); // 从全局的 symbol 注册表设置和取得 symbol
    
    obj[a] = "localSymbol";
    obj[b] = "globalSymbol";
    
    var objectSymbols = Object.getOwnPropertySymbols(obj);
    
    console.log(objectSymbols.length); // 2
    console.log(objectSymbols)         // [Symbol(a), Symbol(b)]
    console.log(objectSymbols[0])      // Symbol(a)
    

    思路就是先查找有没有 Symbol 属性,若查找到则先遍历处理 Symbol 情况,然后再处理正常情况

    function deepClone(obj, map = new WeakMap()) {
      if (obj === null) return obj;
      if (typeof obj !== "object") return obj;
      let target = Object.prototype.toString.call(obj) == '[object Array]' ? [] : {};
      
      if (map.has(obj)) { 
        return map.get(obj); 
      }
      map.set(obj, target);
      
      let symKeys = Object.getOwnPropertySymbols(obj); // 查找
      if (symKeys.length) { // 查找成功
        symKeys.forEach(symKey => {
          target[symKey] = deepClone(obj[symKey], map); 
        })
      }
      
      for (let prop in obj) {
        if (obj.hasOwnProperty(prop)) {
          target[prop] = deepClone(obj[prop], map);
        }
      }
      return target;
    }
    
    var a = {
      name: "tn",
      book: {
        title: "JS",
        price: "45"
      },
      a1: undefined,
      a2: null,
      a3: 123,
      field: [2, 4, 8]
    };
    var sym1 = Symbol("a"); // 创建新的 symbol 类型
    var sym2 = Symbol.for("b"); // 从全局的symbol注册表设置和取得 symbol
    
    a[sym1] = "localSymbol";
    a[sym2] = "globalSymbol";
    
    var b = deepClone(a);
    console.log(b);
    
    // {
    //   a1: undefined
    //   a2: null
    //   a3: 123,
    //   book: {title: "JS", price: "45"},
    //   field: (3) [2, 4, 8],
    //   name: "tn",
    //   Symbol(a): "localSymbol",
    //   Symbol(b): "globalSymbol"
    // }
    
  • 方法二:Reflect.ownKeys(...)

    返回一个由目标对象自身的属性键组成的数组。它的返回值等同于Object.getOwnPropertyNames(target).concat(Object.getOwnPropertySymbols(target)) -- MDN

    Reflect.ownKeys({z: 3, y: 2, x: 1}); // [ "z", "y", "x" ]
    Reflect.ownKeys([]); // ["length"]
    
    var sym = Symbol.for("comet");
    var sym2 = Symbol.for("meteor");
    var obj = {
      [sym]: 0, 
      "str": 0, 
      "773": 0, 
      "0": 0,
      [sym2]: 0, 
      "-1": 0, 
      "8": 0, 
      "second str": 0
    };
    Reflect.ownKeys(obj); // [ "0", "8", "773", "str", "-1", "second str", Symbol(comet), Symbol(meteor) ]
    // 注意顺序
    // Indexes in numeric order, 
    // strings in insertion order, 
    // symbols in insertion order
    

    这里使用了 Reflect.ownKeys() 获取所有的键值,同时包括 Symbol,对 src 遍历赋值即可

    function deepClone(obj, map = new WeakMap()) {
      if (obj === null) return obj;
      if (typeof obj !== "object") return obj;
      let target = Object.prototype.toString.call(obj) == '[object Array]' ? [] : {};
        
      if (map.has(obj)) { 
        return map.get(obj); 
      }
      map.set(obj, target);
        
      Reflect.ownKeys(obj).forEach(key => {
        target[key] = deepClone(obj[key], map); 
      })  
      return target;
    }
    // 使用上面例子测试 OK
    

性能优化

在上面的代码中,遍历数组和对象都使用了 for in 方式,实际上 for in 在遍历时效率是非常低的,我们来对比下常见的三种循环 for、while、for in 的执行效率:

image.png

由上可以看到 while 的效率是最好的,因此可以想办法把 for in 遍历改变为 while 遍历

我们先使用 while 来实现一个通用的 forEach 遍历,callback 是遍历的回调函数,其可以接收每次遍历的 valueindex 两个参数:

function forEach(arr, callback) {
  let index = -1;
  const length = arr.length;
  while (++ index < length) {
    callback(arr[index], index);
  }
  return arr;
}

下面对的 deepClone 函数进行改写,当遍历数组时直接使用 forEach 进行遍历,当遍历对象时先使用Object.keys 取出所有的 key 再进行遍历,然后在遍历时 forEach 会调函数的 value 当作 key 使用:

function deepClone(obj, map = new WeakMap()) {
  if (obj === null) return obj;
  if (typeof obj !== "object") return obj;
  let target = Object.prototype.toString.call(obj) == '[object Array]' ? [] : {};
 
  if (map.has(obj)) { 
    return map.get(obj); 
  }
  map.set(obj, target);
  
  const keys = isArray ? undefined : Reflect.ownKeys(obj);
  forEach(keys || target, (value, key) => { 
    if(keys) { 
      key = value; 
    } 
    target[key] = deepClone(obj[key], map); });
  return target;
}

兼容其他数据类型

首先,判断是否为引用类型,需要考虑 functionnull 两种特殊的数据类型:

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 getType(target) {
  return Object.prototype.toString.call(target);
}

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

const mapType = '[object Map]';
const setType = '[object Set]';
const arrType = '[object Array]';
const objectType = '[object Object]';

const boolType = '[object Boolean]';
const dateType = '[object Date]';
const errorType = '[object Error]';
const numberType = '[object Number]';
const regexpType = '[object RegExp]';
const stringType = '[object String]';
const symbolType = '[object Symbol]';

在上面的集中类型中,我们简单将他们分为两类,分别为它们做不同的拷贝

  • 可以继续遍历的类型

    • 上面的 objectarrayMapSet 等都属于可以继续遍历的类型,另外还有等都是可以继续遍历的类型,因为它们内存都还可以存储其他数据类型的数据,这里我们只考虑这四种
    • 这几种类型还需要继续进行递归,首先需要获取它们的初始化数据,如 []{},我们可以通过拿到 new target.constructor() 的方式来通用的获取(注意:也可以使用Object.create(target.constructor.prototype),但这这种方式仅使用在 Object/Array 上,若是 Map/Set 还是需要使用 new target.constructor() 来创建一个空集合,否则调用Map/Set 上的方法会出现问题,所以使用时在类型上还需要加以区别)
    • 如:const target = {}const target = new Object() 的语法糖,这种方法还有一个好处:因为使用了原对象的构造方法,所以它可以保留对象原型上的数据,若直接使用普通的 {} 则原型必然是丢失了的
    function getInit(obj) {
      return new obj.constructor();
    }
    

    改写 deepClone 函数,对可继续遍历的数据类型进行处理:

    function deepClone(obj, map = new WeakMap()) {
      // 克隆原始类型 
      if (!isObject(obj)) return obj; 
    
      // 初始化 
      const type = getType(obj); 
      let target = getInit(obj); 
    
      // 防止循环引用
      if (map.has(obj)) return map.get(obj); 
      map.set(obj, target);
    
      // 克隆 set
      if (type === setType) { 
        obj.forEach(value => { 
          target.add(deepClone(value, map));
        }); 
        return target; 
      }
    
      // 克隆 map 
      if (type === mapType) { 
        obj.forEach((value, key) => { 
          target.set(key, deepClone(value, map)); 
        }); 
        return target; 
      }
    
      // 克隆对象和数组
      const keys = type === arrType ? undefined : Reflect.ownKeys(obj);
      forEach(keys || target, (value, key) => { 
        if(keys) { key = value; } 
        target[key] = deepClone(obj[key], map); 
      });
      return target;
    }
    
    var a = {
      name: "tn",
      book: {title: "JS", price: "45"},
      a1: undefined,
      a2: null,
      a3: 123,
      field: [2, 4, 8],
    };
    var sym1 = Symbol("a"); // 创建新的 symbol 类型
    var sym2 = Symbol.for("b"); // 从全局的symbol注册表设置和取得 symbol
    a[sym1] = "localSymbol";
    a[sym2] = "globalSymbol";
    
    var map = new Map();
    var set = new Set();
    map.set('a', 1);
    map.set('b', 2);
    set.add('a');
    set.add(33);
    a.map = map;
    a.set = set;
    
    var b = deepClone(a);
    console.log(b);
    
    // {
    //   a1: undefined,
    //   a2: null,
    //   a3: 123,
    //   book: {title: 'JS', price: '45'},
    //   field: (3) [2, 4, 8],
    //   map: Map(2) {'a' => 1, 'b' => 2},
    //   name: "tn",
    //   set: Set(2) {'a', 33},
    //   Symbol(a): "localSymbol",
    //   Symbol(b): "globalSymbol"
    // }
    
  • 不可以继续遍历的类型

    • 其他剩余的类型我们把它们统一归类成不可处理的数据类型,依次进行处理
    • BoolNumberStringStringDateErrorRegExp 这几种类型都可以直接用构造函数和原始数据创建一个新对象
    // 克隆 symbol
    function cloneSymbol(obj) { 
      return Object(Symbol.prototype.valueOf.call(obj)); 
    }
    
    // 克隆正则
    function cloneReg(obj) { 
      const reFlags = /\w*$/; 
      const result = new obj.constructor(obj.source, reFlags.exec(obj)); 
      result.lastIndex = obj.lastIndex; 
      return result; 
    }
    
    
    function cloneOtherType(obj, type) {
      switch (type) {
        case boolType:
        case numberType:
        case stringType:
        case errorType:
        case dateType:
          return new obj.constructor(obj);
        case regexpTag:
          return cloneReg(obj);
        case symbolTag:
          return cloneSymbol(obj);
        default:
          return null;
      }
    }
    
  • 简单处理函数

    function cloneFunc(target) {
      const isFunc = typeof target == 'function';
      return isFunc ? target : {};
    }
    

    当然处理函数涉及到多层柯里化、函数本身的原型等,这里只是简单返回

  • 当然还有很多数据类型这里没有写到

完整代码

// 常见数据类型
const mapType = '[object Map]';
const setType = '[object Set]';
const arrType = '[object Array]';
const objectType = '[object Object]';
const boolType = '[object Boolean]';
const dateType = '[object Date]';
const errorType = '[object Error]';
const numberType = '[object Number]';
const regexpType = '[object RegExp]';
const stringType = '[object String]';
const symbolType = '[object Symbol]';
const funcType = '[object Function]';

// 可继续遍历的数据类型
const typeArr = [mapType, setType, arrType, objectType];

// 封装 while 循环
function forEach(arr, callback) {
  let index = -1;
  const len = arr.length;
  while(++ index < len) {
    callback && callback(arr[index], index)
  }
  return arr;
}

// 判断是否是引用类型
function isObject(target) {
  const type = typeof target;
  return target !== null && (type === 'object' || type === 'function');
}

// 获取实际数据类型
function getType(target) {
  return Object.prototype.toString.call(target);
}

// 初始化被克隆对象
function getInit(obj) {
  return new obj.constructor();
}

// 复制 symbol
function cloneSymbol(target) {
  return Object(Symbol.prototype.valueOf.call(target));
}

// 复制正则
function cloneReg(target) {
  const reFlags = /\w*$/;
  const result = new target.constructor(target.source, reFlags.exec(target));
  return result;
}

// 复制函数
function cloneFunc(target) {
  const isFunc = typeof target == 'function';
  return isFunc ? target : {};
}

// 复制其他类型
function cloneOtherType(target, type) {
  switch(type) {
    case boolType:
    case numberType:
    case stringType:
    case dateType:
    case errorType:
      return new target.constructor(target);
    case regexpType:
      return cloneReg(target);
    case symbolType:
      return cloneSymbol(target);
    case funcType:
      return cloneFunc(target);
    default:
      return null;
  }
}

// 深拷贝函数
function deepClone(obj, map = new WeakMap()) {
  // 基本数据类型,直接返回
  if(!isObject(obj)) return obj;
  
  // 根据数据类型进行操作
  const type = getType(obj);
  let target;
  if(typeArr.includes(type)) {
    target = getInit(obj);
  } else {
    return cloneOtherType(obj, type);
  }
  
  // 防止循环引用
  if(map.has(obj)) return obj;
  map.set(obj, target);
  
  // 处理 set 类型
  if(type === setType) {
    obj.forEach(value => {
      target.add(deepClone(value));
    })
    return target;
  }
  
  // 处理 map 类型
  if(type === mapType) {
    obj.forEach((value, key) => {
      target.set(key, deepClone(value));
    })
    return target;
  }
  
  // 处理对象和数组
  const keys = type === arrType ? undefined : Object.keys(obj);
  forEach(keys || obj, (value, key) => {
    if(keys) {
      key = value;
    }
    target[key] = deepClone(obj[key], map);
  })
  return target;
}

// test
var map = new Map();
map.set('a', 'sss');
map.set('b', 22);

var set = new Set();
set.add('tn');
set.add(33);

var a = {
  name: "tn",
  book: {title: "JS", price: "45"},
  a1: undefined,
  a2: null,
  a3: 123,
  field: [2, 4, 8],
  map,
  set,
  bool: new Boolean(true), 
  num: new Number(2),
  str: new String(2),
  date: new Date(),
  reg: /\d+/,
  error: new Error(),
  symbol: Object(Symbol(1)),
  func1: () => { console.log('code秘密花园'); }, 
  func2: function (a, b) { return a + b; }
};

var sym1 = Symbol("a"); // 创建新的 symbol 类型
var sym2 = Symbol.for("b"); // 从全局的symbol注册表设置和取得 symbol
a[sym1] = "localSymbol";
a[sym2] = "globalSymbol";

var b = deepClone(a);
console.log(b);

image.png

未来的深拷贝

其实,浏览器自己实现了深拷贝函数,这个 Web API 为 structuredClone(),详情可访问 MDN 和 HTML5 规范

很显然,这是一个新的 API,从兼容性来考虑,很多浏览器不支持 image.png

另外 MDN 也介绍了实现这个 API 用到的算法:Structured_clone_algorithm,感兴趣的可以去看看

应用场景

  • 浅拷贝

    • 对于一层结构的 ArrayObject 想要拷贝一个副本时使用
    • vuemixin 是浅拷贝的一种复杂型式
  • 深拷贝

    • 复制深层次的 object 数据结构,如想对某个数组或对象的值进行修改,但又要保留原数组或对象的值不被修改,此时就可以用深拷贝来创建一个新的数组或对象

总结

关于深/浅拷贝的使用选择,我认为保险的做法是所有的拷贝都用深拷贝,且在实际开发中一般是直接引用三方库,毕竟自己写的深拷贝可能存在各种边界情况考虑不到的问题

另外,若无统一规范,可根据实际的目的而进行选择,如:能用 JSON.parse(JSON.stringify(object))实现你的功能,就没必要去引入 lodash 库的 cloneDeep 方法,这样反倒徒增项目的打包体积

当然,若团队有规范,为了统一代码风格或为了避免潜在的风险,要求统一用第三方库的方法,增加一些打包的体积也没关系,毕竟企业级的项目还是要严谨一点

总之,各取所长就行

参考资料

如何写出一个惊艳面试官的深拷贝?
深拷贝的终极探索(99%的人都不知道)
JavaScript 如何完整实现深度Clone对象?
github lodash 源码
MDN 结构化克隆算法