JS 引擎是一个可以编译、解释我们的JS代码强大的组织。最受欢迎的JS 引擎是V8,由 Google Chrome 和 Node.j s使用,SpiderMonkey 用于Firefox,以及Safari/WebKit使用的 JavaScriptCore。

虽然现在 JS 引擎不是帮我们处理全面的工作。但是每个引擎中都有一些较小的组织为我们做繁琐的的工作。

其中一个组件是调用堆栈(Call Stack,也叫做执行栈),与全局内存和执行上下文一起运行我们的代码。

JS 引擎和全局内存(Global Memory)

JavaScript 是编译语言同时也是解释语言。信不信由你,JS 引擎在执行代码之前只需要几微秒就能编译代码。

这听起来很神奇,对吧?这种神奇的功能称为JIT(及时编译)。这个是一个很大的话题,一本书都不足以描述JIT是如何工作的。但现在,我们将重点放在执行阶段。

考虑以下代码:

var num = 2;
function pow(num) {
    return num * num;
}

如果问你如何在浏览器中处理上述代码? 你会说些什么? 你可能会说“浏览器读取代码”或“浏览器执行代码”。

现实比这更微妙。首先,读取这段代码的不是浏览器,是JS引擎。JS引擎读取代码,当 JS 引擎第一次遇到你的脚本时,它会创建一个全局的执行上下文,然后将几个引用放入全局内存

全局内存(也称为堆) JS引擎保存变量和函数声明的地方。因此,回到上面示例,当 JS引擎读取上面的代码时,全局内存中放入了两个绑定。

js引擎 GraalJS引擎 java_事件循环

即使示例只有变量和函数,也要考虑你的JS代码在更大的环境中运行:在浏览器中或在Node.js中。 在这些环境中,有许多预定义的函数和变量,称为全局变量。 

上例中,没有执行任何操作,但是如果我们像这样运行函数会怎么样呢:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);

现在事情变得有趣了。当函数被调用时,JS引擎会为全局执行上下文调用栈腾出空间。

JS引擎:它们是如何工作的? 全局执行上下文和调用堆栈

刚刚了解了 JS引擎如何读取变量和函数声明,它们最终被放入了全局内存(堆)中。

但现在我们执行了一个JS函数,JS引擎必须处理它。怎么做?每个JS引擎中都有一个基本组件,叫调用堆栈

调用堆栈是一个后进先出的堆栈数据结构:这意味着元素可以从顶部进入,但如果它们上面有一些元素,它们就不能离开,JS 函数就是这样的。

一旦执行,如果其他函数仍然被阻塞,它们就不能离开调用堆栈。请注意,这个有助于你理解“JavaScript是单线程的”这句话。

回到我们的例子,当函数被调用时(每当引擎遇到一个函数调用),JS引擎会为该函数创建一个函数执行上下文并将该函数推入调用堆栈顶部。

js引擎 GraalJS引擎 java_js引擎 GraalJS引擎 java_02

同时,JS 引擎还分配了一个全局执行上下文,这是运行JS代码的全局环境,如下所示

js引擎 GraalJS引擎 java_执行上下文_03

想象全局执行上下文是一个海洋,其中全局函数像鱼一样游动,多美好! 但现实远非那么简单, 如果我们的函数有一些嵌套变量或一个或多个内部函数怎么办?

即使是像下面这样的简单变化,JS引擎也会创建一个函数执行上下文:

var num = 2;
function pow(num) {
    var fixed = 89;
    return num * num;
}
pow(num);

注意,我在pow函数中添加了一个名为fixed的变量。在这种情况下,pow函数中会创建一个函数执行上下文,fixed 变量被放入pow函数中的函数执行上下文中。

对于嵌套函数的每个嵌套函数,引擎都会创建更多的函数执行上下文。

什么是调用堆栈的后进先出?

一起来看下面这个例子:

let a = 'Hello World!';

function first() {
  console.log('Inside first function');
  second();
  console.log('Again inside first function');
}

function second() {
  console.log('Inside second function');
}

first();
console.log('Inside Global Execution Context');

当上述代码在浏览器加载时,JavaScript 引擎创建了一个全局执行上下文。当遇到 first() 函数调用时,JavaScript 引擎为该函数创建一个新的函数执行上下文并把first函数压入调用堆栈的顶部。

当从 first() 函数内部调用 second() 函数时,JavaScript 引擎为 second() 函数创建了一个新的函数执行上下文并把second函数压入调用堆栈的顶部。当 second() 函数执行完毕,它会从调用堆栈弹出,并且控制流程到达下一个函数,即 first() 函数。当 first() 执行完毕,它从调用堆栈弹出。

JavaScript 是单线程和其他有趣的故事

JavaScript是单线程的,因为只有一个调用堆栈处理我们的函数。也就是说,如果有其他函数等待执行,函数就不能离开调用堆栈。

在处理同步代码时,这不是问题。例如,两个数字之间的和是同步的,以微秒为单位。但如果涉及异步的时候,怎么办呢?

幸运的是,默认情况下JS引擎是异步的。即使它一次执行一个函数,也有一种方法可以让外部(如:浏览器)执行速度较慢的函数,稍后探讨这个主题。

当浏览器加载某些JS代码时,JS引擎会逐行读取并执行以下步骤:

  • 创建全局执行上下文
  • 将变量和函数的声明放入全局内存(堆)中(这就是var声明的变量提升和function定义的函数提升)
  • 执行全局函数
  • 将函数的调用放入调用堆栈
  • 创建多个函数执行上下文(如果有内部变量或嵌套函数)

到目前为止,对JS引擎的同步机制有了基本的了解。 在接下来的部分中,讲讲 JS 异步工作原理。

异步JS:回调队列和事件循环

全局内存(堆),执行上下文和调用堆栈解释了同步 JS 代码在浏览器中的运行方式。 然而,我们遗漏了一些东西,当有一些异步函数运行时会发生什么?

请记住,调用堆栈一次只能执行一个函数,甚至一个阻塞函数也可以直接冻结浏览器。 幸运的是JavaScript引擎是聪明的,并且在浏览器的帮助下可以解决问题。

当我们运行一个异步函数时,浏览器接受该函数并运行它。考虑如下代码:

setTimeout(callback, 10000);
function callback(){
    console.log('hello timer!');
}

setTimeout 大家都知道得用得很多次了,但你可能不知道它不是内置的JS函数。 也就是说,当JS 出现,语言中没有内置的setTimeout

setTimeout浏览器API( Browser API)的一部分,它是浏览器免费提供给我们的一组方便的工具。这在实战中意味着什么?由于setTimeout是一个浏览器的一个Api,函数由浏览器直接运行(它会在调用堆栈中出现一会儿,但会立即移除)。

10秒后,浏览器接受我们传入的回调函数并将其移动到回调队列(Callback Queue)中。考虑以下代码:

var num = 2;
function pow(num) {
    return num * num;
}
pow(num);
setTimeout(callback, 10000);
function callback(){
    console.log('hello timer!');
}

示意图如下:

js引擎 GraalJS引擎 java_js引擎 GraalJS引擎 java_04

如你所见,setTimeout在浏览器上下文中运行。 10秒后,计时器被触发,回调函数准备运行。 但首先它必须通过回调队列(Callback Queue)。 回调队列是一个队列数据结构,回调队列是一个有序的函数队列。

每个异步函数在被放入调用堆栈之前必须通过回调队列,但这个工作是谁做的呢,那就是事件循环(Event Loop)。

事件循环只有一个任务:它检查调用堆栈是否为空。如果回调队列中(Callback Queue)有某个函数,并且调用堆栈是空闲的,那么就将其放入调用堆栈中。

完成后,执行该函数。 以下是用于处理异步和同步代码的JS引擎的图:

js引擎 GraalJS引擎 java_堆栈_05

想象一下,callback() 已准备好执行,当 pow() 完成时,调用堆栈(Call Stack) 为空,事件循环(Event Look) 将 callback() 放入调用堆栈中。如果你理解了上面的插图,那么你就可以理解所有的JavaScript了。

跟事件循环相关的几个练习

1. 运行foo函数,控制台是否会报堆栈溢出错误?

function foo() {
    setTimeout(foo, 0);
};
foo();

不会溢出。引擎每次从堆栈中取出一个函数,然后从上到下依次运行代码。每当它遇到一些异步代码,如 setTimeout,异步任务就会被挂起。因此,每当事件被触发时,callback 都会被发送到任务队列(在这里,任务队列里也最多只会有一个函数)。Event loop不断地监视任务队列,并按它们排队的顺序一次处理一个回调。每当调用堆栈为空时,Event loop获取回调并将其放入调用堆栈中进行处理。所以每次堆栈中只会有一个回调,也就不会造成堆栈溢出了。

2. 运行以下函数,页面的UI是否仍然响应?

function foo() {
    return Promise.resolve().then(foo);
};
foo()

不会响应。任务分为宏任务和微任务。主要的区别在于他们的执行方式。宏任务在单个循环周期中一次一个地推入堆栈,但是微任务队列总是在执行后返回,到事件循环之前清空。只有当微任务队列为空时,事件循环才会重新渲染页面。Promise的then属于微任务,因此,每次执行 foo 的时候都会继续在微任务队列上添加另一个 foo 回调,那么你就永远在处理微任务。因此js引擎无法继续处理其他事件(滚动,单击等),直到该队列完全清空为止。 因此,它会阻止界面响应。

总结

1. JS引擎包含的组件:全局内存(堆)、调用堆栈、回调队列、事件循环。

    栈:后进先出。队列:先进先出。

2. JS引擎是单线程的,这意味着只有一个调用堆栈在运行函数。这一限制是JS异步本质的基础:所有需要时间的操作都必须由外部实体(例如浏览器)或回调函数负责。