写点什么

JS 词法环境和执行上下文

作者:hellocoder2029
  • 2023-02-24
    浙江
  • 本文字数:5154 字

    阅读完需:约 17 分钟

前言

JavaScript 是一门解释性动态语言,但同时它也是一门充满神秘感的语言。如果要成为一名优秀的 JS 开发者,那么对 JavaScript 程序的内部执行原理要有所了解。


本文以最新的 ECMA 规范中的第八章节为基础,理清 JavaScript 的词法环境和执行上下文的相关内容。这是理解 JavaScript 其他概念(let/const 暂时性死区、变量提升、闭包等)的基础。


本文参考的是最新发布的第十代 ECMA-262 标准,即 ES2019ES2019 与 ES6 在词法环境和执行上下文的内容上是近似的,ES2019 在细节上做了部分补充,因此本文直接采用 ES2019 的标准。你也可以对比两个版本的标准的差异。

执行上下文(Execution Context)

执行上下文是用来跟踪记录代码运行时环境的抽象概念。每一次代码运行都至少会生成一个执行上下文。代码都是在执行上下文中运行的。


你可以将代码运行与执行上下文的关系类比为进程与内存的关系,在代码运行过程中的变量环境信息都放在执行上下文中,当代码运行结束,执行上下文也会销毁。


在执行上下文中记录了代码执行过程中的状态信息,根据不同运行场景,执行上下文会细分为如下几种类型:


  • 全局执行上下文:当运行代码是处于全局作用域内,则会生成全局执行上下文,这也是程序最基础的执行上下文。

  • 函数执行上下文:当调用函数时,都会为函数调用创建一个新的执行上下文。

  • eval 执行上下文:eval 函数执行时,会生成专属它的上下文,因 eval 很少使用,故不作讨论。

执行栈

有了执行上下文,就要有合理管理它的工具。而执行栈(Execution Context Stack)是用来管理执行期间创建的所有执行上下文的数据结构,它是一个 LIFO(后进先出)的栈,它也是我们熟知的 JS 程序运行过程中的调用栈。程序开始运行时,会先创建一个全局执行上下文并压入到执行栈中,之后每当有函数被调用,都会创建一个新的函数执行上下文并压入栈内。


我们从一小段代码来看下执行栈的工作过程:


<script>    console.log('script')    function foo(){        function bar(){            console.log('bar', isNaN(undefined))        }        bar()        console.log('foo')    }    foo()</script>
复制代码


当这段 JS 程序开始运行时,它会创建一个全局执行上下文GlobalContext,其中会初始化一些全局对象或全局函数,如代码中的console,undefined,isNaN。将全局执行上下文压入执行栈,通常 JS 引擎都有一个指针running指向栈顶元素:



JS 引擎会将全局范围内声明的函数(foo)初始化在全局上下文中,之后开始一行行的执行代码,运行到console就在running指向的上下文中的词法环境中找到全局对象console并调用log函数。


PS:当然,当调用log函数时,也是要新建函数上下文并压栈到调用栈中的。这里为了简单流程,忽略了log上下文的创建过程。


运行到foo()时,识别为函数调用,此时创建一个新的执行上下文FooContext并入栈,将FooContext内词法环境的 outer 引用指向全局执行上下文的词法环境,移动running指针指向这个新的上下文:



在完成FooContext创建后,进入到FooContext中继续执行代码,运行到bar()时,同理仍需要新建一个执行上下文BarContext,此时BarContext内词法环境的 outer 引用会指向FooContext的词法环境:



继续运行bar函数,由于函数上下文内有outer引用实现层层递进引用,因此在bar函数内仍可以获取到console对象并调用log


之后,完成barfoo函数调用,会依次将上下文出栈,直至全局上下文出栈,程序结束运行。


执行上下文的创建

执行上下文创建会做两件事情:


  1. 创建词法环境LexicalEnvironment

  2. 创建变量环境VariableEnvironment


因此一个执行上下文在概念上应该是这样子的:


ExecutionContext = {  LexicalEnvironment = <ref. to LexicalEnvironment in memory>,  VariableEnvironment = <ref. to VariableEnvironment in  memory>,}
复制代码


在全局执行上下文中,this 指向全局对象,window in browser / global in nodejs


参考 前端进阶面试题详细解答

词法环境(LexicalEnvironment)

词法环境是 ECMA 中的一个规范类型 —— 基于代码词法嵌套结构用来记录标识符和具体变量或函数的关联。简单来说,词法环境就是建立了标识符——变量的映射表。这里的标识符指的是变量名称或函数名,而变量则是实际变量原始值或者对象/函数的引用地址。


LexicalEnvironment中由两个部分构成:


  • 环境记录EnvironmentRecord:存放变量和函数声明的地方;

  • 外层引用outer:提供了访问父词法环境的引用,可能为 null;


this 绑定ThisBinding:确定当前环境中 this 的指向,this binding 存储在 EnvironmentRecord 中;


词法环境的类型


  • 全局环境(GlobalEnvironment):在 JavaScript 代码运行伊始,宿主(浏览器、NodeJs 等)会事先初始化全局环境,在全局环境的EnvironmentRecord中会绑定内置的全局对象(Infinity等)或全局函数(evalparseInt等),其他声明的全局变量或函数也会存储在全局词法环境中。全局环境的outer引用为null


这里提及的全局对象就有我们熟悉的所有内置对象,如 Math、Object、Array 等构造函数,以及 Infinity 等全局变量。全局函数则包含了 eval、parseInt 等函数。


  • 模块环境(ModuleEnvironment):你若写过 NodeJs 程序就会很熟悉这个环境,在模块环境中你可以读取到exportmodule等变量,这些变量都是记录在模块环境的 ER 中。模块环境的outer引用指向全局环境。

  • 函数环境(FunctionEnvironment):每一次调用函数时都会产生函数环境,在函数环境中会涉及this的绑定或super的调用。在 ER 中也会记录该函数的lengtharguments属性。函数环境的outer引用指向调起该函数的父环境。在函数体内声明的变量或函数则记录在函数环境中。


环境记录 ER


代码中声明的变量和函数都会存放在EnvironmentRecord中等待执行时访问。环境记录EnvironmentRecord也有两个不同类型,分别为declarativeobjectdeclarative是较为常见的类型,通常函数声明、变量声明都会生成这种类型的 ER。object类型可以由with语句触发的,而with使用场景很少,一般开发者很少用到。


如果你在函数体中遇到诸如var const let class module import 函数声明,那么环境记录就是declarative类型的。


值得一提的是全局上下文的ER有一点特殊,因为它是object ERdeclarative ER的混合体。在object ER中存放的是全局对象函数、function 函数声明、asyncgeneratorvar关键词变量。在declarative ER则存放其他方式声明的变量,如let const class等。由于标准中将object类型的 ER 视作基准 ER,因此这里我们仍将全局 ER 的类型视作object


GlobalExecutionContext = {    LexicalEnvironment: {        EnvironmentRecord: {            type: 'object',  // 混合 object + declarative            this: <globalObject>,            NaN,            parseInt,            Object,            myFunc,            a,            b,            ...        },        outer: null,    }}
复制代码


LexicalEnvironment只存储函数声明和let/const声明的变量,与下文的VariableEnvironment有所区别。


比如,我们有如下代码:


let a = 10;function foo(){    let b = 20    console.log(a, b)}foo()
// 它们的词法环境伪码如下:GlobalEnvironment: { EnvironmentRecord: { type: 'object', this: <globalObject>, a: <uninitialized>, foo: <func> }, outer: <null>}
FunctionEnvironment: { EnvironmentRecord: { type: 'declarative', this: <globalObject>, // 严格模式下为undefined arguments: {length: 0}, b: <uninitialized> }, outer: <GlobalEnvironment>}
复制代码


函数环境记录


由于函数环境是我们日常开发过程最常见的词法环境,因此需要更加深入的研究一下函数环境的运行机制,帮助我们更好理解一些语言特性。


当我们调用一个函数时,会生成函数执行上下文,这个函数执行上下文的词法环境的环境记录就是函数类型的,有点拗口,用树形图代表一下:


FunctionContext    |LexicalEnvironment        |EnvironmentRecord  //--> 函数类型
复制代码


为什么要强调这个类型呢?因为 ECMA 针对函数式环境记录会额外增加一些内部属性:



此外,函数环境记录中还存有一个 arguments 对象,记录了函数的入参信息。


ThisBinding


this 绑定是一个老生常谈的问题,由于存在多种分析场景,这里不便展开,this 绑定的目的是在执行上下文创建之时就明确 this 的指向,在函数执行过程中读取到正确的 this 引用的对象。


小结


概念类型太多,有一些凌乱了。简单速记一下:


词法环境分类 = 全局 / 函数 / 模块词法环境 = ER + outer + thisER分类 = declarative(DER) + object(OER)全局ER = DER + OER
复制代码

VariableEnvironment 变量环境

在 ES6 前,声明变量都是通过var关键词声明的,在 ES6 中则提倡使用letconst来声明变量,为了兼容var的写法,于是使用变量环境来存储var声明的变量。


var关键词有个特性,会让变量提升,而通过let/const声明的变量则不会提升。为了区分这两种情况,就用不同的词法环境去区分。


变量环境本质上仍是词法环境,但它只存储var声明的变量,这样在初始化变量时可以赋值为undefined


有了这些概念,一个完整的执行上下文应该是什么样子的呢?来点例子🌰:


let a = 10;const b = 20;var sum;
function add(e, f){ var d = 40; return d + e + f }
let utils = { add}
sum = utils.add(a, b)
复制代码


完整的执行上下文如下所示:


GlobalExecutionContext = {    LexicalEnvironment: {        EnvironmentRecord: {            type: 'object',            this: <globalObject>,            add: <function>,            a: <uninitialized>,            b: <uninitialized>,            utils: <uninitialized>        },        outer: null    },    VariableEnvironment: {        EnvironmentRecord: {            type: 'object',            this: <globalObject>            sum: undefined        },        outer: null    },}
// 当运行到函数add时才会创建函数执行上下文FunctionExecutionContext = { LexicalEnvironment: { EnvironmentRecord: { type: 'declarative', this: <utils>, arguments: {0: 10, 1: 20, length: 2}, [[NewTarget]]: undefined, e: 10, f: 20, ... }, outer: <GlobalLexicalEnvironment> }, VariableEnvironment: { EnvironmentRecord: { type: 'declarative', this: <utils> d: undefined, }, outer: <GlobalLexicalEnvironment> },}
复制代码


执行上下文创建后,进入到执行环节,变量在执行过程中赋值、读取、再赋值等。直至程序运行结束。我们注意到,在执行上下文创建时,变量a``b都是<uninitialized>的,而sum则被初始化为undefined。这就是为什么你可以在声明之前访问var定义的变量(变量提升),而访问let/const定义的变量就会报引用错误的原因。

let/const 与 var

简单聊聊同是变量声明,两者有何区别?


let 与 const 的区别这里不再赘述


存放位置


从上一结中,我们知道了let/const声明的变量是归属于LexicalEnvironment,而var声明的变量归属于VariableEnvironment


初始化(词法阶段)


let/const在初始化时会被置为<uninitialized>标志位,在没有执行到let xxxlet xxx = ???(赋值行)的具体行时,提前读取变量会报ReferenceError的错误。(这个特性又叫暂时性死区var在初始化时先被赋值为undefined,即使没有执行到赋值行,仍可以读取var变量(undefined)。


块环境记录(块作用域)


在 ECMA 标准中提到,当遇到BlockCaseBlock时,将会新建一个环境记录,在块中声明的let/const变量、函数、类都存放这个新的环境记录中,这些变量与块强绑定,在块外界则无法读取这些声明的变量。这个特性就是我们熟悉的块作用域。


什么是 Block?被花括号({})括起来的就是块。


Block中的let/const变量仅在块中有效,块外界无法读取到块内变量。var变量不受此限制。


var不管在哪,都会变量提升~

与 ES3 的区别

如果你了解 ES5 版本的有关执行上下文的内容,会感到奇怪为啥有关VOAO、作用域、作用域链等内容没有在本文中提及。其实两者概念并不冲突,一个是 ES3 规范中的定义,而词法环境则是 ES6 规范的定义。不同时期,不同称呼。


ES3 --> ES6 作用域 --> 词法环境作用域链 --> outer 引用 VO|AO --> 环境记录


你问我该学哪个?立足现在,铭记历史,拥抱未来。

总结

本文关于执行上下文的理论知识比较多,不容易马上吸收理解,建议你逐渐消化、反复阅读理解。当你熟悉了执行上下文和词法环境,相信去理解认识更多 JS 特性和概念时,会更加轻松容易。


用户头像

还未添加个人签名 2022-09-08 加入

还未添加个人简介

评论

发布
暂无评论
JS词法环境和执行上下文_JavaScript_hellocoder2029_InfoQ写作社区