JavaScript 的执行上下文

105 阅读16分钟

大家可能会听说过以下专业术语:

  • 变量提升(Hoisting)
  • 作用域(Scope)和作用域链(Scope chain)
  • 闭包(Closure)
  • this

这些都涉及到一个知识点 —— 执行上下文(execution context)

执行栈与执行上下文

执行上下文是 JavaScript 执行一段代码时的基础运行环境;包括了变量环境,词法环境,this值,外部环境等内容。

(完整的运行时还包括 WEB API, 堆栈内存,消息队列,事件循环系统等等)

JS代码在引擎中是以“一段一段”的方式来解析的,而并非一行一行来解析的。而这“一段一段”的可执行代码可分为四种:​​​Global code​​​、​​​Function Code​​​、​​​Eval code​​​​、​​Module code​​​。每进入一个不同的可执行代码都会创建一个相应的执行上下文(Execution Context) ,然后将其​​压入执行栈(Call Stack)并执行,执行完以后会被弹出执行栈​

比如有代码:

var a;
function foo() {
    a = "hi, i am foo";
    console.log(a);
}
function baz() {
    foo();
}
baz();

整个代码执行过程如下:

图中的蓝色方块就是​​执行上下文(Execution Context)​​​,包在蓝色方块的灰色区域就是​​执行栈(Call Stack)​​,整个执行栈遵循后进先出的原则,伪代码大概如下所示:

// 代码执行前创建全局执行上下文
ECStack = [Global Execution Context]
// bar 调用
ECStack.push('baz Function Execution Context')
// foo 调用
ECStack.push('foo Function Execution Context')
// console.log 调用
ECStack.push('log Function Execution Context')
// console.log 调用完毕然后出栈
ECStack.pop('log Function Execution Context')
// foo 调用完毕然后出栈
ECStack.pop('foo Function Execution Context')
// bar 调用调用完毕然后出栈
ECStack.pop('baz Function Execution Context')
// 此时执行栈中只剩下一个全局执行上下文

​执行栈(Call Stack)​​是JS引擎追踪函数执行流程的一种机制,当执行环境中调用了多个函数时,通过这种机制,我们能够追踪到哪个函数正在执行,执行的函数体又调用了哪个函数; 

执行栈也称为执行上下文栈,又称为调用栈;

当一段 JavaScript 代码被调用运行时,会遇到两个阶段:编译和执行。

这看起来很简单。然而,所有的秘密都隐藏在这两个阶段中。

当 JavaScript 被编译时时候,一个执行上下文(execution context)被创建。当执行上下文准备就绪时,执行阶段就开始了。所有可执行的 JavaScript 代码会被一行一行地运行。

执行上下文是由以下四个组件组成:

  • 变量环境(Variable Environment)
  • 词法环境(Lexical Environment)
  • Outer
  • this

变量环境(Variable Environment)

此处我们先说说变量环境(Variable Environment)。先暂时忽略其他组件。

变量( Variables )和变量环境( Variable Environment )

让我们从一段代码代码开始:

var apple = 10;
console.log(apple);

当这段 JavaScript 代码运行时,第一步是编译。

在这个步骤中,执行上下文被创建。

同时,变量 ​​apple​​​ 被声明,且值为​​undefined​​,并存储到变量环境中。

编译阶段结束,执行阶段开始。

当第一行被执行时,变量 ​​apple​​​ 被赋值为数值 ​​10​​。变量环境也同时被更新。

然后执行第二行代码,console 开始在变量环境中寻找变量​​apple​​​,并成功找到。最终,​​console.log​​​ 输出 ​​10​​ 到控制台。

整个过程结束,同时执行上下文也被移除。

从这段示例代码中,可以收获以下关键点:

  • 变量赋值实际上被分成了两个阶段。
  • 编译阶段负责处理变量声明。
  • 执行阶段负责变量赋值和其余代码执行。

\

提升(Hoisting)

console.log(apple);
var apple = 10;

这段示例代码与上面的略有不同。在变量 ​​apple​​​ 被声明之前,先执行 ​​console.log​​ 操作。

我们知道最终控制台会输出 ​​undefined​​。但是,让我们从执行上下文的角度来一步一步看看到底发生了什么。

在编译阶段,第一行代码被跳过,因为它与变量声明没​有关系。然后第二行中的变量 ​​apple​​​ 在变量环境中被声明,并赋值 ​​undefined​​。此时,编译阶段结束。

执行阶段,第一行代码中​​console​​​开始在变量环境中寻找变量​​apple​​​。此时,变量​​apple​​​为​​undefined​​​。所以,控制台输出了​​undefined​​。

然后执行第二行代码,变量​​apple​​​被赋值为数字​​10​​。整个过程结束。

\

我们称这个过程为提升( Hoisting ),因为变量​​apple​​的声明好像是被提升到代码顶端。下面的代码片段模拟了提升的效果:

var apple = undefined;
console.log(apple);
apple = 10;

然而,从执行上下文角度来看,其实并没有什么提升。它的发生是因为变量在编译阶段中先声明了。提升这个名称是基于结果而产生的。

\

提升和函数

函数的提升略有不同,因为我们有两种方式来声明函数。

showNumber();
showName();

//赋值( assignment )
var showName = function() {
    console.log("Hi, this is my name.")
}

//声明( declaration )
function showNumber() {
    console.log("Hey, show a number.");
}

将函数赋值给变量 ​​showName​​​,而 ​​showNumber​​ 只是一个函数声明。

有趣的是,我们看到的输出是 ​​Hey, show a number.​​​ 后面跟着一个错误信息 ​​showName is not a function.​​。

让我们回顾一下编译和执行这两个阶段,看看到底发生了什么。

在编译阶段,​​showNumber​​ 是一个声明,所以在这个时候它将被保存到执行上下文中。

当来到​​showName​​​时,它被赋值为​​undefined​​,因为这是一个赋值语句。

​showName​​直到执行阶段才会被赋值具体函数。

如果你在第二行用 ​​console.log(showName)​​​ 来替换 ​​showName()​​​,控制台会输出 ​​undefined​​​。因为 ​​undefined​​​ 不是函数,所以才会有错误提示 ​​showName is not a function​​。

在编译阶段,JS引擎会扫描代码片段中的函数和变量的声明。这些函数和变量的声明都会被添加到内存中一个叫做词法环境的 JavaScript 数据结构中。所以这些函数和变量可以在实际声明之前就被使用,这就叫提升

示例

第一个示例:命名冲突

我们知道,当使用相同的变量名称时,后面一个变量会覆盖前面的一个变量。

你知道下面这段示例代码最终会输出什么吗?

showNumber()

function showNumber(){
    console.log("I'm showNumber1.")
}

function showNumber(){
    console.log("I'm showNumber2")
}

此处会输出"I'm showNumber2", 

如果函数名的属性已经存在,那么该属性将会被新的引用所覆盖。

showNumber()

function showNumber(){
    console.log("I'm a declaration function.")
}

var showNumber = 20

此处输出 ​​I'm a declaration function.​​​。第二个 ​​showNumber​​ 变量并没有覆盖第一个。

如果变量名的属性已经存在,为了防止同名的函数被修改为undefined,则会直接跳过,原属性值不会被修改。

var c = 1
function c(c) {
  console.log(c)
  var c = 3
}
c(2)

此处输出 ​​Uncaught TypeError: c is not a function.​​ 报错!!

这是一个很容易被忽视的陷阱,所以我们应该避免使用相同的变量名称。

console.log(apple);

if(false){
    var apple = 10;
}

这里,​​if​​​ 的条件是  ​​​​ ​​false​​​。这意味着 ​​if​​ 中的语句块永远都不会运行。

如果你运行这段代码,控制台会输出 ​​undefined​​​,而不是 ​​apple is not defined​​ 的错误信息。

这种情况下,编译阶段,变量 ​​apple​​ 仍然会被声明到变量环境中。

​if​​ 中的条件只在执行阶段起作用,而在编译阶段没有作用。

词法环境(Lexical Environment)

块作用域

我们可以用 ​​let​​ 重写上面的代码,这次引入了新的作用域,块作用域。

var apple = 'apple';

// block scope starts
if (true) {
    let apple = 'banana';
    console.log('if apple:', apple); // if apple: banana
}
// block scope ends

console.log('Global apple:', apple); // Global apple: apple

控制台中输出了两个不同变量 ​​apple​​​ 的值。第一个变量 ​​apple​​​ 的值是 ​​apple​​​,​​if​​​ 块中变量 ​​apple​​​ 的值是 ​​banana​​。

怎么会有两个同名的变量呢?

\

在编译阶段,第一个 ​​apple​​​ 变量被添加到全局执行上下文并初始化为 ​​undefined​​。

同时,JavaScript 引擎决定跳过第二个 ​​apple​​ 变量,这么做有两个原因:

  • 它是用 let 创建的变量
  • 它在块作用域中

接下来,执行阶段开始了。第一个 ​​apple​​​ 变量被赋值为 ​​apple​​。

当读到 ​​if​​​ 代码块时,发生了嵌套的编译阶段操作。第二个 ​​apple​​​ 变量被创建(未进行初始化)。

这个变量并没有在变量环境中创建,而是被添加到了词法环境中。

很快,​​banana​​​被赋给词法环境中的变量​​apple​​。

\

现在,我们在两个环境中有了两个名称相同的变量。这就是 JavaScript 引擎处理​​let​​的方式,并且还向后兼容了​​var​​。

词法环境中的作用域栈

为了更好的理解 ​​let​​​ 和 ​​var​​ 的区别,我们用一个有趣的示例把它们结合起来。

var apple = 'global apple';
let banana = 'global banana';

{
    let banana = 'block banana';
    var grape = 'global grape';
    let orange = 'block orange';
    console.log(apple);
    console.log(banana);
}

console.log(banana);
console.log(grape);
console.log(orange);

在编译阶段,变量 ​​apple​​​和 ​​grape​​​ 在变量环境中被初始化为 ​​undefined​​​。这里变量 ​​grape​​​ 的初始化被提升。同时词法环境中创建了变量 ​​banana​​(未进行初始化​)。

执行阶段开始,变量​​apple​​被赋值为​​global apple​​,而变量​​banana​​被赋值为​​global banana​​。

这时候开始处理块中的变量了。

在这里,变量​​banana​​和变量​​orange​​处在一个独立的作用域中(未进行初始化)。

从图中可以看出,当进入块级作用域时,作用域块中通过 let/const 声明的变量,会被存放在词法环境的一个单独的区域中,这个区域中的变量并不影响作用域块外面的变量,

其实,在词法环境内部,维护了一个小型栈结构,栈底是函数最外层的变量,进入一个作用域块后,就会把该作用域块内部的变量压到栈顶;当作用域执行完成之后,该作用域的信息就会从栈顶弹出,这就是词法环境的结构。需要注意下,我这里所讲的变量是指通过 let 或者 const 声明的变量。

​很快,这两个变量都被赋予相应的值。

\

最后一句赋值语句执行完成后,所以的变量都准备就绪。

当输出第一个变量时,JavaScript 引擎首先在词法环境中从上到下寻找变量 ​​apple​​​。然后再检查全局变量环境,并找到了变量 ​​apple​​​,然后输出 ​​global apple​​。

当查找变量​​banana​​​时,JavaScript 引擎遵循同样的步骤,最终在词法环境的顶端找到变量​​banana​​​,然后输出​​block banana​​。

与此同时,块中不再有可执行代码了。块作用域将被移除。

脚本继续执行。JavaScript 引擎在词法环境中找到了变量​​banana​​​,在全局变量环境中找到了变量​​grape​​​,并相应地输出​​global banana​​​和​​global grape​​。

当脚本执行到搜索变量​​orange​​​时,这个变量此时并不存在,因为它存在的词法作用域已经被删除了。这时候将抛出一个错误​​orange is not defined​​。

示例

let apple = 'apple';

{
    console.log(apple);
    let apple = 'banana';
}

上面这段代码最终会输出什么?​​apple​​​ 还是 ​​banana​​?

令人奇怪的是,会输出一个错误 ​​Cannot access apple before initialization.​​。

从编译到执行,一个变量要经历三个阶段:

  1. 创建(Creation)
  2. 初始化(Initialization)
  3. 赋值(Assignment)
  • 对于 ​​var​​ 变量来说,它的创建和初始化是被提升的,但赋值不是。
  • 对于 ​​let​​ 变量来说,它的创建是被提升的,但是初始化和赋值不是。
  • 对于函数来说,它的创建、初始化和赋值同时被提升。

JavaScript 中所有的声明(function, var , let , const 和 class )都会被提升,而 **** ​​var​​ ****声明被初始化为 ****​​undefined​,但是 **** ​​let​​ ****  **** ​​const​​ **** 声明没有被初始化

将变量初始化之前的代码阶段称为暂时死区(temporal dead zone TDZ)。

Outer

变量查询

在下面的代码示例中,变量的查询会让人觉得很困惑。

var apple = 'apple';

function isApple() {
    console.log(apple);
}

function isBanana() {
    var apple = 'banana';
    isApple();
}

isBanana(); // console.log 将会输出什么?

\

当执行 ​​isApple​​ 函数的时候,有三个执行上下文在调用栈中。

  • 全局执行上下文
  • ​isBanana​​ 函数执行上下文
  • ​isApple​​ 函数执行上下文

\

接下来,​​console​​​ 开始查找变量 ​​apple​​。

直观的看,我们可以通过调用栈中从上到下的顺序来查找变量。这样的话 ​​console​​​ 会输出 ​​banana​​​,因为会在 ​​isBanana​​​ 函数执行上下文中找到变量 ​​apple​​。

不同的是,​​console​​​ 实际输出了 ​​apple​​​,这个在全局执行上下文中变量 ​​apple​​ 的值。

\

这是为什么?

\

​我们的链条查询遗漏了执行上下文中一个重要的组件—— ​​​outer​​​。​。

outer 定义了 JavaScript 引擎如何执行链条查询,也被称为作用域链(scope chain)。

如果我们看 ​​isApple​​ 函数执行上下文,它的 outer 指向的是全局执行上下文。

\

为什么 ​​isApple​​ 函数执行上下文中的 outer 指向了全局执行上下文而不是 ​​isBanana​​ 函数执行上下文?

\

词法作用域(Lexical Scope)

\

JavaScript 中的作用域链是由词法作用域决定(即代码结构中的函数位置决定的),不受调用栈的影响

\

var apple = 'apple';

function isApple() {
    console.log(apple);
}

function isBanana() {
    var apple = 'banana';
    isApple();
}

isBanana(); // console.log 将会输出什么?

\

在这个示例中,​​isApple​​​ 和 ​​isBanana​​ 这两个函数都是在全局作用域中声明。因此,它们的词法作用域都是全局作用域。

\

从两个阶段运行的角度看,作用域链是在编译阶段定义的,而不是在执行阶段

当 JavaScript 引擎编译代码时,这两个函数执行上下文的 outer 都指向了全局执行上下文。

\

​不同于在全局作用域中声明函数,我们将每个函数声明到前一个函数的内部。

let price = 10;

function priceA() {
    let price = 20;
    (function priceB() {
        let price = 30;
        (function priceC() {
            console.log(price);
        })();
    })();
}

priceA();

在这个示例中,

  • 函数 ​​priceA​​ 定义在全局作用域中;
  • 函数 ​​priceB​​​ 定义在 ​​priceA​​ 作用域中;
  • 函数 ​​priceC​​​ 定义在 ​​priceB​​ 作用域中。

根据词法作用域定义,我们可以推理出每个执行上下文中的 outer :

  • 在 ​​priceC​​​ 函数执行上下文中,outer 指向 ​​priceB​​ 函数执行上下文;
  • 在 ​​priceB​​​ 函数执行上下文中,outer 指向 ​​priceA​​ 函数执行上下文;
  • 在 ​​priceA​​ 函数执行上下文中,outer 指向全局执行上下文。

最后执行的时候,​​console​​​ 输出 ​​30​​。

这就是作用域链在 JavaScript 的执行上下文中的工作方式。

This

谁调用这个方法,谁就是 this。

this 与作用域链没有关联,两者是不同的机制;

为什么箭头函数没有this

箭头函数不创建自己的执行上下文,所以就没有this

箭头函数会无视以上所有的规则,是因为箭头函数不会创建自己的执行上下文, 而 this 本身是属于执行上下文中的一个组件,执行上下文都不创建了,自然不会有属于自己的 this;  如果非要在箭头函数中使用 this , 实际上使用的是外部作用域的 this ;

普通函数执行的时候, 需要创建一堆的环境, 比如变量环境, 词法环境, this, outer 等等, 但是很多函数类似于 function (x, y) { return x + y } 这种纯函数,根本不涉及这些环境信息的,为了加快函数的执行效率, ECMA委员会专门设计了一个阉割版的函数, 也就是箭头函数, 箭头函数直接不创建执行上下文, 加快了执行的效率

延伸

1:其他 ES 版本的执行上下文

ES3 中,包含三个部分。

scope:作用域,也常常被叫做作用域链。

variable object:变量对象,用于存储变量的对象。

this value:this 值。

在 ES5 中,改进了命名方式,把执行上下文最初的三个部分改为下面这个样子。

lexical environment:词法环境,当获取变量时使用。

variable environment:变量环境,当声明变量时使用。

this value:this 值。

在 ES2018 中,执行上下文又变成了这个样子,this 值被归入 lexical environment,但是增加了不少内容。

lexical environment:词法环境,当获取变量或者 this 值时使用。

variable environment:变量环境,当声明变量时使用。

code evaluation state:用于恢复代码执行位置。

Function:执行的任务是函数时使用,表示正在被执行的函数。

ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码。

Realm:使用的基础库和内置对象实例。

Generator:仅生成器上下文有这个属性,表示当前生成器。

2:为什么 JS 运行时有编译和执行两个阶段

首先先了解一些概念:

编译器和解释器
之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。

按语言的执行流程,可以把语言划分为编译型语言和解释型语言。编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

了解上面的概念之后,v8引擎就是走的基于解释器的路线,但是又与它不同,是一个改进版。具体如下图

v8引擎处理代码流程.png

从图中可以看出v8是解释器、编译器一起用了的。

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

回到上面的js运行时两个阶段,编译阶段和执行阶段,编译阶段就是源码 ->AST -> 字节码。执行阶段就是基于字节码、机器码执行。