JavaScript执行机制:执行上下文
你是否对 JavaScript 中的执行上下文、词法环境、变量环境,变量对象、活动对象、作用域链、闭包、提升、this,执行栈等概念有很多困惑,或者了解其中的一些但总觉得一知半解?那是因为脱离了环境,零散的概念本身就晦涩难懂,一位不知名的前端攻城狮说过,理解零散概念的最好方式就是想办法建立知识体系,把它们全部串联在一起,而上述的概念都是围绕下面这个问题,
一段 JavaScript 代码到底是如何执行的 ? 🤔
var name = 'Monch Lee~'
var sayName = function () {
console.log(name)
}
function sayName() {
console.log(name)
var name = 'Pony Ma~'
}
我们知道 JavaScript 代码需要在某种环境中托管和运行,大多数情况下,这个 “环境” 可能是浏览器,以浏览器为例,
在《浏览器原理:渲染流程》中我们介绍过,JavaScript 代码最终会由 引擎(JavaScript Engine) 解释并执行,问题变成了,
引擎是如何执行 JavaScript 代码的? 需要先编译吗?
编译(Compile)
了解过其他编译语言(如 C、C++、Golang、Pascal、汇编等)编译原理的同学应该知道,
一段代码在执行前通常要经历三个步骤:词法分析、语法分析和代码生成,

- 词法分析:将字代码分解为词法单元(Token),生成词法单元流数组
- 语法分析:将词法单元流数组转换为 抽象语法树(Abstract Syntax Tree,AST)
- 代码生成:将 AST 转换为可执行代码
AST 转换的工具有很多(如 esprima、traceur、acorn、shift),你可以点击这个 esprima 的例子直观的感受下。
JavaScript 也是一门编译语言,任何 JavaScript 代码在执行前都要进行编译,

那么编译阶段 JavaScript 引擎具体会做哪些事情呢?
执行上下文(Execution Context)
首先,为了编译和执行 JavaScript 代码,引擎会创建一个特殊的运行环境,这个环境就是执行上下文。
简单来说,执行上下文就是一段代码(包括函数)执行所需的所有信息。
从类型上,执行上下文可以分为,
- 全局执行上下文(GEC):引擎编译和执行全局代码时创建,在浏览器中,全局执行上下文的 VO 就是 window 对象
- 函数执行上下文(FEC):每一个函数调用都会创建函数执行上下文,调用结束后从执行栈弹出并销毁
- eval的执行上下文:不推荐也不常见的一种,但 eval 函数调用时引擎会创建 eval 的执行上下文
一段代码执行到底需要哪些信息呢,换言之,引擎创建的执行上下文到底包含了哪些东西呢? 🤔
我们知道 ECMAScript 标准几乎每年都在更新,一些术语经历了比较多的版本和社区的演绎,而且比较遗憾的是,网上的文章对这些术语的定义往往都比较混乱,这里我觉得有必要从标准的角度帮你重新梳理一遍,
首先,在 ES3 中,执行上下文包含三个部分,

- Variable Object:变量对象,存储执行上下文中定义的所有变量和函数
- Scope Chain:作用域链,用于代码执行阶段的标识符查找
- This Value:this 值
你可能还听说过活动对象(Activation Object),它是针对函数而言,在函数执行上下文中,活动对象就是变量对象,只不过相较于全局执行上下文中的变量对象,它还额外拥有一个 arguments 属性,
function hello(name) {
console.log(arguments)
}
hello('Monch Lee~') // Arguments ['Monch Lee~', callee: ƒ, Symbol(Symbol.iterator): ƒ]
arguments 是一个对应于传递给函数的参数的类数组对象,它是除了箭头函数外,所有函数中都可用的一个局部变量。
在 ES5 中,上面一些术语发生了变化,执行上下文改为了由下面三部分组成,

- Lexical Environment:词法环境,用于获取变量和函数
- Variable Environment:变量环境,存储声明的变量和函数
- This Value:this 值
在较新的 ES2018 中,this 值被归入了词法环境,同时增加了一些新的内容,

- Lexical Environment:词法环境
- Variable Environment:变量环境
- Code Evaluation State:用于恢复代码执行位置
- Function:执行的任务是函数时使用,表示正在被执行的函数
- ScriptOrModule:执行的任务是脚本或者模块时使用,表示正在被执行的代码
- Realm:使用的基础库和内置对象实例
- Generator:生成器上下文特有的这个属性,表示当前生成器
尽管经历了多个版本的迭代,执行上下文的一些核心东西还是不变的,这里我们需要重点关注是:词法环境和变量环境。
变量环境(Variable Environment)
在编译阶段,引擎首先会确定变量环境,这个阶段会创建变量对象(Variable Object)用来存储所有声明的变量和函数,
提升(Hosting)就是发生在这一阶段。
提升(Hosting)
var 声明的变量或函数会被提升到当前作用域顶端(作用域我们在后面词法环境中会详细介绍),并且函数提升优先于变量,
console.log(a) // ƒ a() {}
var a = 2
function a() {}
由于存在提升,var 的声明和赋值其实是两个阶段,可以把上面的代码看成,
function a() {}
var a
console.log(a)
a = 2
此外,var 还会穿透 for、if 等语句,你可能发现在只有 var,没有 let 的旧 JavaScript 时代,我们经常通过创建一个函数,并立即执行,来构造一个新的域,从而控制 var 的范围,但由于语法规定 function 关键字开头的是函数声明,我们一般会加上括号让函数变成一个函数表达式,也就是我们常说的立即函数表达式(IIFE),
(function() {
var a;
//code
})();
最常见的做法是直接加括号让函数变成一个表达式,但如果上一行代码不写分号,括号会被解释为上一行代码最末的函数调用,所以你可能发现一些推荐不加分号的代码风格规范,会要求在括号前面加上分号,
;(function() {
var a;
//code
}())
我们也可以用 void 关键字,语义上 void 运算表示忽略后面表达式的值,变成 undefined,我们确实不关心 IIFE 的返回值,
void function() {
var a;
//code
}();
变量对象创建完成后,引擎会创建作用域链(Scope Chain),它是我们接下来要介绍的词法环境的一部分。
词法环境(Lexical Environment)
词法环境被用于 JavaScript 引擎获取变量或者 this 值,
在编译和执行 JavaScript 代码前,引擎会收集并维护由所有声明的标识符(变量)组成的一系列查询,并实施一套非常严格的规则,确定当前执行的代码对这些标识符的访问权限,这个过程会生成作用域链(Scope Chain),确定 this 指向,
作用域(Scope)和作用域链(Scope Chain)可以看成一个东西,什么是作用域呢?
作用域(Scope)
前面我们介绍到,编译器在语法分析阶段后会将 AST 转换为可执行代码,然后引擎会执行代码,我们以一个简单的代码片段为例,
var a = 2
这里看似一段简单的变量赋值操作实际上会经历两个阶段:编译时处理和运行时处理。
首先为了确定在何处以及如何查找变量(标识符),引擎会创建当前代码的作用域(Scope),
你可以简单地把作用域理解为一个集合,它存储了我们所有声明的标识符,并提供了一系列查询来访问这些标识符。
-
编译时处理:编译器在遇到上述代码片段后,会在当前作用域中声明一个变量
a
,如果当前作用域已经存在同名的变量a
,则会忽略该声明;真实的场景中,执行上下文可能有多个且可以嵌套,所以这里最终会创建多个嵌套的作用域,也就是我们常说的作用域链(Scope Chain)。 -
运行时处理:代码执行阶段稍有不同,引擎在遇到变量
a
时,会先在当前作用域中查找变量a
,如果找到就会使用这个变量,否则引擎会继续沿着作用域链查找,查找的过程遵循 “属性遮蔽”(Property Shadowing) 原则,也就是查找到的最近的属性会被优先应用;作用域查找会一直向上,直到找到变量或抵达全局作用域后停止。
根据查找的类型,作用域查找又可以分为 LHS 查询和 RHS 查询,分别代表赋值操作的左侧和右侧。
LHS & RHS
这里的赋值操作并不简单意味着 "=" 操作符,JavaScript 中存在譬如隐式赋值等多种赋值操作,以下面的代码片段为例,
function foo(a) {
var b = a
return a + b
}
var c = foo( 2 )
这里函数的调用 foo()
是一次 RHS 查询,因为引擎需要去查找 foo
到底是什么,我们可以把 RHS 查询理解为 retrieve his source value(取到它的源值),所以上述代码会执行 4 次 RHS 查询,
- foo(2...,foo 函数调用,对 foo 的查询
- = a,a 赋值给 b 时,对变量 a 的查询
- a ...,对 + 运算左侧变量 a 的查询
- ... b,对 + 运算右侧变量 b 的查询
LHS 查询的目的是为了对变量进行赋值,需要注意隐式变量分配也是一次 LHS 查询,上述代码会执行 3 次 LHS 查询,
- c = ...,对变量 c 的赋值查询
- a = 2,隐式变量分配
- b = ...,对变量 b 的赋值查询
这里理解 LHS 查询和 RHS 查询其实是有必要的,因为它对应着 JavaScript 中两种常见的异常类型。
异常
对于未声明的变量,LHS 查询和 RHS 查询导致的行为是不一样的,具体来说,
RHS 查询在所有嵌套作用域(整个作用域链)中找不到变量时,引擎会抛出 ReferenceError 异常,
console.log(a) // Uncaught ReferenceError: a is not defined
LHS 查询如果找不到变量,在严格和非严格模式下会有不同表现,
- 非严格模式下,引擎会在全局作用域中创建一个同名变量
- 严格模式下,引擎会抛出 ReferenceError 异常
a = 2
console.log(a) // 2,a 未声明,对变量 a 的 LHS 查询,引擎会在全局作用域创建一个同名变量 a
"use strict"
a = 2 // Uncaught ReferenceError: a is not defined,严格模式下,禁止自动或隐式地创建全局变量,这时会抛出 ReferenceError 异常
你可能还遇到过 TypeError 异常,这是因为 RHS 查询找到了变量,但我们尝试对这个变量的值进行不合理的操作时导致的,
什么是不合理的操作呢?比如常见的有,
- 试图对一个非函数类型的值进行函数调用
- 引用 null 或 undefined 类型的值中的属性
var a = 2
a() // Uncaught TypeError: a is not a function
a.foo.bar // Uncaught TypeError: Cannot read properties of undefined (reading 'bar')
总的来说,ReferenceError 和作用域判别失败相关,而 TypeError 则代表作用域判别成功了,但是对结果的操作是不合理的。
了解了词法环境和变量环境后,接下来我们来看 JavaScript 中比较晦涩的一个概念: 闭包。
闭包(Closure)
闭包的翻译自英文单词 Closure,在计算机科学的不同领域它可能代表不同的东西,
- 编译原理中,它是处理语法产生式的一个步骤;
- 计算几何中,它表示包裹平面点集的凸多边形(翻译作凸包)
- 编程语言领域,它表示一种函数
闭包的概念最早可以追溯到 1964 年的《The Computer Journal》(牛津大学出版社出版的最古老的计算机科学研究期刊之一),
P. J. Landin 在《The mechanical evaluation of expressions》 一文中第一次提及了 closure 的概念,

在这个最古典的闭包定义中,closure 表示 “带有一系列信息的一个 λ 表达式”,我们知道对于函数式语言而言,λ 表达式其实就是函数,所以上述的定义可以简单理解为:一个绑定了执行环境的函数,具体来说,一个闭包包含了以下两个部分,
- 环境部分:环境和标识符列表
- 表达式部分
然而纵观 JavaScript 的所有标准,似乎都未提及闭包(Closure)这个术语,但我们却不难根据古典定义,结合前面介绍的词法环境,在 JavaScript 中找到对应的闭包组成部分,
- 环境部分:函数执行上下文中的词法环境,以及函数中用到的变量组成的标识符列表
- 表达式部分:函数体
所以,闭包在 JavaScript 就是函数,只不过这个函数携带了标识符列表和词法环境,
我希望通过上述的古典定义,帮你理清楚 “JavaScript 中闭包就是函数” 这个概念,它和普通的函数并没有本质的区别,
// 你完全可以把函数 foo 看成闭包,闭包没有什么特殊的魔法,就是一个携带环境的函数,普通函数也会携带环境
function foo() {}
只不过与普通函数相比,闭包可能会携带更多的环境信息,
function foo() {
var a = 2
return function bar() {
console.log(a)
}
}
var baz = foo()
baz() // 2,foo 函数从执行栈中弹出后,我们仍然可以访问其内部变量 a
比如上面的例子中,我们通过调用一个外部函数返回了一个内部函数,根据词法作用域的规则,内部函数可以访问包含它自身及外部函数的词法环境,所以即使我们的外部函数 foo 已经执行结束(从执行栈中弹出)了,但内部函数引用外部函数的变量 a 依然保存在内存中,导致这一内部函数 bar 携带了额外的环境信息(外部函数 foo 的词法环境)。
利用闭包携带的环境信息,我们可以搭配 IIFE 模拟实现块级作用域,你可能在一些早期的 JavaScript 库中见到过类似用法。
好了,让我们把视角拉回到词法环境,前面我们介绍到,引擎确定作用域链后,接着会进行 this 值的绑定。
this
this 是 JavaScript 中的一个关键字,它的使用方法类似于一个变量,全局执行上下文中,this 的值跟当前是否处于严格模式有关,
- 非严格模式下,this 的值为全局对象,对应到浏览器中就是 window
- 严格模式下,this 的值为 undefined
这没有什么魔法,真正让大多数人困惑的是 this 在函数执行上下文中的行为,所以我们接下来重点介绍函数中的 “this”。
在 JavaScript 标准中,为函数规定了用来保存定义时上下文的私有属性 [[Environment]],当一个函数执行时,会创建一条新的执行环境记录,记录的外层词法环境(outer lexical environment)会被设置成函数的 [[Environment]],这个动作就是我们说的 执行上下文切换。
我们知道 JavaScript 用一个栈(执行栈,Execution Stack)来管理执行上下文,这个栈中的每一项又包含一个链表,

当函数调用时,会入栈一个新的执行上下文,函数调用结束时,执行上下文被弹出。
JavaScript 标准定义了 [[thisMode]] 私有属性来处理函数执行上下文中的 this 值,[[thisMode]] 有三个取值,
- lexical:表示从上下文中寻找 this,这对应了箭头函数
- global:表示当 this 为 undefined 时,取全局对象,对应了普通函数
- strict:当严格模式时使用,this 严格按照调用时传入的值,可能为 null 或者 undefined
global 和 strict 呼应了我们前面介绍的全局执行上下文中的 this,这里比较难理解的是 lexical,如何从上下文中寻找 this ?
对于普通函数而言,this 值由函数的调用方式决定,具体来说是由 “调用它所使用的引用” 决定,
function showThis() {
console.log(this);
}
var o = {
showThis: showThis,
};
showThis(); // global
o.showThis(); // o
我们获取函数的表达式,它实际上返回的并非函数本身,而是一个 Reference 类型,Reference 由三部分组成,
- base,一个对象
- reference name,一个属性值
- strict mode flag
在函数调用,delete 等算术运算时,Reference 类型会被解引用,以获取真正的值(被引用的内容)来参与运算,上述例子中,Reference 类型中的对象 o 被当作 this 值,传入了执行函数时的上下文中,所以对于普通函数的 this,我们已经非常清晰了,
调用函数时使用的引用,决定了函数执行时刻的 this 值。
上面的方式被网上一些文章称为 “隐式绑定”,从运行时角度来看,this 跟面向对象毫无关联,它只与函数调用时使用的表达式相关,这里也有例外,对于箭头函数和通过 new 实例化一些场景来说,this 的指向有所不同。
我们先来说比较特殊的箭头函数(Arrow Function),
函数创建新的执行上下文中的词法环境记录时,会根据 [[thisMode]] 来标记新纪录的 [[ThisBindingStatus]] 私有属性,然后在代码执行遇到 this 时,会逐层检查当前词法环境记录中的 [[ThisBindingStatus]],这有点类似我们前面介绍的作用域查找,由于箭头函数在创建词法环境记录时并没有定义 this,所以导致了箭头函数无论嵌套多少层,其内部的 this 都指向了最外层普通函数的 this,
var o = {};
o.foo = function foo() {
console.log(this);
return () => {
console.log(this);
return () => console.log(this);
};
};
o.foo()()(); // o, o, o
箭头函数特殊的 this 绑定方式是优先于前面提到的引用绑定的,比如我们把前面的例子改为箭头函数,
const showThis = () => {
console.log(this);
};
var o = {
showThis: showThis,
};
showThis(); // global
o.showThis(); // global
可以发现,不论用什么引用来调用它,都不影响它的 this 值了。
接着我们看绑定到 “类” 的 this,
class C {
a = 1
showThis() {
console.log(this)
}
}
var o = new C()
var showThis = o.showThis
showThis() // undefined
o.showThis() // o
o.a // 1
我们创建了一个类 C,并实例化出对象 o,再把 o 的方法赋值给了变量 showThis,当我们用 showThis 这个引用去调用方法时,得到了 undefined,导致这一行为的原因是 class 被设计成了默认按 strict 模式执行,所以 this 严格按照调用时传入的值进行绑定,这也解释了为什么我们下一行通过 o 去调用时 this 被绑定到了 o 这个引用上。
当然,new 实例化的方式本身也会绑定 this,而且这种优先级是最高的,这主要与 new 关键字的实现原理有关,
我们简单回顾下 new 的实现原理,大致可以分为以下几个步骤,
- 获得构造函数
- 链接到原型
- 绑定 this,执行构造函数
- 返回 new 出来的对象
// 模拟 new 实现
function _new() {
var constructor = [].shift.call(arguments)
var obj = Object.create(constructor.prototype)
var result = constructor.apply(obj, arguments)
return typeof result === 'object' ? result : obj
}
// 调用
function foo() {
this.bar = 'bar'
}
var obj = _new(foo)
console.log(obj) // foo {bar: 'bar'}
console.log(obj.bar) // 'bar'
new 关键字的实现机制里,会将我们传入的构造函数的原型作为 this 绑定到 new 出来的目标对象上。
我们在上述模拟实现 new 的方法里用到了 apply,实际上,JavaScript 中提供了一系列函数的内置方法(call, apply, bind)来操纵 this 值,这种直接绑定 this 值的方式也被称为 “显式绑定”,当然,如果你只是想确认 this 的指向而不关注其背后的机制,可以参考我之前的文章 可能是最好的 this 解析了... 里提供的一个框架思路,
根据绑定规则和优先级,我们可以总结出 this 判断的通用模式,
- 函数是否通过 new 调用?
- 是否通过 call,apply,bind 调用?
- 函数的调用位置是否在某个上下文对象中?
- 是否是箭头函数?
- 函数调用是在严格模式还是非严格模式下?
至此,我们已经介绍完执行上下文中最重要的变量环境和词法环境,包括比较晦涩的 This value,闭包等概念,相信你已经对这个 JavaScript 代码执行的基础设施:执行上下文 有了一定的了解,接下来的文章我们会基于执行上下文,执行栈等概念,了解代码执行阶段另一个重要的机制:事件循环(Event Loop)。
参考链接
- ECMAScript® 2015 Language Specification
- ECMAScript® 2023 Language Specification
- JavaScript Execution Context – How JS Works Behind The Scenes
- You Don't Know JS Yet
- 重学前端——Winter
写在最后
本文首发于我的 博客,才疏学浅,难免有错误,文章有误之处还望不吝指正!
如果有疑问或者发现错误,可以在评论区进行提问和勘误,
如果喜欢或者有所启发,欢迎 star,对作者也是一种鼓励。
转载自:https://juejin.cn/post/7106025570831433741