什么是执行上下文
JavaScript 引擎在执行代码之前所做的一系列准备(创建的环境),就叫作执行上下文,执行上下文在创建时做了如下准备
- 设置作用域(包括变量对象和作用域链),该名词又可以称为词法环境(Lexical Environment)或者文本环境,后面我们默认采用前者
- 设置 this 关键字
执行上下文存在于一个执行上下文栈中,其中栈顶的上下文被常做为当前上下文,JavaScript 引擎总会在当前上下文中查找所需要的资源。
创建执行上下文的时机有以下四种,执行上下文的类型也根据此来分类
- 全局执行上下文
- 函数执行上下文
- eval 函数执行上下文
- 模块(module)执行上下文
全局执行上下文的特殊性
当我们在浏览器环境中,请求一个页面后(并准备执行里面的 JavaScript 代码时),JavaScript 引擎会创建一个全局执行上下文
全局上下文的词法环境由两部分组成,一部分是全局 scope
,另一部分是全局对象
,当我们在全局上下文中查找一个变量时,先去全局 scope 中查找,如果没有找到,再去全局对象中查找。
同时,通过 var
关键字声明的全局变量和函数会存放在全局对象中,通过 let
和 const
关键字声明的全局变量会在全局 scope 中。
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 的绑定),他们的具体设置步骤如下
创建执行上下文(根据所处位置创建不同的上下文)
进行词法分析,找到所有的变量和函数声明,其中这个过程也有先后
- 首先找到所有的不在函数中的 var 声明
- 找到当前块顶级函数声明(不在块作用域中,且不是函数表达式)
- 找到当前块 let 和 const 和 class 声明
- 找到块中的函数声明
进行重复变量名字的处理,
- 如果同时有一个变量和函数都叫做 foo,那么函数会覆盖掉变量,你可以理解为函数是后解析的,导致了覆盖
- 如果是 let const 之类的变量命名重复,就会报错
- 如果是 let var 变量名重复,也会报错
进行绑定
- var 声明的变量登记并初始化为 undefined
- 函数声明登记函数名字,并初始化为一个函数对象
- 块中的函数声明登记名字,初始化为 undefined(如果在登记名字的过程中,发现在变量对象中名字重复了,就不做任何操作,即不会将他初始化为 undefined)
- let const 和 class 声明的变量登记后不初始化
开始执行代码
在上述过程中,设置变量对象的过程包括词法分析和变量绑定,而作用域链的构建与函数对象的初始化有关。在函数对象初始化的时候,当前执行上下文会被保存到他的体内,而当我们执行到这个函数内部的时候,如果查找不到某个变量的声明,就会沿着这个函数内部保存的执行上下文进行查找,直到找到全局执行上下文,这就是所谓的作用域链
暂时性死区的解释
我们知道 let 会有暂时性死区的现象,但是如果从执行上下文创建的角度来思考,更容易明白为什么会有这种现象
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 等,他们是如何处理的呢,我们马上介绍一下
环境记录创建过程中发生了什么
- 在创建上下文等一系列操作执行完之后,JavaScript 开始执行代码,当执行到一个块时,会在当前上下文的词法环境中补充一个环境记录,链接在当前上下文词法环境的前面
- 然后进行词法分析,依次寻找
- 块中所有顶级的函数声明
- 块中剩余的 let const class(不找 var 是因为在上下文的解析过程中已经全部找完了)
- 进行重复处理,处理逻辑与执行上下文创建过程相同
- 进行绑定
- 对于 let const class 声明的变量,绑定规则同上,即只是登记名字,不初始化为 undefined
- 对于块中的函数,登记函数名字,给其初始化一个函数对象
- 执行块中的代码,当块中的代码执行完成后,如果块内有顶级声明函数,将会去全局对象中查找是否包含同名声明,如果有则将这个函数对象赋值给这个同名声明
块中函数提升的解释
如果在块中声明一个函数,那么也会发生函数的提升,但是他的提升并不是简单的拉到全局作用域顶部,而是有一些曲折,让我们从环境记录的角度慢慢剖析
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 不是函数的报错
多层块作用域也不影响函数的提升!
console.log(foo) // undefined
if (true) {
if (true) {
function foo() {
console.log("hello");
}
}
}
foo(); // hello
TIP
当块中的代码执行完毕后,如果发现内部有函数,块对应的环境记录不会被销毁,而是保留着以待内部的函数使用
有没有感觉很眼熟,没错,这就是闭包呀!
同名 let 声明导致块内函数无法提升
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 循环中的块作用域
首先看这个代码
const list = [];
for (var i = 0; i < 3; i++) {
list[i] = function () {
console.log(i);
};
}
list.forEach((fn) => fn());
这个代码会打印出三个 3,而不是 0 1 2,原因如下
- 最开始创建全局上下文将 list 和 i 绑定到全局上下文的词法环境中
- 然后执行 for 语句小括号的代码,即给 i 赋值操作,检查循环条件,然后进入到大括号中的代码执行(此时创建了环境记录,但并没有可以记录的变量名),为 list 的值进行赋值操作(这里是函数表达式)。循环执行完毕后,最终状态残留了三个闭包(环境记录),但闭包内没有任何变量被登记
- 执行完循环代码后,开始执行 list 中所有的函数,逐个创建函数上下文,他们的词法环境都是以函数内部保存的环境记录为父,因为他们自己的词法环境中并没有 i,因此向上查找闭包,发现其中也没有 i 的信息,继续向上查找到全局对象,发现此时的 i 已经变成了 3,因此全部打印 3
图示如下

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

同理可以知道这个代码是不会因为重复使用 let 定义 i 报错的
for (let i = 0; i < 3; i++) {
let i = 10;
console.log(i);
}
因为 for 创建的 i 的环境记录和块作用域中创建的 i 的环境记录根本不是同一个,上面的代码打印出来的值是三个 10
练习题
var 变量的作用域
var foo = 1;
function bar() {
if (!foo) {
var foo = 10;
}
console.log(foo);
}
bar(); // 10
首先全局执行上下文中找到了 foo,然后执行 bar 函数,此法分析时又找到了 foo,赋值为 undefined 后遮蔽了上层全局作用域的 foo,因此 if 中的赋值语句会执行,并最终打印出 10