JS核心理论之《JS引擎、运行时与调用堆栈》

506 阅读4分钟

JS引擎

JavaScript 引擎说起来最流行的当然是谷歌的 V8 引擎了, V8 引擎使用在 Chrome 以及 Node 中,下面有个简单的图能说明他们的关系

image
这个引擎主要由两部分组成:

内存堆:这是内存分配发生的地方。当V8引擎遇到变量声明和函数声明的时候,就把它们存储在堆里面。

调用栈:这是你的代码执行时的地方。当引擎遇到像函数调用之类的可执行单元,就会把它们推入调用栈。

JS单线程,指的是在JS引擎中,解析执行JS代码的调用栈是唯一的,所有的JS代码都在这一个调用栈里按照调用顺序执行,不能同时执行多个函数。

运行时

我们可以把JS的运行时环境看作一个大的容器,里面有一些其他的小容器。当JS引擎解析代码时,就是把代码片段分发到不同的容器里。

image

这整个一套环境称为JS的运行时环境。

Web API:还有很多引擎之外的 API,我们把这些称为浏览器提供的 Web API,比如说 事件监听函数、DOM、HTTP/AJAX请求、setTimeout等等。

事件循环:持续的检测调用栈和回调队列,如果检测到调用栈为空,它就会通知回调队列把队列中的第一个回调函数推入执行栈。

回调队列:按照先进先出的顺序存储所有的回调函数。在任意时间,只要Web API容器中的事件达到触发条件,就可以把回调函数添加到回调队列中去。

image

JS运行时环境的工作机制:

  1. JS引擎(唯一主线程)按顺序解析代码,遇到函数声明,直接跳过,遇到函数调用,入栈;
  2. 如果是同步函数调用,直接执行得到结果,同步函数弹出栈,继续下一个函数调用;
  3. 如果是异步函数调用,分发给Web API(多个辅助线程),异步函数弹出栈,继续下一个函数调用;
  4. Web API中,异步函数在相应辅助线程中处理完成后,即异步函数达到触发条件了,就把回调函数推入回调队列中。
  5. Event Loop不停地检查主线程的调用栈与回调队列,当调用栈空时,就把回调队列中的第一个任务推入栈中执行,不断循环。

示例:

setTimeout(function(){
    console.log('Hey, Why am I last?')
}, 0)

function sayHi(){
    console.log('Hello')
}

function sayBye(){
    console.log('Goodbye')
}

sayHi()
sayBye()

执行过程是这样的:

  1. JS引擎会检查整段代码的语法错误,如果没有错误,就从头开始深度解析
  2. 首先遇到setTimeout函数调用,把它推入执行栈顶
  3. 解析函数体,发现setTimeout函数是Web API的一种,因此就把它分发到Web API模块然后推出栈
  4. 因为定时器设置了0ms延迟,因此Web API模块立即把它的匿名回调函数推入到回调函数函数队列。事件循环检测执行栈是否是空闲,但是当前栈并不空闲,因为...
  5. 当setTimeout函数一被分发到Web API模块,JS引擎发现了两个函数声明,把它们存储在堆内存里,然后遇到了sayHi函数的调用,就把它推入了栈顶
  6. sayHi函数调用了console.log函数,因此console.log就被推入了栈顶
  7. JS引擎开始解析console.log的函数体,它接收了一个消息去打印‘Hello’,然后被弹出栈
  8. JS引擎返回到函数sayHi的执行,遇到函数的结束符号}之后,把sayHi弹出栈
  9. sayHi函数一出栈,紧接着sayBye函数被调用,它就被推入栈顶,被解析,调用console.log,把console.log推入栈顶,打印一条消息,弹出栈。然后sayBye函数弹出栈
  10. 事件循环检测到执行栈终于空闲了,通知回调队列,然后回调队列把其中的匿名函数推入执行栈
  11. 匿名函数(就是setTimeout的回调函数)被解析,调用console.log,console.log推入栈顶
  12. console.log执行完毕、再出栈
  13. 匿名函数再被推出栈,程序结束

调用椎栈

可以把执行栈认为是一个存储函数调用的栈结构,遵循先进后出的原则。

stack

当开始执行 JS 代码时,首先会执行一个 main 函数,然后执行我们的代码。 根据先进后出的原则,后执行的函数会先弹出栈,在图中我们也可以发现,foo 函数后执行,当执行完毕后就从栈中弹出了。

平时在开发中,大家也可以在报错中找到执行栈的痕迹

function foo() {
  throw new Error('error')
}
function bar() {
  foo()
}
bar()

image

大家可以在上图清晰的看到报错在 foo 函数,foo 函数又是在 bar 函数中调用的。 当我们使用递归的时候,因为栈可存放的函数是有限制的,一旦存放了过多的函数且没有得到释放的话,就会出现栈溢出。