执行上下文和提升与事件循环

也许你会感到惊讶,但我认为理解JavaScript语言最重要也是最基础的概念就是“执行上下文”。充分理解这个概念,能够帮助你进一步探讨一些(JS语言)更深入的话题,比如提升,作用域链和闭包。那么,究竟什么是执行上下文呢?为了更好的理解这个概念,我们得从软件开发的策略说起。

软件开发的一个有效策略是将我们将代码拆分成许多独立的“片段”。这些“片段”有许多不同的名字(functions,modules,packages等等),但它们的存在都是为了一个目的,即,将我们的应用按一定逻辑拆分,以降低其复杂度。

现在,我们从程序员视角切换成JavaScript引擎的视角。JS引擎的工作是处理JS脚本,那么它是否也是用同样的方式,将代码拆分成独立的“片段”,来降低自身“翻译工作”的复杂度呢?

答案是肯定的,而这些独立的代码块就是我们所说的执行上下文。我们已经了解执行上下文存在的目的(降低JS引擎翻译代码的复杂度),下一个要解决的问题是,执行上下文是如何创建的,以及它的构成是什么?

全局上下文

JS引擎编译代码时,**第一个创建的执行上下文叫做全局执行上下文(Global Execution Context)。**这个执行上下文由两部分组成,一个全局对象以及this变量。我们称this关键字为变量的原因是this实质上是一个指向全局对象的指针。而这个全局对象,在浏览器环境下是window,在Node环境下是global.

如上图所示,即使没有任何代码,全局执行上下文依然由window和this构成。这就是全局执行上下文最基本的形态。

当然完全没有代码的情况是不太可能的,接下来让我们定义一些变量和函数,看看全局执行上下文的变化。

看到上面两张截图的不同了吗?这里重要的知识点是全局执行上下文有两个不同的阶段,创建(Creation)阶段和执行(Execution)阶段,且每个阶段都有其各自的职责。

创建阶段

  1. 创建一个全局对象(window)
  2. 创建指向全局对象的this变量
  3. 我们声明的变量和函数分配好内存空间
  4. 与此同时,为我们声明的变量和函数赋默认值“undefined”

执行阶段,顾名思义,在这一阶段,JS引擎开始一行一行的执行我们的代码。

下面的GIF图形象地描述了全局执行上下文从创建到执行阶段的整个流程

就本例而言,在创建阶段,JS引擎创建了window和this,将我们所声明的变量(name和handle)赋值为undefined,最后为所有的函数(getUser)分配内存。然后在执行阶段,JS引擎逐行执行我们的代码,且将真实的值赋给变量。

那么什么是**“提升”**(hoisting)呢?这个词令人费解,因为其实没有任何东西被提升或者移动了,执行上下文在创建阶段为我们声明的所有变量分配内存并赋一个初始值(undefined)的过程,称之为提升。

现在我们已经非常清楚全局执行上下文有两个阶段,创建和执行。还有另一个执行上下文是我们需要学习的,叫做函数执行上下文(Function Execution Context)。JS引擎在执行函数调用的时候会创建函数执行上下文。

函数执行上下文与全局执行上下文非常相似,我们来看看它们的异同。JS引擎会在全局执行上下文初始化的时候创建一个全局对象(window),而在创建函数执行上下文时则不会。因为全局对象是唯一的,而且它并不是函数执行上下文需要关心的。函数执行上下文需要关心的是参数(Arguments)。有了这个知识点,我们便了解了,在函数执行上下文的创建阶段,JS引擎会:

  1. 创建一个arguments对象 (这个对象将保存我们在调用函数时传入的参数列表,如果我们没有传任何参数,arguments对象最基础的形态是 {length: 0});
  2. 创建一个this对象;
  3. 我们声明的变量和函数分配好内存空间;
  4. 与此同时,为我们声明的变量和函数赋默认值“undefined”

为了更形象的描述这一过程,我们使用了上一个例子用到的代码,但这次有所不同的是,我们在最后增加了对getUser函数的调用。

正如我们所讨论过的,JS引擎在getUser函数被调用时,创建了一个新的执行上下文(getUser函数的执行上下文)。在该执行上文的创建阶段,JS引擎创建了this对象和arguments对象,但因为getUser函数内没有声明任何变量,JS引擎不需要分配任何内存空间,也就是我面前面所描述的“提升”这一步被略去了。

我们还看到,当getUser函数执行完的时候,它的执行上下文就被移除了。事实上,JS引擎创建了一个执行栈(execution context,也可以称之为调用栈 call stack)。当一个函数被调用的时候,JS引擎创建一个新的函数执行上下文,并将它添加到执行栈里。当一个函数执行完毕(此处可以理解为其执行上下文已经完成了创建和执行),那么这个执行上下文就会从执行栈中被弹出(pop操作)。因为JS是单线程的,所以我们很容易将上述过程图像化。在下图中,我们以嵌套的方式来表述这一个过程,即,在执行栈内,每一个执行上下文是与上一个执行上下文相嵌套的。

我们已经看到函数是如何调用其执行上下文的,但是我们还没有讨论过当函数内声明了局部变量(Local variable)的情况。让我们看看下面的例子。

这里有一些细节是我们需要注意的。首先,我们在调用函数时的传参,会被作为一个变量添加到执行上下文中。在上例中,handle作为一个变量既存在于全局执行上下文中(因为它是一个全局变量),同时也存在于getUser的函数执行上下文中,因为它是getUser函数被调用时的传参。另外一个重点是,在函数内声明的变量则仅存在于该函数的执行上下文中。比如本例中的twitterURL变量,由于它是在getUser函数中被声明的,所以它仅存在于该函数的执行上下文中,而不在全局执行上下文中。这个道理浅显易懂,但我依然要画一个重点,因为它是我们下一个重要概念的基础,作用域(Scopes)

作用域 (Scopes) 和闭包 (Closures)

过去我们也许听过作用域的概念,诸如变量可用性的代码范围之类。无论这样的描述是否易于理解,现在我们明白了执行上下文和执行栈,我们对作用域概念的理解将前所未有的清晰。事实上,MDN对作用域的定义是“当前的执行环境 (The current context of execution)”,是不是听上去有些熟悉呢?让我们将作用域与执行上下文结合起来思考。

看看下面的代码,我的问题是,变量 bar 在控制台的输出值是什么?

function foo () {
  var bar = 'Declared in foo'
}
foo()
console.log(bar)

带着你的答案,我们来看看下面JS引擎的执行过程。

当foo函数被调用时,JS引擎创建了一个函数执行上下文,并将其存入(push操作)执行栈中。在foo函数执行上下文的创建阶段,JS引擎创建了this,arguments,并为bar分配了内存空间,将其值设为“undefined”。然后进入执行阶段,本例中我们将一个字符串“Declared in foo”赋值给变量bar。之后,执行结束,该函数执行上下文从执行栈中被弹出并关闭(pop操作)。当foo从执行栈中被移除后,我们尝试在控制台打印bar,然而这个时候,bar在当前的执行上下文中从未存在过,所以我们最后得到的是undefined(或一个系统错误 bar is not defined)。通过这个例子,我们知道在函数内声明的变量,其作用域是局部的。这意味着,当一个函数执行上下文从执行栈中移除,那么其局部变量则不可用了(这句陈述在绝大多数情况下是准确的,但有一个例外我们在接下来会讨论)。

我们来看下面一个例子,同样的问题,这段代码会在控制台输出什么?

function first () {
  var name = 'Jordyn'
  console.log(name)
}

function second () {
  var name = 'Jake'
  console.log(name)
}

console.log(name)
var name = 'Tyler'
first()
second()
console.log(name)

带着你的答案,我们来看看下面的执行图示。

控制台的输出结果依次是 undefined, Jordyn, Jake和Tyler。我们得到的结论是每一个执行上下文都有自己唯一的变量域。JS引擎总是首先在当前的执行上下文查找这个变量(name),即便其它执行上下文也包含这个变量JS引擎也不会使用。

这时你可能会疑惑,如果当前的执行上下文不包含这个变量呢?JS引擎会停止查找吗?我们来看看下面的代码片段,想一想控制台会输出什么?

var name = 'Tyler'

function logName () {
  console.log(name)
}

logName()

你的第一直觉也许是,控制台会输出undefined因为logName的执行上下文并没有一个name变量在其作用域内。听上去也不无道理但这是错的。当JS引擎无法在当前执行上下文中找到一个变量的时候,它会从当前执行上下文的最近父级执行上下文中查找,如果没有,就再向上一级查找。这个查找链会一直持续到全局执行上下文。如果全局执行上下文中还是没有这个变量,JS引擎会抛出一个引用错误(reference error)。

当一个变量在当前执行上下文中不存在时,JS引擎逐级地查找该变量的过程我们称之为作用域链。

前面我们提到,当一个函数执行上下文从执行栈中移除,那么其局部变量则不可用了,我加了备注“在大多数情况下,但有一个例外”。是时候来聊一聊这个例外了。有一种例外是多个函数的嵌套声明。在这种情况下,即便父函数的执行上下文已经被移除了,子函数仍然能够访问到其父函数作用域。我们还是不要赘述了,先来看看下面的可视化过程。

看到了吗?**当makeAdder函数的执行上下文从执行栈被移除时,JS引擎创建了一个闭包作用域(Closure Scope)。**这个闭包作用域内保存了一份与之前执行上下文相同的变量环境。函数的嵌套使JS引擎创建了这个闭包作用域。就本例而言,inner函数嵌套在makeAdder函数内,所以inner创建了一个闭包作用域来保存其父函数makeAdder的变量环境,当makeAdder的执行上下文被移除后,inner还是能够(通过作用域链的机制)访问到x变量。

子函数在其外层函数的执行上下文被移除时,保存外层函数的变量环境,这一概念我们叫做闭包(Closures)。

原文:https://tylermcginnis.com/ultimate-guide-to-execution-contexts-hoisting-scopes-and-closures-in-javascript/

事件循环 (Event Loop)

我觉得,说明JS引擎异步化工作原理最简单的方式就是使用 setTimeout,因为setTimeout允许我们指定一个函数延迟执行的时间。另外,setTimeout是非阻塞的(non blocking), 这是我们用它来演示JS异步化的一个重要原因。记住,JS是单线程的,这意味着它一次只能执行一个任务,而由于setTimeout的非阻塞特性,JS引擎并不会“坐等”这段我们指定的延迟时间,相反它会触发JS事件循环。

我们来看看下面的例子,我的问题是控制台会以什么顺序输出 ‘First’,‘Second’和‘Third’?

console.log('First')

setTimeout(function () {
    console.log('Second')
}, 1000)

console.log('Third')

也许你已经猜到啦,它的输出顺序是First, Third, Second, 答案显而易见,而其背后的运行机制就是事件循环。我们将使用一个叫做Loupe的工具来将上述代码的运行时可视化,其实就是放慢我们代码的执行速度,以便我们能够看清JS引擎是如何处理调用栈(或执行栈)以及异步代码的。

通过上面的GIF我们看到,首先,console.log(‘First’) 被执行,此时JS引擎不需要做任何非常规的工作,它仅仅需要将 log 函数推入(push操作)调用栈,然后在它执行完后,将其弹出并关闭(pop操作)。接下来,setTimeout方法被调用 (此处我们也可以认为我们的程序在发送一个网络请求或做其它任何异步化操作)。此时,JS引擎将 setTimeout 推入调用栈,setTimeout内的匿名方法被添加到Web APIs区间。Web APIs将负责倒计时(我们在代码中所指定的1000毫秒)。

这里需要说明的是setTimeout函数并不是JavaScript语言的一部分,它来自JS所运行的环境(浏览器或Node)。所以我们最好称setTimeout为一个Web API。除此之外,浏览器所提供的API还包括DOM,AJAX。这就是为什么在我们的可视化过程中,setTimeout内的匿名函数会被放到Web APIs区间,并由该区间负责计时。

接下来 setTimeout 从调用栈弹出,JS引擎继续逐行执行下面的代码,也就是将下一个log函数console.log(‘Third’) 推入调用栈,执行完毕后弹出并关闭。在这个log函数的执行过程中,Web APIs完成了1000毫秒的倒计时,于是它将匿名函数推入任务队列(Task Queue)。

**任务队列(Task Queue),也可称为回调队列(Callback Queue), 内保存了所有已经完成的异步操作的回调函数。**一旦调用栈空闲下来,JS引擎将遵照事件循环机制,将任务队列内的函数逐个推入调用栈。本例中,匿名函数就是setTimeout的回调。

我们来看下面的例子,同样的问题,我的问题是控制台会以什么顺序输出 ‘First’,‘Second’和‘Third’?

console.log('First')

setTimeout(function () {
  console.log('Second')
}, 0)

console.log('Third')

本例与之前唯一的不同是,setTimeout函数的延迟执行时间被设置为0毫秒。你的直觉也许是 First, Second, Third,但很不幸的,你错了。想想我们之前提到的事件循环机制,其关键就是只有当调用栈空闲时,任务队列内的函数才会被依次放入调用栈。

所以在本例中,当匿名函数被放到任务队列,console.log(‘Third’) 已经开始执行了。这意味着即便匿名函数的延迟时间为0,它仍然需要等上一个log函数执行完毕才能进入调用栈。因此,我们得到的结果依然是 First, Third, Second。

JS引擎在处理Ajax或Http请求的执行过程与上面我们所描述的过程是几乎完全一致的,有一点不同是Web APIs区间内的异步任务不再是setTimeout而是XMLHttpRequest。

Promises 是JS ES6提出的新的异步操作方案。为了适应(兼容) Promises,事件循环流程引入了一个工作队列(Job Queue)。工作队列与任务队列极为相似,唯一的不同是,工作队列的优先级较高。这意味着在事件循环机制中,工作队列中的任务会优先被处理(放入调用栈)。我们来看看下面的例子。

console.log('First')

setTimeout(function () {
  console.log('Second')
}, 0)

new Promise(function (res) {
  res('Third')
}).then(console.log)

console.log('Fourth')

控制台的输出结果是

First
Fourth
Third
Second

本例中尽管setTimeout的调用在Promise.then之前,但由于Promise所在的工作队列优先级高于任务队列,Third先于Second输出。



请遵守《互联网环境法规》文明发言,欢迎讨论问题
扫码反馈

扫一扫,反馈当前页面

咨询反馈
扫码关注
返回顶部