调用堆栈与执行上下文-Javascript初级

113 阅读9分钟

所谓JS引擎,就是一种为解释执行JavaScript代码而专门设置的流程虚拟机。

其中最为重要的是一个组件是调用堆栈(Call Stack),它与全局内存执行上下文一起运行代码。

在读取和执行代码时,是由JS引擎来读取代码的,一旦读取到引用,就会将引用放入全局内存(堆)中。

全局内存(堆)是JS引擎保存全局变量和全局函数声明的地方。

那么,当在代码中运行函数会发生什么呢?

代码试例:

let [a,b] = [2,4];//解构赋值
function sum(a,b) {
    return a+b;
}
sum(a,b);

现在我执行了一个函数,这时如果JS引擎执行这段代码,就要用到在上文中所说的一个基本组件----调用堆栈。

所谓调用堆栈,就是一种数据结构:元素可以从顶部进入,而如果元素上面还有其他元素的时候,则需要继续等待上面顶部元素一一执行。

从这里,我们可以看出,JS是单线程的

但是需要注意的是,JavaScript 确实只在一个线程上运行,却不代表 JavaScript 引擎只有一个线程。事实上,JavaScript 引擎有多个线程,单个脚本只能在一个线程上运行(称为主线程),其他线程都是在后台配合运行。

再强调一遍,JS单线程是指JS引擎执行JS代码时只分了一个线程给它执行,也就是执行JS时是单线程的。

扯远了,回到我们的主题,已知组件“调用堆栈”的执行方式,与此同时,当执行函数的时候,JS引擎还会分配一个**全局执行上下文,**它是运行JS代码的全局环境。

打个不是很形象的比方:如果全局执行上下文是我们已知的可以看到的世界,那么全局引用和全局函数都是在世界上面撒欢的我们,可是吧,在某些地方藏着一些未知的小世界,就是全局函数中的嵌套变量或者是若干个内部函数。在这种情况下,JS引擎会创建一个本地执行上下文

代码试例:

let [a,b] = [2,3];
function sum(a,b) {
    var c = 4;
    return a+b;
}
sum(a,b);

在上述情况下,sum全局函数里面定义声明了一个变量c,这种情况下,sum函数中会创建一个本地执行上下文,c变量会被放到这个函数的本地执行上下文中去。

了解了大概的原理,我们来具体讨论一下调用堆栈和执行上下文。

调用堆栈

先介绍堆和栈是什么:

栈 (stack) : 用来保存简单的数据字段

堆 (heap) : 用来保存栈中简单数据字段对指针的引用

栈会自动分配内存空间,会自动释放,存放基本类型,简单的数据段,占据固定大小的空间

而堆是动态分配的内存,大小不定也不会自动释放,存放引用类型,指那些可能由多个值构成的对象,保存在堆内存中,包含引用类型的变量,实际上保存的不是变量本身,而是指向该对象的指针。

呃,简单来说,在内存属性方面,栈(stack)会自动分配内存空间,会自动释放。而堆(heap)是动态分配的内存,大小不定也不会自动释放。

(这里的堆栈与数据结构里面的堆栈定义并不相同)

重要的是要知道,栈内存中关于引用类型的数据的是通过**指针(地址)**来引用的,指针和地址指向堆内存中的数据。

那么为什么会这样呢?

这是因为基本类型的数据简单,所占用空间比较小,内存由系统自动分配,而引用类型数据比较复杂,复杂程度是动态的,计算机为了较少反复的创建和回收引用类型数据所带来的损耗,就先为其开辟另外一部分空间——及堆内存,以便于这些占用空间较大的数据重复利用。堆内存中的数据不会随着方法的结束立即销毁,有可能该对象会被其它方法所引用,直到系统的垃圾回收机制检索到该对象没有被任何方法所引用的时候才会对其进行回收。

了解堆栈对明白调用堆栈的有很大帮助,我们也更应该了解代码运行的原理,而不是仅仅知道如何去使用API。

我在上文中提到过了,JavaScript 是一门单线程的语言,这意味着它只有一个调用栈,因此,同一时间只能做一件事,调用栈记录了我们在程序中的位置。如果我们运行到一个函数,它就会将其放置到栈顶。当从这个函数返回的时候,就会将这个函数从栈顶弹出,毫不留恋!!!

每一个进入调用栈的可执行代码都称为调用帧。

当函数调用过多,如在递归函数中无终止条件,就会不断的入栈,直到堆栈溢出。

执行上下文

在我们的JS代码在执行之前,JS引擎总要做准备工作,就是创建对应的执行上下文;

执行上下文只有三类,全局执行上下文,函数上下文,与eval上下文;由于eval一般不会使用,这里不做讨论。

1.全局执行上下文

全局执行上下文只有一个,在客户端中一般由浏览器创建,也就是我们熟知的window对象,我们能通过this直接访问到它。

全局对象window上预定义了大量的方法和属性,我们在全局环境的任意处都能直接访问这些属性方法,同时window对象还是var声明的全局变量的载体。我们通过var创建的全局对象,都可以通过window直接访问。

2.函数执行上下文

函数执行上下文可存在无数个,每当一个函数被调用时都会创建一个函数上下文;需要注意的是,同一个函数被多次调用,都会创建一个新的上下文。

说到这你是否会想,上下文种类不同,而且创建的数量还这么多,它们之间的关系是怎么样的,又是谁来管理这些上下文呢,没错,就是上面一直在讲的组件调用堆栈,在这里我们叫它执行栈,因为在这里它管理执行上下文。

执行栈用于存储代码执行期间创建的所有上下文,具有LIFO(Last In First Out后进先出,也就是先进后出)的特性。其实在上文中都讲过了。。。

JS代码首次运行,都会先创建一个全局执行上下文并压入到执行栈中,之后每当有函数被调用,都会创建一个新的函数执行上下文并压入栈内;由于执行栈LIFO的特性,所以可以理解为,JS代码执行完毕前在执行栈底部永远有个全局执行上下文。

好了,上面初步讲了执行上下文的存储规则,还需要了解执行上下文的创建。

执行上下文创建阶段

执行上下文创建分为创建阶段与执行阶段两个阶段。

执行上下文的创建阶段主要负责三件事:确定this---创建词法环境组件(LexicalEnvironment)---创建变量环境组件(VariableEnvironment)。

1.确定this

官方的称呼为This Binding,在全局执行上下文中,this总是指向全局对象,例如浏览器环境下this指向window对象。

而在函数执行上下文中,this的值取决于函数的调用方式,如果被一个对象调用,那么this指向这个对象。否则this一般指向全局对象window或者undefined(严格模式)。

2.词法环境组件(我也不懂,查的资料)

词法环境是一个包含标识符变量映射的结构,这里的标识符表示变量或函数的名称,变量是对实际对象(包括函数类型对象)或原始值的引用。

词法环境由环境记录与对外部环境引入记录两个部分组成。

其中环境记录用于存储当前环境中的变量和函数声明的实际位置;外部环境引入记录很好理解,它用于保存自身环境可以访问的其它外部环境。

我们在前文提到了全局执行上下文与函数执行上下文,所以这也导致了词法环境分为全局词法环境与函数词法环境两种。

  • 全局词法环境组件:

对外部环境的引入记录为null,因为它本身就是最外层环境,除此之外它还记录了当前环境下的所有属性、方法位置。

  • 函数词法环境组件:

包含了用户在函数中定义的所有属性方法外,还包含了一个arguments对象。函数词法环境的外部环境引入可以是全局环境,也可以是其它函数环境。

3.变量环境组件(我也不懂,查的资料)

变量环境可以说也是词法环境,它具备词法环境所有属性,一样有环境记录与外部环境引入。在ES6中唯一的区别在于词法环境用于存储函数声明与let const声明的变量,而变量环境仅仅存储var声明的变量。

重点来了,在执行上下文创建阶段,函数声明与var声明的变量在创建阶段已经被赋予了一个值,var声明被设置为了undefined,函数被设置为了自身函数,而let const被设置为未初始化。

这就解释了变量提升与函数声明提前,以及为什么let和const有暂时性死域,这是因为作用域创建阶段JS引擎的初始化赋值不同。

上下文除了创建阶段外,还有执行阶段,这点应该好理解,代码执行时根据之前的环境记录对应赋值,比如早期var在创建阶段为undefined,如果有值就对应赋值,像let const值为未初始化,如果有值就赋值,无值则赋予undefined。

到了这里,这篇文章就结束了,貌似除了执行上下文和堆栈调用,还说了变量提升和函数声明提前的原理,下期明天写this的几种绑定,完结撒花撒花。