Skip to content

JavaScript 执行上下文

什么是执行上下文

JavaScript 引擎在执行代码之前所做的一系列准备(创建的环境),就叫作执行上下文,执行上下文在创建时做了如下准备

  • 设置作用域(包括变量对象和作用域链),该名词又可以称为文本环境或者词法环境
  • 设置 this 关键字

执行上下文存在于一个执行上下文栈中,其中栈顶的上下文被常做为当前上下文,JavaScript 引擎总会在当前上下文中查找所需要的资源。

创建执行上下文的时机有以下四种,执行上下文的类型也根据此来分类

  1. 全局执行上下文
  2. 函数执行上下文
  3. eval 函数执行上下文
  4. 模块(module)执行上下文

全局执行上下文的特殊性

当我们在浏览器环境中,请求一个页面后(并准备执行里面的 JavaScript 代码时),JavaScript 引擎会创建一个全局执行上下文

全局上下文的文本环境由两部分组成,一部分是全局 scope,另一部分是全局对象,当我们在全局上下文中查找一个变量时,先去全局 scope 中查找,如果没有找到,再去全局对象中查找。

同时,通过 var 关键字声明的全局变量和函数会存放在全局对象中,通过 letconst 关键字声明的全局变量会在全局 scope 中。

javascript
var a = 3;
console.log(global.a); // 输出: 3

let b = 4;
console.log(global.b); // 输出: undefined

console.log(Function); // 输出: [Function: Function]
console.log(global.Function); // 输出: [Function: Function]

let Function = "some";
console.log(Function); // 输出: 'some'
console.log(global.Function); // 输出: [Function: Function]

而其他的上下文没有类似的结构,它们的文本环境只有 scope

执行上下文创建过程中发生了什么

我们前面提到创建执行上下文的时候会设置变量对象和作用域链(暂且不讲 this 的绑定),他们的具体设置步骤如下

  1. 创建执行上下文(根据所处位置创建不同的上下文)

  2. 进行词法分析,找到所有的变量和函数声明,其中这个过程也有先后

    1. 首先找到所有的不在函数中的 var 声明
    2. 找到当前块顶级函数声明(不在块作用域中,且不是函数表达式)
    3. 找到当前块 let 和 const 和 class 声明
    4. 找到块中的函数声明
  3. 进行重复变量名字的处理,

    1. 如果同时有一个变量和函数都叫做 foo,那么函数会覆盖掉变量,你可以理解为函数是后解析的,导致了覆盖
    2. 如果是 let const 之类的变量命名重复,就会报错
    3. 如果是 let var 变量名重复,也会报错
  4. 进行绑定

    1. var 声明的变量登记并初始化为 undefined
    2. 函数声明登记函数名字,并初始化为一个函数对象
    3. 块中的函数声明登记名字,初始化为 undefined(如果在登记名字的过程中,发现在变量对象中名字重复了,就不做任何操作,即不会将他初始化为 undefined)
    4. let const 和 class 声明的变量登记后不初始化
  5. 开始执行代码

在上述过程中,设置变量对象的过程包括词法分析和变量绑定,而作用域链的构建与函数对象的初始化有关。在函数对象初始化的时候,当前执行上下文会被保存到他的体内,而当我们执行到这个函数内部的时候,如果查找不到某个变量的声明,就会沿着这个函数内部保存的执行上下文进行查找,直到找到全局执行上下文,这就是所谓的作用域链

暂时性死区的解释

我们知道 let 会有暂时性死区的现象,但是如果从执行上下文创建的角度来思考,更容易明白为什么会有这种现象

javascript
var a = 3;
function foo() {
  console.log(a);
  let a = 3;
}
foo();

首先创建全局执行上下文,然后找到了一个全局变量 a 和一个全局函数 foo,然后进行绑定,开始执行代码,执行到第一行时,a 被赋值为了 3

然后开始执行 foo 函数,创建了一个函数执行上下文,此时继续进行词法分析,找到了一个 a 的声明,然后进行绑定,但注意此时的 a 并没有被初始化,所以在执行 console.log(a) 时会报错,这就是暂时性死区的原因

什么是环境记录

这个名词你可能没有听过,但不要害怕,这个名词主要是为了应对 ES6 块作用域而冒出来的,你可以把他理解为执行上下文青春版。每当要进入一个块作用域,我们就会像进入一个新的上下文一样(打个比方),创建一个环境记录,链接到前面的执行上下文或者环境记录上

前文我们提到,执行上下文创建过程中会查找所有不在函数中的 var,不在块中的 let const 等,而我们又知道,块作用域的粒度更细,因此执行上下文创建中有一点细微的地方没有覆盖,那就是块中声明的 let const function 等,他们是如何处理的呢,我们马上介绍一下

环境记录创建过程中发生了什么

  1. 在创建上下文等一系列操作执行完之后,JavaScript 开始执行代码,当执行到一个块时,会在当前上下文的文本环境中补充一个环境记录,链接在当前上下文文本环境的前面
  2. 然后进行词法分析,依次寻找
    1. 块中所有顶级的函数声明
    2. 块中剩余的 let const class(不找 var 是因为在上下文的解析过程中已经全部找完了)
  3. 进行重复处理,处理逻辑与执行上下文创建过程相同
  4. 进行绑定
    1. 对于 let const class 声明的变量,绑定规则同上,即只是登记名字,不初始化为 undefined
    2. 对于块中的函数,登记函数名字,给其初始化一个函数对象
  5. 执行块中的代码,当块中的代码执行完成后,如果块内有顶级声明函数,将会去全局对象中查找是否包含同名声明,如果有则将这个函数对象赋值给这个同名声明

块中函数提升的解释

如果在块中声明一个函数,那么也会发生函数的提升,但是他的提升并不是简单的拉到全局作用域顶部,而是有一些曲折,让我们从环境记录的角度慢慢剖析

javascript
console.log(foo); // undefined
if (true) {
  function foo() {
    console.log("hello");
  }
}
foo(); // hello

首先创建全局上下文,找到了块作用域中的函数 foo,将其绑定到 undefined,开始执行代码,执行到 console.log(foo) 时,输出 undefined

然后执行块作用域中代码,首先创建环境记录,然后找到顶级函数 foo,为其绑定一个函数对象,块作用域中的代码执行完毕后,发现全局对象中已经有了同名的 foo,于是将这个函数对象赋值给全局对象中的同名变量,因此再执行 foo() 的时候就可以找到需要的函数对象了

而我们如果将 true 换为 false,就会报错,因为块作用域代码并没有执行,所以全局对象中只有一个 foo,其值为最开始的 undefined,因此直接调用 foo 会出现 foo 不是函数的报错

多层块作用域也不影响函数的提升!

JavaScript
console.log(foo) // undefined
if (true) {
  if (true) {
    function foo() {
      console.log("hello");
    }
  }
}
foo(); // hello

TIP

当块中的代码执行完毕后,如果发现内部有函数,块对应的环境记录不会被销毁,而是保留着以待内部的函数使用

有没有感觉很眼熟,没错,这就是闭包呀!

同名 let 声明导致块内函数无法提升

javascript
let foo;
if (true) {
  function foo() {
    console.log("this is foo");
  }
}
foo();

分析同上,最开始全局上下文声明的 foo 被放入了全局 scope 中,然后找到块内的函数,发现变量对象内已经有同名的了,就不会做任何操作

然后执行块作用域中的代码,找到了顶级函数 foo,为其绑定了一个函数对象,退出块时发现全局对象中没有 foo 的名字(因为一开始 foo 声明后被绑定在了全局 scope 中),就不会把这个函数对象赋值到全局对象上去,因此最后执行 foo() 时候自然会报错“foo 不是一个函数”

而我们如果将 let 换为 var,这个报错就不会出现,具体情况自己分析

TIP

上面这种情况 LSP 静态检查也会报错 Cannot redeclare block-scoped variable 'foo',所以不用担心自己写的代码出现这种情况

for 循环中的块作用域

首先看这个代码

javascript
const list = [];

for (var i = 0; i < 3; i++) {
  list[i] = function () {
    console.log(i);
  };
}

list.forEach((fn) => fn());

这个代码会打印出三个 3,而不是 0 1 2,原因如下

  1. 最开始创建全局上下文将 list 和 i 绑定到全局上下文的词法环境中
  2. 然后执行 for 语句小括号的代码,即给 i 赋值操作,检查循环条件,然后进入到大括号中的代码执行(此时创建了环境记录,但并没有可以记录的变量名),为 list 的值进行赋值操作(这里是函数表达式)。循环执行完毕后,最终状态残留了三个闭包(环境记录),但闭包内没有任何变量被登记
  3. 执行完循环代码后,开始执行 list 中所有的函数,逐个创建函数上下文,他们的词法环境都是以函数内部保存的环境记录为父,因为他们自己的文本环境中并没有 i,因此向上查找闭包,发现其中也没有 i 的信息,继续向上查找到全局对象,发现此时的 i 已经变成了 3,因此全部打印 3

图示如下

而与此同时,如果把 for 中的 var 改成 let,会有些许改变

  1. 最开始创建全局上下文将 list 绑定到全局上下文的词法环境中
  2. 然后执行 for 语句小括号的代码,首先创建一个环境记录(可以理解为循环语句创建的块作用域),将 i 记录,然后复制该环境记录,用以进行循环条件判断操作,等进入块作用域后,创建新的环境记录,连接到前面创建的循环标志环境记录前面,此时创建的函数对象的文本环境就是这个最新的环境记录。等到开始下一轮循环的时候,会再次复制循环标志环境记录,对里面的 i 进行运算自增操作,然后判断是否符合循环条件,然后执行块作用域代码,如此循环。最终状态是,共有六个循环标志块作用域(一个是刚进入时候创建的,五个是循环时复制出来的),还有五个块作用域
  3. 执行完循环代码后,开始执行 list 中所有的函数,逐个创建函数上下文,他们的词法环境都是以函数内部保存的环境记录为父,因为他们自己的文本环境中并没有 i,因此向上查找块作用域也没有 i 的信息,接着向上查找循环标志块作用域,发现环境记录中有已经复制保存后的 i(即对应的 0 1 2),所以可以打印出相应的值

图示如下

同理可以知道这个代码是不会因为重复使用 let 定义 i 报错的

javascript
for (let i = 0; i < 3; i++) {
  let i = 10;
  console.log(i);
}

因为 for 创建的 i 的环境记录和块作用域中创建的 i 的环境记录根本不是同一个,上面的代码打印出来的值是三个 10

练习题

var 变量的作用域

JavaScript
var foo = 1;
function bar() {
  if (!foo) {
    var foo = 10;
  }
  console.log(foo);
}
bar(); // 10

首先全局执行上下文中找到了 foo,然后执行 bar 函数,此法分析时又找到了 foo,赋值为 undefined 后遮蔽了上层全局作用域的 foo,因此 if 中的赋值语句会执行,并最终打印出 10

参考

https://www.bilibili.com/video/BV1wD4y1D7Pp