likes
comments
collection
share

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

作者站长头像
站长
· 阅读数 22

壹 ❀ 引

深浅拷贝属于js中一个比较基础但重要的概念,日常开发中很多地方都会需要使用到深拷贝,在操作数据时,我们都应该潜意识的思考是否会对原数据造成影响。而且在面试时,我也遇到过如何实现一个深拷贝,需要注意哪些边界情况的问题,对于初学者而言,了解这个概念是非常有必要的。

本文是我在17年刚从事前端不久时所写的一篇文章,我觉得作为基础入门去了解深拷贝的概念还挺不错,所以决定迁移过来,至于从零手写一个实用的深拷贝,我决定再开一篇文章,那么本文开始。

贰 ❀ 深拷贝与浅拷贝的区别

如何区分深拷贝与浅拷贝,简单点来说,就是假设B复制了A,当修改A时,看B是否会发生变化,如果B也跟着变了,说明这是浅拷贝,拿人手短;如果B没变,那就是深拷贝,自食其力。

此篇文章中也会简单阐述到栈堆基本数据类型引用数据类型,因为这些概念能更好的让你理解深拷贝与浅拷贝。

我们来举个浅拷贝例子:

let a=[0,1,2,3,4],
    b=a;
console.log(a===b);
a[0]=1;
console.log(a,b);

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

嗯?明明b复制了a,为啥修改数组a,数组b也跟着变了,这里我不禁陷入了沉思。那么这里,就得引入基本数据类型引用数据类型的概念了。

叁 ❀ 基本数据类型与引用数据类型

基本数据类型有哪些?大家熟悉的可能有number,string,boolean,null,undefined,symbol,BigInt(任意精度整数)七类。

引用数据类型也非常多,比如对象{},数组[],函数fn,正则等等。

而这两类数据存储分别是这样的:

基本类型--名值存储在栈内存中,例如let a = 1;

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

当你执行复制let b = a时,栈内存会开辟一个新内存,比如这样:

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

多以当你此时修改a = 2时,对b并不会造成影响,因为此时的b已自食其力,翅膀硬了,不受a的影响了。当然,let a = 1, b = a;虽然b不受a影响,但这也算不上深拷贝,因为深拷贝本身只针对较为复杂的object类型数据。

我们再看来看看引用类型数据的存储情况,比如let a = [1,2,3],此时变量名存在栈内存中,值存在于堆内存中,但是栈内存会提供一个引用的地址指向堆内存中的值,我们以上面浅拷贝的例子画个图:

图4

而当执行let b = a时,其实复制的是a的引用地址,并非堆里面具体的值。

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

因此假设此时执行a[0] = 2对数组进行修改,由于ab指向的是同一个地址,所以b自然也受到了影响,这就是所谓的浅拷贝了。

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

那要是在堆内存中也开辟一个新的内存专门为b存放值,就像基本类型那样,岂不就达到深拷贝的效果了。

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

肆 ❀ 实现简单的深拷贝(均为乞丐版)

写在前面,以下的实现均为简单的实现,并适合实际业务开发,像loadsh三方工具库均有提供像深拷贝常用的工具函数,下面的实现作为了解即可,较为强大的手写深拷贝我会单独出一篇文章。

肆 ❀ 壹 JSON.parse与stringify

原理其实很简单,先将对象转成JSON字符串后并复制一份,再通过stringfy还原成对象,依次达到深拷贝的目的:

function deepClone(obj) {
  // 转JSON字符串并复制
  let _obj = JSON.stringify(obj),
    // 还原成对象
    objClone = JSON.parse(_obj);
  return objClone
}
let a = [0, 1, [2, 3], 4],
  b = deepClone(a);
a[0] = 1;
a[2][0] = 1;
console.log(a, b);

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

可以看到,这下b是完全不受a的影响了,但这个方法有个大问题,对于undefined或者函数在深拷贝的过程中均会造成丢失,比如:

let a = {
    a: undefined,
    b: 1,
    c: null,
    d: function () {}
  },
  // 使用上面我们实现的深拷贝
  b = deepClone(a);
console.log(a, b);

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

肆 ❀ 贰 递归实现简单的深拷贝

我们假定有一个这样的对象,那是不是在拷贝前,我们自定义一个空对象,然后判断每个key对应的value,看value是不是一个对象或者数组,如果是对象/数组,我们需要递归继续拷贝,如果不是则可以直接赋值,以此来模拟拷贝过程:

const obj = {
  name: 1,
  user: {
    userName: 'echo',
    class: [1, 2, 3],
  },
  fn: function(){}
}
const deepClone = (obj) => {
  // 这里只考虑了object类型,基础类型或者其它类型直接返回
  if (typeof obj !== 'object') {
    return obj;
  };
  const obj_ = Array.isArray(obj) ? [] : {};
  for (let k in obj) {
    if (obj.hasOwnProperty(k)) {
      if (typeof obj[k] === 'object') {
        obj_[k] = deepClone(obj[k])
      } else {
        obj_[k] = obj[k];
      }
    }
  }
  return obj_;
}
​
const obj_ = deepClone(obj);
obj.user.class[0] = 2;
console.log(obj, obj_);

JS 深入了解深拷贝和浅拷贝的区别,实现一个简单的深拷贝(基础篇)

可以看到不管对象属性嵌套多深,在拷贝完成后修改属性,都不会对新对象造成影响,当然此方法距离生产可用仍然有一些差距。

伍 ❀ 识别数组中浅拷贝方法

在实际开发中,我们常常用到数组中一些会返回新数组的api,比如slice,此方法可用于裁剪一定范围的元素,并返回一个新数组:

const a = [[1, 2], 2, 3, 4];
const b = a.slice(0);
a[0][0] = 2;
console.log(a, b);// [2, 2, 3, 4] [1, 2, 3, 4]

有人一看,哎,这明显是深拷贝啊,我改了原数组,对于b并未造成任何影响。

但事实上,slice在拷贝数组时,是一个接一个元素进行复制,这个数组中每个元素都是数字,基本类型,你赋值过去当然不会造成影响,假设我们将元素改成数组,比如:

const a = [[1, 2], 2, 3, 4];
const b = a.slice(0);
a[0][0] = 2;
console.log(a, b);// [[2, 2], 2, 3, 4] [[2, 2], 2, 3, 4]

可以看到,由于第一个元素是数组,slice在拷贝时依旧是拿了数组的引用地址,改一个还是会影响另一方。

除此之外,像concat,拓展运算符...filter一样有这样的问题:

const a = [1, 2, [1, 2]];
const b = [...a]
const c = [].concat(a);
a[2][1] = 3;
console.log(a, b, c);// [1, 2, [1, 3]]  [1, 2, [1, 3]]  [1, 2, [1, 3]]
​
​
const a = [1, 2, 3, [1, 2]];
const b = a.filter(e => typeof e !== 'number');
a[3][0] = 5
console.log(a, b);// [1, 2, 3, [5, 2]]   [5, 2]

所以在日常数组操作时,假设有深拷贝的场景,这一点一定要注意甄别。

陆 ❀ 总

那么到这里,我们简单普及了深拷贝与浅拷贝的区别,以及简单的实现了一个属于自己的深拷贝。除此之外,我们特意提及了数组api中一些看似深拷贝的数组api,也便于大家在开发中更安全的去处理引用数据类型的复制。

更完美的深拷贝手写,我们在下篇文章见,那么本文到这里结束。