面试必备知识点之深浅拷贝

631 阅读7分钟

JS的数据类型分为两种,一种是基本数据类型,另一种是引用数据类型。基本数据类型有 5+2 种:undefined,null,number,boolean,string,symbol(ES6),BigInt(ES6)。引用数据类型只有1种:object。

在javascript中除了几大基本类型以外其他的都为引用类型。

基本类型与引用类型

基本类型值在内存中占据固定大小,保存在栈内存中。

引用类型的值是对象,保存在堆内存中,而栈内存存储的是对象的变量标识符以及对象在堆内存中的存储地址。

基本类型的拷贝方式从一个变量向另外一个新变量复制基本类型的值,会创建这个值的一个副本,并将该副本复制给新变量

引用类型则是从一个变量向另一个新变量复制引用类型的值,其实复制的是内存地址,也可以类比做C语言中的指针,最终两个变量最终都指向同一个对象

深拷贝与浅拷贝

浅拷贝:仅仅是复制了引用,彼此之间的操作会互相影响

深拷贝:在堆中重新分配内存,不同的地址,相同的值,互不影响

image.png

const Object = {
    id: 1,
    name: "node1",
    children: {
        id: 2,
        name: "node2",
        children: {
            id: 4,
            name: "node4",
            children: {
            },
            id: 5,
            name: "node5",
            children: {
            },
            id: 6,
            name: "node6",
            children: {
            }
        },
        id: 3,
        name: "node3",
        children: {
            id: 7,
            name: "node7",
            children: {
            }
        }
    }
}

以上述对象为例浅拷贝基本只能拷贝一层对象,对于对于复杂嵌套场景就无能为力了,创建的对象也与原对象相关一个修改另一个也跟着改变,并不能满足我们的日常需求,因此深拷贝还是很重要的。本着先易后难的原则我们先来看一眼浅拷贝。

浅拷贝 (只能拷贝一层对象)

先来看一看现成JavaScript提供的方法

方法一:object.assign

let target = {};
let source = { a: { b: 1 } };
Object.assign(target, source);
console.log(target); // { a: { b: 1 } };

使用 object.assign 方法有几点需要注意:

  • 它不会拷贝对象的继承属性;
  • 它不会拷贝对象的不可枚举的属性;
  • 可以拷贝 Symbol 类型的属性。

方法二:扩展运算符方式

/* 对象的拷贝 */
let obj = {a:1,b:{c:1}}
let obj2 = {...obj}
obj.a = 2
console.log(obj)  //{a:2,b:{c:1}} console.log(obj2); //{a:1,b:{c:1}}
obj.b.c = 2
console.log(obj)  //{a:2,b:{c:2}} console.log(obj2); //{a:1,b:{c:2}}

/* 数组的拷贝 */
let arr = [1, 2, 3];
let newArr = [...arr]; //跟arr.slice()是一样的效果

方法三:concat 拷贝数组

let arr = [1, 2, 3];
let newArr = arr.concat();
newArr[1] = 100;
console.log(arr);  // [ 1, 2, 3 ]
console.log(newArr); // [ 1, 100, 3 ]
方法四:slice 拷贝数组
let arr = [1, 2, {val: 4}];
let newArr = arr.slice();
newArr[2].val = 1000;
console.log(arr);  //[ 1, 2, { val: 1000 } ]

下面尝试自己写一个浅拷贝

自我实现

const shallowClone = (target) => {
  if (typeof target === 'object' && target !== null) {
    const cloneTarget = Array.isArray(target) ? []: {};
    for (let prop in target) {
      if (target.hasOwnProperty(prop)) {
          cloneTarget[prop] = target[prop];
      }
    }
    return cloneTarget;
  } else {
    return target;
  }
}

原理:先判断要拷贝的内容的类型,如果为基本类型则直接复制,如果为引用类型则遍历对象的所有属性,如为数组则循秩访问逐个复制 浅拷贝的常用方法和实现原理学会了那就来啃一啃硬骨头

深拷贝 (完整的拷贝)

先从最简单的无脑序列化开始

方法一:乞丐版(JSON.stringfy)JSON与对象互化

let obj1 = { a:1, b:[1,2,3] }
let str = JSON.stringify(obj1);
let obj2 = JSON.parse(str);
console.log(obj2);   //{a:1,b:[1,2,3]} 
obj1.a = 2;
obj1.b.push(4);
console.log(obj1);   //{a:2,b:[1,2,3,4]}
console.log(obj2);   //{a:1,b:[1,2,3]}

原理:我们可以理解为,将原始数据转换为新字符串,再通过新字符串还原为一个新对象,这种改变数据类型的方式,间接的绕过了拷贝对象引用的过程,也就谈不上影响原始数据。 局限性

  1. 拷贝的对象的值中如果有函数、undefined、symbol 这几种类型,经过 JSON.stringify 序列化之后的字符串中这个键值对会消失;
  2. 拷贝 Date 引用类型会变成字符串;
  3. 无法拷贝不可枚举的属性;
  4. 无法拷贝对象的原型链;
  5. 拷贝 RegExp 引用类型会变成空对象;
  6. 对象中含有 NaN、Infinity 以及 -Infinity,JSON 序列化的结果会变成 null;
  7. 无法拷贝对象的循环应用,即对象成环 (obj[key] = obj)。

既然浅拷贝可以逐个循环遍历那么深拷贝,用递归遍历不就好了吗, 我们只需要将所有属性即其嵌套属性原封不动的复制给新变量一份就可以了。

理论存在开始 Coding ...

方法二:基础版(手写递归实现)

let obj1 = {
  a:{
    b:1
  }
}

function deepClone(obj) { 
  let cloneObj = {}
  for(let key in obj) {                 //遍历
    if(typeof obj[key] ==='object') { 
      cloneObj[key] = deepClone(obj[key])  //是对象就再次调用该函数递归
    } else {
      cloneObj[key] = obj[key]  //基本类型的话直接复制值
    }
  }
  return cloneObj
}
let obj2 = deepClone(obj1);
obj1.a.b = 2;
console.log(obj2);   //  {a:{b:1}}

局限性

  1. 这个深拷贝函数并不能复制不可枚举的属性以及 Symbol 类型;
  2. 这种方法只是针对普通的引用类型的值做递归复制,而对于 Array、Date、RegExp、Error、Function 这样的引用类型并不能正确地拷贝;
  3. 对象的属性里面成环,即循环引用没有解决。

再努努力胜利就在眼前,接下来的任务就是处理循环调用,引用复制和边界条件,有亿点点困难,休息一下回顾一下上面的实现,进阶版马上就来。

方法三:改进版(进阶实现)

const isComplexDataType = obj => (typeof obj === 'object' || typeof obj === 'function') && (obj !== null)
const deepClone = function (obj, hash = new WeakMap()) {
  if (obj.constructor === Date) 
  return new Date(obj)       // 日期对象直接返回一个新的日期对象
  if (obj.constructor === RegExp)
  return new RegExp(obj)     //正则对象直接返回一个新的正则对象
  //如果循环引用了就用 weakMap 来解决
  if (hash.has(obj)) return hash.get(obj)
  let allDesc = Object.getOwnPropertyDescriptors(obj)
  //遍历传入参数所有键的特性
  let cloneObj = Object.create(Object.getPrototypeOf(obj), allDesc)
  //继承原型链
  hash.set(obj, cloneObj)
  for (let key of Reflect.ownKeys(obj)) { 
    cloneObj[key] = (isComplexDataType(obj[key]) && typeof obj[key] !== 'function') ? deepClone(obj[key], hash) : obj[key]
  }
  return cloneObj
}
// 下面是验证代码
let obj = {
  num: 0,
  str: '',
  boolean: true,
  unf: undefined,
  nul: null,
  obj: { name: '我是一个对象', id: 1 },
  arr: [0, 1, 2],
  func: function () { console.log('我是一个函数') },
  date: new Date(0),
  reg: new RegExp('/我是一个正则/ig'),
  [Symbol('1')]: 1,
};
Object.defineProperty(obj, 'innumerable', {
  enumerable: false, value: '不可枚举属性' }
);
obj = Object.create(obj, Object.getOwnPropertyDescriptors(obj))
obj.loop = obj    // 设置loop成循环引用的属性
let cloneObj = deepClone(obj)
cloneObj.arr.push(4)
console.log('obj', obj)
console.log('cloneObj', cloneObj)

改进点

  1. 针对能够遍历对象的不可枚举属性以及 Symbol 类型,我们可以使用 Reflect.ownKeys 方法;
  2. 当参数为 Date、RegExp 类型,则直接生成一个新的实例返回;
  3. 利用 Object 的 getOwnPropertyDescriptors 方法可以获得对象的所有属性,以及对应的特性,顺便结合 Object 的 create 方法创建一个新对象,并继承传入原对象的原型链;
  4. 利用 WeakMap 类型作为 Hash 表,因为 WeakMap 是弱引用类型,可以有效防止内存泄漏,(你可以关注一下 Map 和 weakMap 的关键区别,这里要用 weakMap)作为检测循环引用很有帮助,如果存在循环,则引用直接返回 WeakMap 存储的值。

上面的解法是老师讲的,还是有一些难度的,需要关注这几点,边界条件单独重新创建新实例,不可枚举方法使用 Reflect.ownKeys 方法,如何使用 getOwnPropertyDescriptors ,以及 WeakMap 处理循环引用

相关链接

Reflect.ownKeys Object.getOwnPropertyDescriptors() weakMap

总结

本文是整理的浅拷贝和深拷贝中涉及的知识点,在工作中是非常重要的,在面试中也是必考的,希望对小伙伴们有所帮助!

❤️ 感谢大家

如果你觉得这篇内容对你挺有有帮助的话:

点赞支持下吧,让更多的人也能看到这篇内容(收藏不点赞,都是耍流氓 -_-)

关注公众号咸鱼爱前端,我们一起学习一起进步。

觉得不错的话,也可以阅读其他文章(感谢朋友的鼓励与支持🌹🌹🌹):

Nodejs 实现定时爬虫

React-Query 让你的状态管理更优雅

前端页面布局学习神器