JavaScript 深拷贝 浅拷贝

234 阅读9分钟

数据类型

ECMAScript中有5种简单数据类型 (也称为基本数据类型):Undefined、Null、Boolean、Number和String。还有一种复杂数据类型Object,Object本质上是由一组无序的明值对组成的。

看一个例子:

let a = 1;
let b = a;
a = 23;

输出a,b是什么?如下图所示:

再看一个例子:

let a = [12, 56];
let b = a;
a[1] = 1024;

输出a,b又是什么?如下图所示:

明明是一样的操作,b复制了a,第一个例子,修改a,对b没有任何影响,第二个例子,修改a,b也跟着修改了o_O???

基本数据类型存储

基本数据类型,名值是存储在栈内存

当我们定义a的时候,会在栈内存中开辟一个内存空间,如图所示:

我们定义b并复制a的时候,会在栈内存中新开辟一个内存空间,如下图所示:
所以当我们修改a为23,并不会对b造成什么影响。

复杂(引用)数据类型存储

引用数据类型,名存于栈内存中,值存于堆内存中,栈内存会提供一个引用地址指向堆内存中的值

当我们定义a的时候,在栈内存中开辟一个内存空间存储变量名a,栈内提供引用地址指向堆内存中的值[12, 56],如下图所示:

定义b并复制a的时候,复制的只是a的引用地址,并没有复制堆内存中的值,如下图所示:
当我们修改a[1]=1024的时候,由于a和b都指向同一个地址,b也被影响了。如图所示:

赋值并非真正的浅拷贝

首先我们看一下赋值,例子如下:

let a = [1, 2, { p1: 12, p2: 25, d: 8 }];
let b = a;
a[1] = 100;
a[2].d = 999;

输出a,b的结果如下:

图-1

接着我们看一下浅拷贝,浅拷贝的实现并不难,存在多种实现方式,以下给出一种实现方法(并非最佳实践),例子如下:

function shallowClone(source) {
  let cloneResult = Array.isArray(source) ? [] : {};
  if (source && typeof source === 'object') {
    for (let k in source) {
      if (source.hasOwnProperty(k)) {
        cloneResult[k] = source[k];
      }
    }
  }
  return cloneResult;
}

let a = [1, 2, { p1: 12, p2: 25, d: 8 }];
let b = shallowClone(a);
a[1] = 100;
a[2].d = 999;

输出a,b的结果如下:

图-2

比较赋值浅拷贝,如果采用赋值的方式赋值给b,不管原数据a的第一层数据是否为基本数据类型,改变a第一层数据,b也跟着改变,如图-1所示;如果是采用浅拷贝的方式拷贝给b,当原数据a的第一层数据为基本数据类型的时候,改变a的第一层数据,b是不会跟着改变的;如果原数据中包含引用数据类型,改变a中的引用类型数据,b同样会跟着改变,如图-2所示。

看到这里,我们应该能明白赋值和浅拷贝的区别了*.。(๑・∀・๑)*.。

Object.assign()实现浅拷贝

Object.assign() 方法用于将所有可枚举属性的值从一个或多个源对象复制到目标对象。它将返回目标对象。 (MDN Web 文档

Object.assign()实现的是浅拷贝。我们给出例子:

let a = { g: 12, h: 56, k: { t: 23, y: 70 } };
let b = Object.assign({}, a);

输出a,b的结果如下:

接下来我们加上两句代码:

a.h = 10000;
a.k.y = 'nice';

输出a,b的结果如下:

跟上面手动实现的浅拷贝的结果一样。

深拷贝

深拷贝是对复杂数据类型Object以及该Object以下的所有子层级的属性进行复制。深拷贝本身只针对较为复杂的object类型数据!

我们简单的聊一聊两种类型判断的方法(还有其他类型判断方法,这里就不做讲解,后续一定会出文章详细讲解的(•̀ᴗ•́)و ̑̑)目的是为了方便我们理解深拷贝。

typeof这种类型判断是不精准的,我们测试一下:

针对基本数据类型Number,String,Boolean,Undefined是可以准确判断出来的,但是对于Null的话,执行之后返回的是"object",数组,对象返回的也都是"object",对于function,返回的是"function",从上图可以看出来。

Object.prototype.toString.call()这个类型判断是精准的,我们可以测试一下:

对于每一种数据都可以得出相应的判断结果。

虽然我们强调了深拷贝本身只针对较为复杂的object类型数据!,但是避免不了好奇的心,我硬是要把基本类型来进行深拷贝,我就想看看结果╮(๑•́ ₃•̀๑)╭

递归实现深拷贝

深拷贝实现的方法有多种,以下采用typeof和递归封装了一个深拷贝的函数,并非为最佳实践,大家可以自己多尝试!

function deepClone(source) {
  if (typeof source !== 'object') {
    // 如果判断结果为Number、String、Boolean、Undefined以及Function的直接返回原数据
    return source;
  }
  // 如果原数据通过typeof判断之后为object并且取反为true,那原数据为null,同样直接返回原数据
  if (!source) return source;
  // 判断原数据是数组还是对象
  let cloneResult = Array.isArray(source) ? [] : {};
  for (let k in source) {
    // hasOwnProperty() 方法会返回一个布尔值,指示对象自身属性中是否具有指定的属性(也就是是否有指定的键)
    if (source.hasOwnProperty(k)) {
      if (source[k] && typeof source[k] === 'object') {
        cloneResult[k] = deepClone(source[k]);
      } else {
        cloneResult[k] = source[k];
      }
    }
  }
  return cloneResult;
}


let a = {
  g: 12,
  h: 56,
  k: [null, 45, undefined],
  f: {
    fun: () => {
      console.log('asdf');
    }
  }
};
let b = deepClone(a);

参考下面的图可能会更好理解:

我们来看看执行结果:
我们加多几行代码

a.h = 90;
a.k[2] = 1234;
a.f.fun = () => {
  return 'good';
};

再看看执行效果:

修改a中属性的值,对b已经没有任何影响了,大家可以尝试修改b中属性的值,看看对a是否有影响。

JSON.parse(JSON.stringify())实现深拷贝

使用JSON.parse(JSON.stringify())也可以实现深拷贝,我们来看个例子:

let a = { g: 12, h: 56, k: { t: 23, y: 70 } };
let b = JSON.parse(JSON.stringify(a));
a.g = 999;
a.k.y = 'hello';

输出a、b的结果如下:

修改a中属性的值,对b没有任何影响,看着确实是完成了深拷贝,其实,使用JSON.parse(JSON.stringify())实现深拷贝是有针对性的,我们修改一下a

let a = {
  g: 12,
  h: 56,
  k: [null, 45, undefined],
  f: {
    fun: () => {
      console.log('asdf');
    }
  }
};

输出a,b的结果:

方法并没有成功的复制过去,undefined变成了null(Google Chrome 版本 75.0.3770.142(正式版本)(64 位))

循环实现深拷贝

递归实现深拷贝似乎是大家都公认并且首选的方法,如果让你通过循环来实现呢?我一开始也不会,而且觉得有些难理解,查阅一些资料、学习前辈的分享的知识,终于算是拿下来这个知识点了。下面的知识可能比较不好理解,大家一定要努力理解,最好上浏览器调试执行一遍,总能啃下来的。

我们来看一个知识点,例子如下:

let a = { e: 'red', r: { u: 67 } };
let b = a['r'] = {};

let b = a['r'] = {}其实等同于let b = (a['r'] = {}),那我们肯定就知道上面代码输出a和b分别是什么了,看结果:

a的属性r的值被赋为{},b的值为{};当然,如果这么简单的话,那就没必要当一个知识点来说了。不信请往下看,我加多一行代码:

b['v'] = 7878;

我们再输出a和b的结果:

我当时看到这样的结果的时候,我是很惊讶的╭(⊙o⊙)╮只是给b添加一个属性v并设置值为7878,a的属性r也被添加了属性v值也是7878。回过头来看这句代码:let b = a['r'] = {},其实是先将a的r属性的值赋为{},接着将b指向a['r'],当给b添加属性的时候,由于b和a['r']指向同一个堆地址,所以a['r']也就一样变化了。

理解了这个知识点,我们就可以来看循环实现的深拷贝了,此处给出的实现方法并非最佳实践。

function deepClone(source) {
  if (typeof source !== 'object') {
    return source;
  }
  if (!source) return source;

  const root = Array.isArray(source) ? [] : {};
  // 定义栈
  let list = [
    {
      parent: root,
      key: undefined,
      data: source
    }
  ];

  while (list.length) {
    const temp = list.pop();
    const parent = temp.parent;
    const key = temp.key;
    const data = temp.data;

    let res = parent;
    if (key !== undefined) {
      res = parent[key] = Array.isArray(data) ? [] : {};
    }
    for (let k in data) {
      if (data.hasOwnProperty(k)) {
        if (data[k] && typeof data[k] === 'object') {
          // 过滤掉data[k]为null的情况,如果data[k]类型为object,入栈
          list.push({
            parent: res,
            key: k,
            data: data[k]
          });
        } else {
          res[k] = data[k];
        }
      }
    }
  }
  return root;
}

let a = {
  g: 12,
  h: 56,
  k: [null, 45, undefined, { i: 990 }],
  f: {
    fun: () => {
      console.log('asdf');
    }
  }
};
let b = deepClone(a);

我们输出a和b看看:

修改a的属性:

a.h = 1024;
a.k[3].i = 'coding';
a.f.fun = () => {
  console.log('I love coding');
};

我们再输出a和b看看:

完成了深拷贝。实现的思想,将a当做一颗树来看:
首先会向栈内推入一个根节点root,存储key是为了保持父元素和子元素的关系,通俗一点讲,我们保存a的属性k,也就是key,目的是为了保持k和k的子元素[null, 45, undefined, { i: 990 }]之间的关系,这样拷贝出来父子节点之间的关系才是正确的。接着遍历当前节点以下的子节点,如果类型为object,推入栈内,否则直接拷贝,循环的结束条件就是,栈内没有任何元素,然后把拷贝完成之后的结果返回。如果似懂非懂的话,一定要自己在浏览器中调试运行一下,这样会更加明了。

结语

感觉这篇写的有点长,但其中涵盖了很多知识点,细细读完并动手实现一遍,完全可以掌握下来的。如果文章存在错误或者不足之处,望大家多多指点!谢谢大家的阅读!

参考文章

【JS】深拷贝与浅拷贝的区别,实现深拷贝的几种方法
深拷贝的终极探索(90%的人都不知道)
js 深拷贝 vs 浅拷贝