为什么理解执行上下文这么重要?它是怎么与闭包,作用域链,变量提升紧密相连的

283 阅读8分钟

什么是执行上下文(Execution Context)?

执行上下文是JavaScript代码执行的环境。 这个环境包含执行代码所需要信息,比如可用的变量、函数和对象的信息,以及作用域链和this关键字的值。

什么时候会产生执行上下文?

ECMAScript2015标准是这么说的

A new execution context is created whenever control is transferred from the executable code associated with the currently running execution context to executable code that is not associated with that execution context.The newly created execution context is pushed onto the stack and becomes the running execution context.

每当控制从与当前运行的执行上下文关联的可执行代码转移到不与该执行上下文关联的可执行代码时,就会创建新的执行上下文。新创建的执行上下文会被压入栈顶并成为正在运行的执行上下文。

"当前运行的执行上下文关联的可执行代码"是指在调用栈栈顶的正在执行的代码,你可以简单的理解为一个函数, "不与该执行上下文关联的可执行代码",你可以理解为遇到了一个新的函数。合起来说人话就是:每调用一个函数就会创建新的执行上下文,且这个函数会被压入栈顶成为正在执行的执行上下文,栈顶以下的函数会被挂起,等待当前执行上下文完成。注意:在任意时刻只能有一个执行上下文在执行

执行上下文一般分为两种全局执行上下文(Global Execution Context,GEC)和函数执行上下文(Functional Execution Context,FEC),当JS引擎加载一个Script文件时,GEC默认就会被创建;当调用一个函数时,一个新的FEC就会被创建。

什么是词法环境(Lexical Environments)?

词法环境是执行上下文的一个组成部分(component),当一个执行上下文被创建时,它包含一个对其词法环境的引用。词法环境包含两部分环境记录(Environment Record)和指向外部词法环境的引用(Outer Lexical Environment Reference)。环境记录的作用是一种将标识符(例如变量名和函数名)映射到其相应值或引用。指向外层词法环境的引用构成了作用域链(Scope Chain)的基础,因为它允许内部(inner)函数访问其外部(outer)环境的变量和函数。记住这一点,这跟闭包的形成密切相关。

执行上下文是如何被创建和执行的?

一个执行上下文包括两个阶段

1. 创建阶段

在此阶段,JS引擎为执行的代码设置环境,并执行以下任务:

1.1 创建变量对象(Variable Object,VO):VO包含当前作用域中定义的所有变量和函数。它存储着函数的引用和变量的值。如果变量声明用的是var,则将其值初始化为undefined。VO用于在执行期间将标识符解析为其值。

VO只是一个抽象的概念,在ECMAScript标准中描述为VariableEnvironment

1.2 构建作用域链(Scope Chain):通过指向外部词法环境的引用来指向当前执行上下文的外部环境

作用域链是基于嵌套函数的词法环境创建的。之前我们说每个词汇环境都包含对其外部词汇环境的引用,这就形成了词汇环境链。当函数内引用变量时,JS引擎首先在当前词法环境(局部作用域)中查找该变量,然后通过外部词法环境(外部作用域)不断往外层找,直到找到该变量或到达全局作用域,这就形成了作用域链

当一个函数在另一个函数中定义时,一个闭包(Closure)就被创建了,它会捕获对其外部词法环境的引用,从而允许它访问其父作用域的变量和函数,即便父函数执行完后也是如此。

1.3 绑定“this”关键字:正确绑定“this”关键字的值

注意箭头函数(Arrow Function)与传统的函数不一样,它不会创建新的FEC,因此它没有自己的this,它的this的值取决于其外层作用域的this

2. 执行阶段

在此阶段,JS引擎读取代码,一次执行一行,并执行下列任务

2.1 为变量赋值:在执行阶段,若遇到赋值语句,则为变量赋值

2.2 执行函数和代码块:JS引擎在代码中遇到函数和代码块时执行它们。如果调用函数,则会为该函数创建一个新的执行上下文并将其添加到调用栈中。

2.3 管理调用栈:调用栈(Call Stack)是一种维护正在执行的函数的LIFO数据结构。当一个函数被调用时,它的执行上下文被添加到调用栈的顶部。当函数返回时,其执行上下文将从栈中删除。

实际例子讲解

理论知识到此为止,下面来看个具体的例子。

var x = 10
var y = 20

function sumOfTwo(num1, num2) {
    var result = num1 + num2
    return result
}

sumOfTwo(x, y)

首先第一步,内存创建阶段,你可以在第一行打个断点,然后打开chrome调试工具,你可以看到在代码执行之前,在内存中已经可以访问到我们声明的变量和函数了

image.png

如我们之前所说,JS引擎遇到一个Script文件,默认会创建一个全局执行上下文GEC,并把它压入调用栈。然后创建一个VO,你可以简单认为全局对象(浏览器里是window,node环境为global)包含VO。当然了,这些全局对象比VO有更多的功能,比如Web APIs。接着JS引擎遇到var声明的变量则初始化为undefined;遇到函数则存储其引用。然后将当前执行上下文的Outer Lexical Environment Reference指向null,因为它是已经是最外层的环境。注意这时候还没有执行代码,内存创建之后大概是这样子的

image.png

接着是执行阶段,JS引擎根据执行的语句给VO里的变量赋值,或创建新的执行上下文

执行 Line 1

image.png

执行 Line 2

image.png

执行 Line 9,关键地方来了,JS引擎遇到一个函数,上面我们说遇到一个函数就创建一个新的FEC。创建一个新的执行上下文时,需要经历内存创建和执行代码两个阶段。所以首先我们先进行内存创建阶段。内存创建阶段:再次,变量初始化为undefined,将FEC的Outer Lexical Environment Reference指向其外部词法环境,在这里就是GEC。

image.png

接着执行函数的内容,首先是局部参数初始化,num1=10; num2=20

image.png

执行 Line 5

image.png

执行 Line 6,函数遇到return,终止执行,函数出栈

image.png

控制权返回给GEC,JS引擎发现已经没有代码可执行了,于是GEC也出栈,调用栈为空。执行过程到此就结束啦,最后我们来谈谈变量提升。

变量提升(Hoisting)

提升是指解释器在执行代码之前将函数、变量、类或导入(imports)的声明移动到其作用域顶部的过程。

变量的初始化和赋值并不是同时完成的,简单来说创建阶段初始化,执行阶段才进行赋值

image.png

这意味着在代码执行之前,内存中已经有该变量的标识符了(还记得VO拥有这些信息吗),值是undefined。记住提升的只有声明,初始化并不会被提升,所以访问的值是undefined,但函数是完全被提升的,包括引用和函数体。

console.log(x) // undefined
var x = 10
var y = 20

console.log(sumOfTwo(x, y)) // 30

function sumOfTwo(num1, num2) {
    var result = num1 + num2
    return result
}

var,let and const

letconst定义的变量也会被提升,但是和var不同,如果在完成初始化前访问会得到ReferenceError,而不是undefined,在声明到完成初始化之前的这段时期也叫做TDZ(temporal Dead Zone)

// TDZ starts here
console.log(z) // ReferenceError: Cannot access 'z' before initialization
console.log(x) // undefined
var x = 10
var y = 20

console.log(sumOfTwo(x, y)) // 30

function sumOfTwo(num1, num2) {
    var result = num1 + num2
    return result
}

const z = 30 // TDZ ends here

总结

  • 执行上下文(Execution Context)是JS代码执行时的环境,它提供所有一切执行代码需要的信息。

  • 执行上下文分为GEC(Global Execution Context)和FEC(Funtional Execution Context)。GEC有且只有一个,可能会有多个FEC。

  • 执行上下文有两个阶段,内存创建阶段和执行阶段。创建阶段创建VO,构建作用域链(通过外部环境引用),正确绑定this的值

  • 在执行上下文创建阶段,变量声明和函数声明被提升到其包含范围的顶部,这意味着JS引擎在实际代码执行开始之前识别并为这些变量分配内存。

  • Lexical Environment是执行上下文的一个组成部分,由存储变量和函数声明的环境记录(Environment Record)和对指向外部词法环境的引用组成,这个引用帮助执行上下文建立作用域和变量访问规则,这跟作用域链和闭包的形成密切相关

这是个比较晦涩的问题,我看许多人的描述也不尽相同,看个人理解了,毕竟这些东西外部是访问不到的,是JS内部的工作机制。如果你有不同的理解完全OK。

如果你想了解更多,推荐看看ECMAScript2015对Executable Code and Execution Contexts的描述