[译] JavaScript 之执行上下文,函数调用栈,事件循环

原文地址:The JavaScript Execution Context, Call-stack & Event Loop

给你一段 JavaScript 代码,你可能会很快知道这段代码运行后的结果。但若我问你,JavaScript 引擎是如何执行代码并给出结果的? 或者问你 click handler, AJAX call 等回调函数是如何被触发的?你是不是一时语塞了

理解 执行上下文( Execution Context ) 、调用栈( Call-stack )和 事件循环( Event Loop )这三个概念并知道他们是如何协作的,你就能回答以上两个问题

执行上下文 ( Execution Context )

var a = 4;
function foo(num) {
    return num * num;
}
var b = foo(a);

很简单直白的一段代码,我们将数字 42 赋值给变量 a , 声明 foo() 方法,把变量 a 当作参数传给 foo() 方法并调用,最后把函数执行的结果赋值给变量 b 。如果我问 b 的值是多少?你会马上说出答案。那么 JavaScript 是如何得到最终结果的呢? 让我们来一起探讨这个问题吧

针对以上代码,JS 引擎要做的第一件事情就是生成运行这段代码的 执行上下文。引擎在执行代码的时候有着精确的步骤,在生成执行上下文的时候,分两个阶段,分别是:生成阶段(  creational phase )和执行阶段(  execution phase )

首先,在代码执行之前,一个全局的执行上下文( Global Execution Context )就被生成了。在生成阶段,引擎会做以下一些事情:

  • 创建一个全局实例对象,在浏览器中叫做 window , 在Node中叫做 global
  • 创建一个this对象 , this指向上一步创建的全局实例对象
  • 设置用于存储变量和函数引用的内存堆
  • 将函数声明存储在上一个的内存堆中,并将执行上下文中的每个变量赋值为undefined

在生成阶段,引擎会保存变量 a ,b 以及函数 foo 。 变量a 和 b 会被赋值为 undefined

当生成阶段结束以后,引擎会跳到执行阶段。在执行阶段,代码会从上往逐行执行。在这个阶段,变量会被赋予它真正的值,并且函数也会被调用

此时如果你的代码内没有函数被调用,那么存储到这一步就结束了。然而,每一个你调用的函数,引擎都会创建一个 函数执行上下文,这个上下文和上面我们介绍全局执行上下文基本一致,但是此时 global object的对象不是 window ,而是 arguments , 它指向外部传入到该函数的全部参数的引用

回到我们上文的代码示例,在执行阶段,引擎首先去拿已声明变量 a ,然后将42赋值给它,然后回执行到给变量 b 赋值,注意,这一行有一个方法调用,所以引擎会创建一个新的函数运行上下文,然后重复上面的步骤(不过,此时有个argumenrts 对象被创建)

但引擎是如何跟踪所有这些执行上下文呢 ? 特别是在有多个嵌套函数调用的情况下 ? 它如何知道哪一个是活动的,或者哪一个已经完全执行了? 这就要用到我们即将介绍的下一个概念:函数调用栈

函数调用栈(Call-Stack)

调用堆栈是一种数据结构,用于跟踪和管理 JavaScript 的函数执行。 它的工作是存储在代码执行过程中创建的所有执行上下文,并记录我们实际处于哪个执行上下文以及那些仍然留在堆栈上的执行上下文 。 当您调用一个函数时,引擎将该函数推到堆栈的顶部,然后创建一个执行上下文。从上面对执行上下文的研究中,我们知道这个上下文要么是全局上下文,要么是函数执行上下文

每当一个函数执行完后,函数调用栈会弹出这个函数,然后移动到下一个函数,直到栈上没有函数为止,这种顺序称为后进先出LIFO(Last in First Out)

当调用一个函数时,将创建一个堆栈帧。这是内存中存储参数和变量的位置(还记得我们在上面讨论的内存堆吗?)当函数返回(隐式或显式)时,该内存将被清除,整个上下文将从调用堆栈中弹出

执行上下文从栈中弹出一个接一个,他们与每一个创建一个完整的执行堆栈帧当我们抛出错误时,我们得到了所谓的堆栈跟踪,这就是听起来——跟踪所有的执行上下文从错误到我们已通过的所有上下文。

如果拥有的帧数超过了堆栈的设计容纳量,也可能会破坏调用堆栈。这可能发生在递归调用一个函数时,没有某种退出条件,或者我确信我们都在某个时间点做过——当运行一个无限的for循环时。

请看如下代码块:

function thirdFunc() {
    console.log("Greetings from thirdFunc()");
  }
  
  function secondFunc() {
    thirdFunc();
    console.log("Greetings from secondFunc()");
  }
  
  function firstFunc() {
    secondFunc();
    console.log("Greetings from firstFunc()");
  }
  
  firstFunc();

灵魂拷问,我们是怎么知道最后输出结果的呢?

当我们运行这段代码的时候,引擎首先启用函数调用栈,将main() 和 global() 函数压入函数调用栈 , 这就是执行JS代码的主线程,我们上文讲到的执行上下文会先进入 创建上下文阶段,然后再进入 执行上下文 阶段。 我们在前一节中描述的执行上下文将首先进入创建阶段,然后调用执行阶段。在此阶段,当引擎到达对firstFunc()的调用时,调用堆栈将再次被引用,firstFunc()的函数执行上下文将推入main()顶部的调用堆栈(下面的步骤2)

现在,引擎开始执行 firstFunc() 因为它在函数调用栈里最上面。引擎会创建一个私有执行上下文用和私有内存用来存储 变量,参数,函数声明等(作用域与这个息息相关)

firstFunc() 函数的第一行就是调用 secondFunc() ,此时,引擎会重新引用函数调用栈把 函数 secondFunc() 压入到最顶端,然后执行上面的过程。 在 secondFunc() 函数的第一行,又是调用 thirdFunc(),那么就重复上面的过程

当引擎开始执行 thirdFunc() 以后,这个函数内没有方法调用了,我们仅仅打印了一个字符串 ” Greetings from thirdFunc() “(注意:console.log()其实也是一个方法,但是我们为了简化模型,暂时把它只当作一行执行语句)。 因为函数中没有更多的语句,执行完毕以后它会隐式返回(译者注:其实所有函数都有返回,如果你没有明确它返回的值,引擎会默认返回 undefined ) ,此时,调用栈会弹出thirdFunc() (步骤4),弹出以后,secondFunc() 处于栈顶,引擎会执行 secondFunc()内剩余的语句,打印出” Greetings from secondFunc() ” , 同样执行完以后,调用栈弹出secondFunc(),如此反复,一旦调用栈为空,程序就停止运行

调用堆栈的性质反映了这样一个事实:JavaScript本质上是单线程的,一次只能运行一个执行上下文。这意味着在执行一个函数时,引擎不能同时运行另一个上下文。它还意味着,每次将一个函数推入调用堆栈时,它都会成为活动的执行上下文,并从调用它的任何函数中获取控制流,直到显式(使用return语句)或隐式(当所有指令都已执行时)返回。

如果故事到这里就结束,那么JavaScript就不可能被用于各种应用,更别提有用户输入,资源请求,API调用这些异步操作了。因为它是单线程呀,那么意味着,当我在加载一张图片的时候,是不是我就不能输入用户名和密码?当然不是,那么JavaScript是如何实现异步的呢?那就得介绍我们鼎鼎大名的事件循环机制了(event loop)

事件循环机制(The Event Loop)

从上文可知,JavaScript是单线程模式, 它从我们代码的顶部开始,向下工作,根据需要创建新的执行上下文,并将它们推入和弹出调用堆栈。 如果有一个阻塞函数需要很长时间才能执行,那么在该函数位于调用堆栈顶部期间,浏览器将无法执行任何操作。不能发生新的执行上下文或代码执行。这意味着即使是像滚动和按钮单击事件这样的用户输入也不能工作。

相反,当我们有一个可能需要很长时间才能完成的函数时,我们通常会提供一个回调函数。这个函数封装了我们希望在稍后的阻塞操作(e。网络呼叫)已经被解决。这允许我们将控制权返回给JS引擎,并将其余的执行推迟到调用堆栈被清除之后。这就是JavaScript中的异步概念。

调整一下代码,看看异步是怎么执行的:

function thirdFunc() {
  setTimeout(function() {
    console.log("Greetings from thirdFunc()");
  }, 5000);
}

function secondFunc() {
  thirdFunc();
  console.log("Greetings from secondFunc()");
}

function firstFunc() {
  secondFunc();
  console.log("Greetings from firstFunc()");
}

firstFunc();

在上面的代码中,执行与前面的示例一样开始。但是,当引擎到达第三个函数时,它不是立即将消息记录到控制台,而是调用setTimeout(),这是浏览器环境提供给我们的一个API。这个函数接受一个“回调”函数,该函数将存储在一个我们尚未讨论的结构中,称为回调队列。然后thirdFunc()将完成它的执行,依次将控制权返回给secondFunc()和firstFunc()。最后,在至少5秒之后(后面会详细介绍),来自thirdFunc()的消息将被记录到控制台。

在JavaScript中,我们实现代码异步执行的机制是通过环境api (Node和浏览器都提供某些api,向我们公开底层特性)、回调队列和事件循环来实现的。 并发性(或它的假象)是通过这些额外的机制实现的。

正如我们所说的,调用堆栈用于跟踪当前执行的函数上下文,回调队列跟踪需要在以后运行的任何执行上下文。例如传递给setTimeout函数或节点异步任务的回调。在调用代码时,事件循环会定期检查调用堆栈是否为空。一旦调用堆栈在我们的代码中运行了所有的执行上下文,事件循环将获取进入回调队列的第一个函数,并将其放在要执行的调用堆栈上。然后再次重复该过程,不断地检查调用堆栈和回调队列,并在调用堆栈为空时将函数从回调队列传递到调用堆栈。

还记得我们说过setTimeout回调将从调用setTimeout开始“至少”运行5秒吗?这是因为setTimeout并不只是在超时完成时将其代码插入调用堆栈,它必须将其传递给回调队列,然后等待事件循环在调用堆栈为空时将其放入调用堆栈。只要调用堆栈中还有项,就不会运行setTimeout回调。让我们仔细看看这个

我们的代码按上面的方式运行,直到到达第三个函数为止,此时setTimeout被调用,从调用堆栈中取出并开始倒计时。我们的代码继续到secondFunc和firstFunc和console。依次记录他们的消息。与此同时,setTimeout几乎立即(在0秒内)完成了它的倒计时,但是没有办法让它的回调直接进入调用堆栈。相反,当它完成倒计时时,它将回调传递给回调队列。事件循环不断检查调用堆栈,但在此期间secondFunc和firstFunc占用了调用堆栈上的空间。直到这两个函数完成执行并清空调用堆栈后,事件循环才接受传递给setTimeout的回调函数,并将其放在要执行的调用堆栈上

这就是为什么有时您会发现使用0调用setTimeout的模式是一种延迟执行传递给它的回调中的代码的方法。我们只是想确保所有其他同步代码在setTimeout回调中的代码之前运行。

还需要注意的是,“回调”是由另一个函数调用的函数,但是我们在上面讨论的回调,例如传递给setTimeout的回调是“异步回调”。区别在于,异步回调被传递到回调队列,等待被(通过事件循环)放到调用堆栈上,以便稍后执行

至此,我们已经介绍了JavaScript代码执行的主要概念,以及JavaScript引擎如何处理异步代码。我们已经看到JS引擎是单线程的,只能同步执行代码。我们还看到了在不阻塞执行线程的情况下实现异步代码的机制。我们还可以更好地理解函数执行的顺序和围绕这个过程的规则。

这些概念很容易理解,但是值得花时间真正掌握它们,因为它们是深入了解JavaScript的基础。不仅仅是var a = 2语法,而是一个完整的视图,关于JavaScript采用这种语法并运行时到底发生了什么。这些概念还可以作为更好地理解其他概念(如作用域和闭包)的基础。

发表评论

电子邮件地址不会被公开。 必填项已用*标注