我不知道的JS之深拷贝和浅拷贝

340 阅读7分钟

深拷贝和浅拷贝是什么?

通过上一篇文章,我们已经知道JavaScript在执行时,V8引擎会把程序用到的变量存储在内存的两个不同的逻辑分块中:栈和堆中。至于为什么要这么存,简单来说栈是程序运行时的上下文,可以通过栈来非常快速的切换执行上下文。这就要求栈的每个元素占用的存储空间必须是固定的(32字节)大小(为了方便下文中把这个固定大小的空间叫作一个存储单元),但是我们难免会用到一些变量。它要存储的值所占用的大小超过一个存储单元的大小(当然也有可能这个值初始大小没有超过一个存储单元,但是它可以动态的扩展到超过一个存储单元),这时候就会把这个需要存储的值存储在堆中,而把它在堆中的地址存储到栈上去。

这样一来就解决了一个存储单元(一个栈空间)没有办法存储大的数据的问题了,但是也产生了新的问题。我们在写代码的时候经常需要拷贝一个变量,对于值存储在栈上的数据,这样操作没什么问题,拷贝它的值顾名思义就是复制一个新的出来。而对于把值存储在堆上的数据,就产生了歧义了,因为这样的数据的数据实体是保存在堆空间上的,而栈空间上往往会保存了一个它的影子,可以通过它的影子找到它的实体。那么当你告诉JS引擎说你要复制这样的数据的时候,JS引擎就不知道你到底是要复制一份它的实体还是复制一份它的影子。当拷贝的是它的“影子”的时,我们把这次拷贝叫作浅拷贝,而当我们拷贝的是它的实体的时候就把这次拷贝叫作深拷贝。简单来说浅拷贝是拷贝堆内存的地址,而深拷贝是拷贝堆内存中的值。

JavaScript没有提供叫作“拷贝”的操作或者api,它提供的只是一个赋值运算符。赋值运算符的我们使用的场景不外乎给一个变量赋予一个具体的值或者把一个变量的值赋予给另一个变量,那既然我们说的是拷贝,其实就是特指第二种场景。从JS引擎的角度来说,只有在数据存储在堆内存上才会有两种拷贝方式。我们知道有些基本类型的值(字符串、Symbol等)也是存储在堆内存上的,但是他们却没有深拷贝,实际上对于所有的基本类型的值,不论他们的值存储在哪里,都只有一种拷贝方式。

基本类型的值的拷贝

var num1 = 1
var num2 = num1
num1 = 2
console.log(num2) // 1

var str1 = 'luwei'
var str2 = str1
console.log(str1[2]) // 'w'
str1[2] = 'W'
console.log(str1) // 'luwei'
console.log(str2) // 'luwei'

str1 = str1 + ' is 26 years old'
console.log(str1) // 'luwei is 26 years old'
console.log(str2) // 'luwei'

这里举了两个例子,这两个例子都是基本类型:

  • 然而Number 类型的值是存储在栈上的,对于它的值的拷贝,是直接对值的拷贝,也就是说在栈内存中重新开辟一块内存存储了和num1一样的值。
  • 但是对于String类型,它的存储方式和引用类型的值的存储方式类似,它的值存储在堆内存中,栈内存只是保存了它的引用,但是它和对象不同的对方在于,我们是没有办法对它的值进行拷贝的,变量str2中只是保存了和变量str1字符串luwei在堆内存中的引用 ,但是这里的怪异现象是在对str1进行索引赋值操作的时候不会报错,但是也不会生效,这一点和引用类型是不一样的,我们无法通过这个引用去修改堆内存的值,而引用类型是可以做到的。这就是字符串的不可变性,字符串一旦生成就是不可变的,而一旦对字符串的操作生成了新的字符串,JS引擎也只是会重新申请一个堆内存存储新的字符串而不是修改原来的字符串

所以对于存储在栈空间的基本类型,我们只能直接拷贝它的值而不能拷贝它的地址;而对于存储在堆内存的基本类型,我们只能拷贝它的地址,而无法去拷贝一份它的值。但是不管具体的拷贝方式是什么,对于基本类型的值的拷贝就只有一种拷贝方式,因此基本类型的值没有深拷贝和浅拷贝。

说句题外话,也正是因为这个原因,有很多人在理解内存模型的时候可以把所有的基本类型都认为是存储在栈空间上的。因为我们无法通过这个地址去直接修改这个值在堆中存储的具体的值,一旦你去修改这个值,JS引擎就会返回给你一个新的地址,这样一来这和这个值存储在栈空间上就没什么区别了。

引用类型的拷贝方式

对于引用类型的值的拷贝,我们可以拷贝它的值,也可以去拷贝它的地址。JS只提供了一个赋值运算符去拷贝它的地址,JS没有提供给我们去拷贝堆内存中值的方法,我们平时用的Object.assign和展开运算符看似可以实现对象的拷贝,而它们对于对象属性的拷贝也是浅拷贝;

而要想去实现引用类型的深拷贝需要我们自己去实现。实现深拷贝有很多种方法,有的是简易实现,那么相应的就会有一些问题。下面我介绍两种实现,一种比较简易,但是很快捷;另一种则比较完备,完全可以用于生产。

第一种:JSON.parse(JSON.stringify(object))

上面的代码通过JSON.parse(JSON.stringify(object))演示了一个简单的深拷贝,但是这种方式是有缺点的:

let obj = {         
    reg : /^reg$/,
    fun: function(){},
    syb: Symbol('foo'),
    undefined: undefined
}; 
let copied_obj = JSON.parse(JSON.stringify(obj));
console.log(copied_obj); // { reg: {} }
  1. 会忽略 undefined
  2. 会忽略 symbol
  3. 不能序列化函数正则对象等特殊对象
  4. 不能处理指向相同引用的情况,相同的引用会被重复拷贝
let obj = {}; 
let obj2 = {name:'aaaaa'};
obj.ttt1 = obj2;
obj.ttt2 = obj2;
let cp = JSON.parse(JSON.stringify(obj)); 
obj.ttt1.name = 'change'; 
cp.ttt1.name  = 'change';

// 因为obj的 ttt1 和 ttt2都是指向一个同一个对象,所以修改其中一个,另一个也会变,也就是说obj.ttt1 === obj.ttt2
console.log(obj); // { ttt1: {name: "change"}, ttt2: {name: "change"}}

// 而通过这种方式拷贝时,obj2拷贝了两次,丢失了cp.ttt1 === cp.ttt2 的特征
console.log(cp); // {ttt1: {name: "change"}, ttt2: {name: "aaaaa"}}

第二种:递归拷贝

function cloneDeep(value) {
  let copied_objs = []; // 用于解决循环引用问题

  function _cloneDeep(value) {
    if (value === null) return null;
    if (typeof value === "object") {
    	// 对象类型的值首先在copied_objs查找是否出现过,如果出现直接返回之前的结果
      for (let i = 0; i < copied_objs.length; i++) {
        if (value === copied_objs[i].source) {
          return copied_objs[i].target;
        }
      }
      let new_value = {};
      
      // 需要处理数组的情况
      if (Array.isArray(value)) new_value = [];
      copied_objs.push({ source: value, target: new_value });

      Object.keys(value).forEach((key) => {
        new_value[key] = _cloneDeep(value[key]);
      });
      return new_value;
    } else {
      return value;
    }
  }
  return _cloneDeep(value);
}

使用lodash的测试用例测试通过,TypedArray的相关用例没有通过,上面的代码没有考虑兼容TypedArray类型

题外话:lodash这样的库每个函数的测试用例都非常丰富,在学习一些重要方法的时候可以自己实现一遍然后采用lodash的测试用例去测试自己写的对不对,再去对照着源码学习,相信可以事半功倍。