笔者最近正在学习JS的一些底层机制,并从这些底层机制的角度出发,去学习那些看似简单的知识,去挖掘他们底层的实现。这将会是一个系列文章,用一系列的问题为驱动,当然也会有追问和扩展,内容较系统好理解,对初中级读者会是一次升华,高级读者也可以以该系列进行复习和巩固。
此次分享的主题是JS内存机制和深浅拷贝的实现,是一块逻辑环环相扣、语言简单但内容很有深度的内容,相信对不同级别的选手都有不同的帮助。
JS的内存机制:数据是如何存储的?
如果大家把这个问题放进百度,网上的文章基本都是这样说的:基本数据类型存储在栈内存中,引用数据类型存储在堆内存中。
那么,看到这,不知道大家会不会思考下面几个问题:
1.为什么数据是这样存储的?谁来完成?怎么完成的?
2.JS内存机制为什么要这样设计
3.为什么不能全部放进栈中,这么做会有什么问题?
4.这句话是完全成立的吗?
首先,普及一下基础知识:
基本数据类型和引用数据类型
具体来讲,JS的数据类型分为两大类:
- 基本数据类型:String,Boolean,Number,Undefined,Null
- 引用数据类型:Object(Array,Date,RegExp,Function)
两者区别: - 基本数据类型存储在栈中,引用数据类型存储在堆中
- 在JS中,基本数据类型的赋值完全赋值变量的值,而引用类型的赋值是赋值地址
栈内存和堆内存
在JS的内存机制中,会在内存中开创一个栈和一个堆,用于存储数据,如下图所示:
两者的区别主要在于:堆比栈大,栈比堆速度快。并且堆存储的一个很明显缺点在于分配内存和回收内存都会占用一定的时间。
接下来,针对前面提出的问题,这里将一一考究:
1. 在内存中,这两种数据类型的数据具体是怎么存储的呢?
以下面的代码段为例:
function foo(){
var a = 1
var b = a
var c = {name:'Aaron_hj'}
}
function bar(){}
foo()
bar()
这里,函数foo()在被调用之前,JS引擎首先会对这个函数进行编译,至于编译阶段发生了什么?还不清楚的小伙伴们可以看这篇文章:JS的预编译。在编译完成后,会将函数foo()的执行上下文压入栈中:
所以,大家不能看出,在函数foo()中,基本数据类型数据被编译后,会被写入执行上下文中,又因为foo()的执行上下文是要被压入栈中的,由此我们说基本数据类型是存储在栈内存中的。而对于引用数据类型,值是被存储在堆内存中的,在栈内存中只保留了它的引用地址。
2. 操作者是谁?为什么这样操作?
这样的数据存储方式,背后的操作者自然是JS的v8引擎。v8引擎的编译部分工作后,就把数据存储成上图的样子。上面我们说过,栈和堆的特点分别是:堆比栈大,栈比堆快。基本数据类型比较稳定,而且相对来说占用的内存小,所以可以放在栈内存中,方便直接取用;而引用数据类型大小是动态的,而且是无限制的,可以说,引用数据类型占用得内存太大,放在堆内存中是最好得选择。
3. 可以把所有得数据放在栈中吗?会产生什么影响?
答案当然是不可以,对于系统栈来说,它的功能除了存储数据之外,还有创建并切换函数执行上下文的功能,以下面的代码为例:
var name = 'Aaron_hj';
function showName(){
var anotherName = 'Aaron';
function swapName(){
var tmpName = anotherName;
anothorName = name
name = tmpName;
}
swapName();
}
showName();
在此代码的执行过程中,系统栈会产生如下的过程:
- 1.先把全局上下文压入栈
- 2.调用showName(),将showName的上下文压入栈,执行showName()
- 3.调用swapName(),将swapName的上下文压入栈,执行swapName()
- 4.swapName执行完毕,上下文出栈回收,系统栈切换到showName的上下文
- 5.showName执行完毕,上下文出栈回收,系统栈切换到全局上下文
- 6.全局上下文在浏览器窗口关闭后出栈
因此,我们可以看到,如果用栈去存储内存占用极大的引用数据类型数据,那么系统栈切换上下文的开销将变得巨大,切换效率就会明显降低。 这也是JS的内存机制这样存储数据的合理性所在。
4. “基本数据类型存储在栈中,引用数据类型存储在中”这句话完全成立吗?
既然提出这个问题,那自然是有反例的,这里就必须来深入聊一聊闭包了:闭包变量是存在堆内存中的。以下面代码块为例:
function foo() {
var myName = '欧文'
let test1 = 1
const test2 = 2
var innerBar = {
setName: function(newName) {
myName = newName
},
getName: function() {
console.log(test1);
return myName
}
}
return innerBar
}
var bar = foo()
bar.setName('Aaron_hj') //这样v8引擎就知道了发生了闭包
console.log(bar.getName) //共用一个clotrue对象,这一步就相当于把test1放进了clotrue对象中
该代码块中,很明显这里产生了闭包,v8引擎的工作流程如下:
- 1.调用foo,引擎先编译foo,将foo的执行上下文压入系统栈并执行foo。
- 2.调用setName,引擎先对setName进行编译,发现setName里面用了myName,在setName的作用域中没找到,去foo找到了,所以引擎发现这里产生了闭包。
- 3.引擎发现产生了闭包,就会把闭包变量myName放进一个closure对象中,并把这个对象存储在堆中,然后把栈中myName的值改为closure对象的引用地址。
- 4.执行setName,按作用域链去找myName,最后在foo的上下文中找到,得到引用地址,对应找到堆中closure对象中的myName值。
- 5.再调用getName时,引擎进行着相似的操作,但需要注意的是,这里引擎并不会再开创一个closure对象,只是将closure中没有的闭包变量加进去,并将它在栈中的值改为closure对象的引用地址。
最后,在内存中的情况为下图:
由此,不难看出,闭包变量即使是基本数据类型也被存储在堆内存中。
深浅拷贝
浅拷贝
- 在ES6之前,浅拷贝的定义很简单:以对象来说,浅拷贝拷贝的只是对象的引用地址,通过这种方法copy出来的新对象和原对象都指向同一地址块。
代码实现如下:
let obj1={count:1,name:'Aaron_hj',age:21};
let obj2 = obj1;
obj2 //{count:1,name:'grace',age:21}
obj2.count=2;
obj1 //{count:2,name:'grace',age:21}
obj2 //{count:2,name:'grace',age:21}
从结果上,我们可以看到,因为新对象和原对象指向的都是同一地址块,所以这个内存地址中的值发生改变对obj1和obj2都有影响。
内存的情况如下简图:
- 而在ES6中,JS引入了Object.assign方法,该方法主要用于对象的合并,将源对象的所有可枚举属性,复制到目标对象。关于该方法的具体用法大家可以自行前往阮一峰ES6入门了解,这里就不过多赘述了。看到这,大家可能会问:这个方法和浅拷贝有什么关系?大家不妨看下面这段代码:
const obj1 = {a: {b: 1},c:2};
const obj2 = Object.assign({}, obj1);
obj1.a.b = 2;
console.log(obj2.a.b) //2
obj2.c = 3
console.log(obj1.c); //2
从上面输出的结果来看,对obj1自身的c属性进行修改,并不会影响obj2中c属性的值;而对obj1中嵌套的对象的属性就行修改,obj2中也会对应改变。以下,是我对这种浅拷贝方法的理解:被复制对象的所有变量都含有与原来的对象相同的值,而所有的对其他对象的引用仍然指向原来的对象。即对象的浅拷贝仅对“主”对象拷贝,但不会复制主对象里面的对象。里面的对象会被复制对象和原对象共享。即可以理解为浅拷贝只会copy一层,嵌套在里面的对象都只是copy了它们的引用地址。 而ES6中实现这种浅拷贝的典型例子就是Object.assign方法
(这里有一点需要注意:该方法对数组同样可以实现,但是数组会被视为对象,特别是在该方法的同名属性会被替换方面体现得尤为突出。)
深拷贝
深拷贝不仅将原对象得各个属性逐个复制出去,而且将原对象各个属性所包含的对象也依次采用深复制的方法递归复制到新对象上,所以原对象和新对象是真正意义上的互不影响。
- 手动实现深拷贝
- 没有对象嵌套的代码实现
let obj1 = {
a: 1,
b: 2
}
let obj2 = {
a: obj1.a,
b: obj1.b
}
obj2.a = 3;
console.log(obj1.a); //1
console.log(obj2.a); //3
- 递归实现深拷贝
//递归实现
function deepCopy(obj) {
var obj2 = Array.isArray(obj) ? [] : {};
if (obj && typeof obj === "object") {
for (var i in obj) {
var prop = obj[i]; // 避免相互引用造成死循环,如obj1.a=obj
if (prop == obj) {continue;}
if (obj.hasOwnProperty(i)) {
// 如果子属性为引用数据类型,递归复制
if (prop && typeof prop === "object") {
obj2[i] = (prop.constructor === Array) ? [] : {};
arguments.callee(prop, obj2[i]); // 递归调用
} else {
// 如果是基本数据类型,只是简单的复制
obj2[i] = prop;
}
}
}
}
return obj2;
}
var obj1 = {
a: 1,
b: 2,
c: {
d: 3
}
}
var obj2 = deepCopy(obj1);
obj2.a = 3;
obj2.c.d = 4;
console.log(obj1.a); // 1
console.log(obj2.a); // 3
console.log(obj1.c.d); // 3
console.log(obj2.c.d); // 4
共同进步
该篇文章主要分享了JS的数据存储机制和深浅拷贝的知识,文章中加入了很多笔者自己的理解,可能存在错误,大家可以在评论区纠出,笔者一定及时改正。也欢迎在评论区说出自己的理解和看法。这篇文章是这个系列的第一篇文章,后续还会不断的增添,将该原生JS系列打造得更加完整和系统,构建自己得知识体系,也欢迎大家来评论区提出原生JS中值得深挖得知识,在这条前端进阶之路共同进步。
如果这篇文章让你对原生JS产生了某种启发,或者说弥补了你的一部分知识盲区,又或者是让原本模糊得概念重新理解清晰了。那么,我觉得这篇文章得价值就有了体现。所以也欢迎各位继续关注该系列得后续文章。
注:本文的参考博文如下:
- 笔者Object.assign方法的学习就是参照阮一峰老师的博文进行的,阮一峰 ES6入门:https://www.bookstack.cn/read/es6-3rd/spilt.2.docs-object-methods.md
- 对深拷贝的递归实现代码是转载于这位大佬的博文,大家可以看看:https://www.jianshu.com/p/cf1e9d7e94fb