JS结合栈和堆内存机制,谈谈数据类型的浅拷贝、深拷贝

在说深拷贝与浅拷贝前,我们 需要深入理解JS的内存机制 …先看两个简单的案例:

//案例一
var num1 = 1, num2 = num1;
console.log(num1);//1
console.log(num2);//1
num2 = 2;//修改num
console.log(num1);//1
console.log(num2);//2

//案例二
var obj1 = {x: 1, y: 2}, obj2 = obj1;
console.log(obj1); //{x: 1, y: 2}
console.log(obj2); //{x: 1, y: 2}
obj2.x = 2;//修改obj2
console.log(obj1); //{x: 2, y: 2}
console.log(obj2); //{x: 2, y: 2}

按照常规思维,obj1应该和num1一样,不会因为另外一个值的改变而改变,而这里的obj1 却随着obj2的改变而改变了。同样是变量,为什么表现不一样呢?这就要引入JS中基本类型和引用类型的概念了。

深入理解JS的内存机制

先看下官方定义:

ECMAScript变量可能包含两种不同数据类型的值:基本类型值和引用类型值。基本类型值指的是那些保存在栈内存中的简单数据段,即这种值完全保存在栈内存中的一个位置。而引用类型值是指那些保存堆内存中的对象,意思是变量中保存的实际上只是一个指针,这个指针指向堆内存中的另一个位置,该位置保存对象。

在内存当中,基本数据类型存放在栈中,引用数据类型存放在堆中。说到这里就要说一下内存空间了,一般来说,js的内存空间分为栈(stack)、堆(heap)、池(一般也会归类栈中)。其中栈存放变量,堆存放复杂对象,池存放常量,所以也叫常量池。

和java中对内存的处理类似,栈内存主要用于存储各种基本类型的变量,包括Boolean、Number、String、Undefined、Null,以及指向堆内存中对象变量的指针,这时候栈内存给人的感觉就像一个线性排列的空间,每个小单元大小基本相等。

(注意:目前基本类型有:Boolean、Null、Undefined、Number、String、Symbol,引用类型有:Object、Array、Function。之所以说“目前”,因为Symbol就是ES6才出来的,之后也可能会有新的类型出来。)

结合图示你可能有更清晰的了解——
在这里插入图片描述
图中可见:在js引擎中对变量的存储主要有两种位置,堆内存和栈内存。

打个比方,基本类型和引用类型在赋值上的区别可以按“连锁店”和“单店”来理解:基本类型赋值等于在一个新的地方安装连锁店的规范标准新开一个分店,新开的店与其他旧店互不相关,各自运营;而引用类型赋值相当于一个店有两把钥匙(即一个引用类型变量,可能有多个指针指向该堆内存位置),交给两个老板同时管理,两个老板的行为都有可能对一间店的运营造成影响。

上面清晰明了的介绍了基本类型和引用类型的定义和区别。

再深入一下

此时会产生一个疑问:为什么 复杂数据类型放入堆内存、并在栈内存中用指针来标识位置,而不是直接放入栈内存中呢…???

栈内存中的变量一般都是已知大小或者有范围上限的,算作一种简单存储。而堆内存存储的对象类型数据对于大小这方面,一般都是未知的。个人认为,这也是为什么null作为一个object类型的变量却存储在栈内存中的原因。

因此当我们定义一个const对象的时候,我们说的常量其实是指针,就是const对象对应的堆内存指向是不变的,但是堆内存中的数据本身的大小或者属性是可变的。而对于const定义的基础变量而言,这个值就相当于const对象的指针,是不可变。

既然知道了const在内存中的存储,那么const、let定义的变量不能二次定义的流程也就比较容易猜出来了,每次使用const或者let去初始化一个变量的时候,会首先遍历当前的内存栈,看看有没有重名变量,有的话就返回错误。

说到这里,有一个十分很容易忽略的点,之前也是自己一直没有注意的就是,使用new关键字初始化的之后是不存储在栈内存中的。为什么呢?new大家都知道,根据构造函数生成新实例,这个时候生成的是对象,而不是基本类型。再看一个例子:

//案例三
var a = new String('123')
var b = String('123')
var c = '123'
console.log(a==b, a===b, b==c, b===c, a==c, a===c)  
>>> true false true true true false
console.log(typeof a)
>>> 'object'

我们可以看到new一个String,出来的是对象,而直接字面量赋值和工厂模式出来的都是字符串。但是根据我们上面的分析大小相对固定可预期的即便是对象也可以存储在栈内存的,比如null,为啥这个不是呢?再继续看:

//案例四
var a = new String('123')
var b = new String('123')
console.log(a==b, a===b)
>>> false false

很明显,如果a,b是存储在栈内存中的话,两者应该是明显相等的,就像null === null是true一样,但结果两者并不相等,说明两者都是存储在堆内存中的,指针指向不一致。

说到这里,再去想一想我们常说的值类型和引用类型其实说的就是栈内存变量和堆内存变量,再想想值传递和引用传递、深拷贝和浅拷贝,都是围绕堆栈内存展开的,一个是处理值,一个是处理指针。

关于JS内存机制的扩展

JS内存泄漏

程序的运行需要内存。只要程序提出要求,操作系统或者运行时就必须供给内存,js中每var一个指令,就会开辟一块内存。
JS中的内存泄漏: 对于不再用到的内存,没有及时释放,就叫做内存泄漏(memory leak);
内存泄漏可能对于前端开发着来说比较陌生,但是你肯定遇到过浏览器卡死的现象,卡死的原因就可能是因为一个死循环导致的内存爆满泄漏。所以对于持续运行的服务进程(daemon),必须及时释放不再用到的内存。否则,内存占用越来越高,轻则影响系统性能,重则导致进程崩溃。

JS垃圾回收
有些语言(比如 C 语言)必须手动释放内存,程序员负责内存管理。大多数语言提供自动内存管理,减轻程序员的负担,这被称为"垃圾回收机制"(garbage collector)。

在js中也有垃圾回收机制,其作用是回收过期无效的变量,以防止内存泄漏。这些工作不需要我们去管理什么时候进行垃圾回收,js会自动进行,这让我们写起代码来感觉超级爽,哈哈。

下面来看一下js垃圾回收机制什么时候会回收变量。我们写代码的时候是区分全局变量和局部变量的,在此,我们看一下局部变量和全局变量的销毁:

  1. 局部变量:局部作用域中,当函数执行完毕,局部变量也就没有存在的必要了,因此垃圾收集器很容易做出判断并回收。
  2. 全局变量:全局变量什么时候需要自动释放内存空间则很难判断,所以在开发中尽量避免使用全局变量,以提高内存有效使用率

详细了解建议阅读阮一峰文章:http://www.ruanyifeng.com/blog/2017/04/memory-leak.html

JS的浅拷贝与深拷贝

再回到前面的案例,案例1中的值为基本类型,案例2中的值为引用类型。案例2中的赋值就是典型的浅拷贝,并且深拷贝与浅拷贝的概念只存在于引用类型。

既然已经知道了深拷贝与浅拷贝的来由,那么该如何实现深拷贝?我们先分别看看Array和Object自有方法是否支持:
先看Array

//Array
var arr1 = [1, 2], arr2 = arr1.slice();
console.log(arr1); //[1, 2]
console.log(arr2); //[1, 2]
arr2[0] = 3; //修改arr2
console.log(arr1); //[1, 2]
console.log(arr2); //[3, 2]

此时,arr2的修改并没有影响到arr1,看来深拷贝的实现并没有那么难嘛。我们把arr1改成二维数组再来看看:

var arr1 = [1, 2, [3, 4]], arr2 = arr1.slice();
console.log(arr1); //[1, 2, [3, 4]]
console.log(arr2); //[1, 2, [3, 4]]
arr2[2][1] = 5; 
console.log(arr1); //[1, 2, [3, 5]]
console.log(arr2); //[1, 2, [3, 5]]

咦,arr2又改变了arr1,看来slice()只能实现 ***一维数组***的深拷贝。
具备同等特性的还有:concat、Array.from() 。

再看Object
1、Object.assign()

var obj1 = {x: 1, y: 2}, obj2 = Object.assign({}, obj1);
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 1, y: 2}
obj2.x = 2; //修改obj2.x
console.log(obj1) //{x: 1, y: 2}
console.log(obj2) //{x: 2, y: 2}
var obj1 = {x: 1, y: { m: 1 }};
var obj2 = Object.assign({}, obj1);
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 1, y: {m: 1}}
obj2.y.m = 2; //修改obj2.y.m
console.log(obj1) //{x: 1, y: {m: 2}}
console.log(obj2) //{x: 2, y: {m: 2}}

经测试,Object.assign()也只能实现***一维对象***的深拷贝。

2、JSON.parse( JSON.stringify(obj) )

var obj1 = {x: 1, y: { m: 1 }};
var obj2 = JSON.parse(JSON.stringify(obj1));
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 1, y: {m: 1}}
obj2.y.m = 2; //修改obj2.y.m
console.log(obj1) //{x: 1, y: {m: 1}}
console.log(obj2) //{x: 2, y: {m: 2}}

JSON.parse(JSON.stringify(obj)) 看起来很不错,不过MDN文档 的描述有句话写的很清楚:
undefined、任意的函数以及 symbol 值,在序列化过程中会被忽略(出现在非数组对象的属性值中时)或者被转换成 null(出现在数组中时)。

我们再来把obj1改造下:

var obj1 = {x: 1, y: undefined, z: function add(z1, z2) { return z1 + z2 }, a: Symbol("foo")};
var obj2 = JSON.parse( JSON.stringify(obj1) );
console.log( obj1 ) //{x: 1, y: undefined, z: , a: Symbol(foo)}
console.log( JSON.stringify(obj1) ); //{"x":1}console.log(obj2) //{x: 1}

发现,在将obj1进行JSON.stringify()序列化的过程中,y、z、a都被忽略了,也就验证了MDN文档的描述。既然这样,那JSON.parse(JSON.stringify(obj))的使用也是有局限性的,不能深拷贝含有undefined、function、symbol值的对象,不过JSON.parse(JSON.stringify(obj))简单粗暴,已经满足90%的使用场景了。

经过验证,我们发现JS 提供的自有方法并不能彻底解决Array、Object的深拷贝问题。只能祭出大杀器:递归

function deepCopy(obj) {
	let result = {} ;// 创建一个新对象 
	let keys = Object.keys(obj), key = null, temp = null; 
	for (let i = 0; i < keys.length; i++) {
		 key = keys[i]; temp = obj[key]; 
		 if (temp && typeof temp === 'object') { // 如果字段的值也是一个对象则递归操作
		 	 result[key] = deepCopy(temp); 
		 } else { // 否则直接赋值给新对象
			 result[key] = temp; 
		 } 
	 } 
	 return result;
 }
 var obj1 = { x: { m: 1 }, y: undefined, z: function add(z1, z2) { return z1 + z2 }, a: Symbol("foo")};
 var obj2 = deepCopy(obj1);
 obj2.x.m = 2;
 console.log(obj1); //{x: {m: 1}, y: undefined, z: , a: Symbol(foo)}
 console.log(obj2); //{x: {m: 2}, y: undefined, z: , a: Symbol(foo)}

可以看到,递归完美的解决了前面遗留的所有问题,我们也可以用第三方库:jquery的$.extend和lodash的_.cloneDeep来解决深拷贝。上面虽然是用Object验证,但对于Array也同样适用,因为Array也是特殊的Object。

到这里,深拷贝问题基本可以告一段落了。但是,还有一个非常特殊的场景:
循环引用拷贝

var obj1 = {x: 1, y: 2};obj1.z = obj1;var obj2 = deepCopy(obj1);

此时如果调用刚才的deepCopy函数的话,会陷入一个循环的递归过程,从而导致爆栈。jquery的$.extend也没有解决。解决这个问题也非常简单,只需要判断一个对象的字段是否引用了这个对象或这个对象的任意父级即可,修改一下代码:

function deepCopy(obj, parent = null) {
	let result = {}; // 创建一个新对象 
	let keys = Object.keys(obj),  key = null,  temp= null,  _parent = parent; // 该字段有父级则需要追溯该字段的父级 
	while (_parent) { // 如果该字段引用了它的父级则为循环引用 
		if (_parent.originalParent === obj) { 
			return _parent.currentParent; // 循环引用直接返回同级的新对象 
		} 
		_parent = _parent.parent; 
	} 
	for (let i = 0; i < keys.length; i++) { 
		key = keys[i]; temp= obj[key]; // 如果字段的值也是一个对象 
		if (temp && typeof temp=== 'object') { // 递归执行深拷贝 将同级的待拷贝对象与新对象传递给 parent 方便追溯循环引用 
			result[key] = DeepCopy(temp, { originalParent: obj, currentParent: result, parent: parent }); 
		} else {
			result[key] = temp; 
		} 
	} 
	return result;
}
var obj1 = { x: 1, y: 2};obj1.z = obj1;
var obj2 = deepCopy(obj1);
console.log(obj1); //太长了去浏览器试一下吧~ 
console.log(obj2); //太长了去浏览器试一下吧~

至此,已完成一个支持循环引用的深拷贝函数。当然,也可以使用lodash的_.cloneDeep噢~。

(以上文档部分内容为经典文章剪辑,如有侵权请联系更正。)

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
在JavaScript中,拷贝和深拷贝是用来复制引用类型数据的两种不同方式。 浅拷贝是指创建一个新的对象或数组,然后将原始对象或数组的引用复制给新对象或数组。这意味着新对象或数组与原始对象或数组共享相同的内存地址,当修改其中一个对象或数组时,另一个对象或数组也会受到影响。 深拷贝是指创建一个新的对象或数组,并递归地复制原始对象或数组的所有层级。这意味着新对象或数组与原始对象或数组完全独立,修改其中一个对象或数组不会影响另一个对象或数组。 下面是浅拷贝深拷贝的示例代码: 浅拷贝: ```javascript const original = { name: 'John', age: 30 }; const shallowCopy = Object.assign({}, original); original.age = 40; console.log(original); // { name: 'John', age: 40 } console.log(shallowCopy); // { name: 'John', age: 30 } ``` 深拷贝: ```javascript function deepCopy(obj) { if (typeof obj !== 'object' || obj === null) { return obj; } let copy; if (Array.isArray(obj)) { copy = []; for (let i = 0; i < obj.length; i++) { copy[i] = deepCopy(obj[i]); } } else { copy = {}; for (let key in obj) { if (obj.hasOwnProperty(key)) { copy[key] = deepCopy(obj[key]); } } } return copy; } const original = { name: 'John', age: 30 }; const deepCopy = deepCopy(original); original.age = 40; console.log(original); // { name: 'John', age: 40 } console.log(deepCopy); // { name: 'John', age: 30 } ```
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值