js编译、执行上下文、作用域链

参考资料

极客时间课程《浏览器工作原理与实践》 – 李兵

《你不知道的JavaScript》-- Kyle Simpson

ES5.1规范:https://262.ecma-international.org/5.1/#sec-10.3

ES6规范:https://262.ecma-international.org/6.0/#sec-executable-code-and-execution-contexts

ES11规范:https://262.ecma-international.org/11.0/#sec-execution-contexts

掘金关于js执行上下文系列:https://juejin.cn/post/6844903741607395336#heading-0

掘金ES6规范下创建执行上下文和闭包的文章:https://juejin.cn/post/7024756814885421087/

在线工具:

查看js运行过程中的执行上下文:https://ui.dev/javascript-visualizer/

一、简述js编译

(一)常规编译流程

常规的编译流程主要有以下三个步骤:

  1. 分词/词法分析

    • 主要完成操作:将由字符组成的字符串分解成有意义的代码块,这些代码块被称为词法单位。

    • 操作结果:生成词法单元流(数组)

//示例代码
var a = 2;

//分词结果:var	a	=	2	;	空格(是否分词出空格取决于在当前语言中,空格是否具有意义)
  1. 解析/语法分析

    • 主要完成操作:将词法单元流转换成一棵树,该树由元素逐级嵌套所组成,代表了程序语法结构,被称为“抽象语法树”。
    • 操作结果:生成抽象语法树(AST)
    • 代码示例如下图所示。推荐一个查看AST结构的网站:https://astexplorer.net/
      AST树结构示例
  2. 代码生成

    • 主要完成操作:将AST转换为可执行代码。略去其中的细节操作,可以简单理解为将AST转换为一组机器指令,用来分配内存、创建变量、存储值等。

    • 操作结果:生成可执行代码

(二)js编译

js的编译流程与常规编译流程一样也需要经过上述三个步骤,接下来我们对js在这三个步骤中所做的工作做大致了解。首先介绍一下js编译阶段出现的几个角色以及该角色的作用:

  • js引擎:从头到尾负责整个js代码的编译以及执行过程

  • 编译器:负责语法分析以及代码生成

  • 作用域:用于确定当前执行代码对标识符(变量)的访问权限,管理js引擎如何在当前作用域一级嵌套的子作用域中根据标识符名称进行变量查找。

列举示例来看上述三种角色在编译过程中如何合作:

//示例:
var a = 2;

//变量提升
var a = undefined;

//可执行代码
a = 2;
  1. 编译器进行词法分析、语法分析以及生成可执行代码。

    遇到变量声明时,首先询问作用域,当前作用域的集合中是否存在相同名称的变量?存在则忽略该变量声明,不存在则要求在当前作用域的集合中声明一个新的变量,并命名为a。

  2. js引擎执行可执行代码。

    询问作用域,当前作用域的集合中是否存在一个叫做a的变量,存在则直接对a进行赋值,不存在则进入当前作用域外层嵌套的作用域继续查找,直到找到该变量,或者抵达最外层作用域。

(三)从编译过程看作用域

在之前简单了解过在js中作用域分为:全局作用域、函数作用域、块级作用域(ES6定义),并没有深入了解作用域的定义。实际上作用域是一套根据名称查找变量的规则,用于帮助js引擎在编译、执行过程查找目标变量。这套规则的工作模型有两种:一种是词法作用域,一种是动态作用域。js采用的工作模型是词法作用域,动态作用域在《你不知道的JavaScript》-- Kyle Simpson的第一章中有介绍。

js采用的工作模型是词法作用域,它将根据代码的书写位置来确定作用域。在编译阶段的第一个步骤就是进行分词,在这个过程中,代码字符串被词法化(单词化)并分解。在词法化过程中可以根据代码的位置形成一个个词法作用域,这些作用域可能是全局的,也可能是属于某个函数的,也可能是属于某个代码块的,分别对应全局作用域、函数作用域、块级作用域。因此,词法作用域是定义在词法阶段的作用域,它由你在写代码时将变量、函数、块作用域写在哪里来决定的。特别地,函数的词法作用域与函数在何处被调用、如何被调用无关,只由函数被声明时所在的位置决定。下面列举代码分析词法作用域:

词法作用域

根据作用域之间的相互嵌套,可以分析出词法作用域链:bar函数作用域 --> foo函数作用域 --> 全局作用域。

作用域的作用范围在代码被书写的时候就已经确定了,是静态的;它区别于运行时的调用栈,调用栈是动态的,根据函数何时调用决定该函数上下文何时入栈。在ES5、ES6之后,作用域这一概念逐渐变为Lexical Environments(词法环境),与以下四个类型的代码结构相对应:

  • 上述全局作用域:对应一个词法环境

  • 上述函数作用域:对应一个词法环境

  • eval:进去eval调用的代码有时会创建一个新的词法作用域

  • with:一个with结构块内是一个词法环境

  • catch结构:一个catch结构块内也是一个词法环境

二、执行上下文的创建

在了解作用域链的形成之前,我们需要先了解执行上下文的创建。

(一)执行上下文

在深入研究执行上下文的具体结构时,需要注意好自己研究的是哪个版本的,在ES3中,执行上下文包含:scope(作用域)、Variable Object(变量对象)、ThisBinding;在ES5中,执行上下文中包含:变量环境(VariableEnvironment)、词法环境(LexicalEnvironment)、ThisBinding;在ES6以及ES6之后,执行上下文包含:变量环境(VariableEnvironment)与词法环境(LexicalEnvironment)。

在ES5,执行上下文有三个组成部分:

  • LexicalEnvironment:值是一个词法环境(Lexical Environment),基于代码词法嵌套结构来记录变量名/函数名与具体变量/函数的引用地址的关联,用来解析引用。
  • VariableEnvironment:变量环境,也是一个词法环境(Lexical Environment),用来登记var和function声明,ES6之后专门用于存储var声明的变量。一般,它在初始化的时候与LexicalEnvironment指向同一个词法环境。
  • ThisBinding:this值

注意:LexicalEnvironment只是名称,它的值是一个Lexical Environment(词法环境),为方便,之后使用LexicalEnvironment表示执行上下文中的LexicalEnvironment,使用词法环境表示Lexical Environment。在初始化执行上下文时,变量环境和LexicalEnvironment指向同一个词法环境,但在之后的运行过程中,LexicalEnvironment的值可能会被替换,VariableEnvironment的值则一直不变。

LexicalEnvironment和VariableEnvironment初始化时指向同一个词法环境,后来运行时什么情况下两者会指向不同的地址,以及出现块级作用域的情况下如何进行声明记录,这些不做深入探讨。以下只探讨ES5中,var、function声明如何被词法环境记录。

总结:ES5时,执行上下文 = 词法环境+ThisBinding,ES6及之后,执行上下文 = 词法环境。

(二)扩展了解—词法环境结构

在ES5中词法环境(Lexical Environments)的结构与ES6差不多,ES6之后的不同版本中,词法环境都有一定的差异,具体可翻阅规范文档进行深入了解,这里只对词法环境结构进行了解:

词法环境(Lexical Environments)由环境记录(Environment Record)和一个指向外部词法环境的引用(outer Lexical Environment)构成。其中环境记录记录所有在当前词法作用域内声明的变量、函数;outer则是作用域链能够连接起来的关键。环境记录可以认为是一个抽象类,它有三个具体的子类:声明环境记录、对象环境记录和全局环境记录,抽象类(指环境记录)定义了一些抽象规范方法,例如:判断是否有绑定、删除绑定等等规范方法,这些抽象方法在具体的子类中都有不同的具体算法实现。其中声明环境记录和对象环境记录是最常用的两种环境记录。
执行上下文
总结:词法环境主要由环境记录与外部词法环境的引用(outer)组成。

(三)分析执行上下文创建过程

在ES5.1规范中,通过以下代码示例分析执行上下文:

function foo() {
    var myName = "foo";
}
var myName = "window";
foo()
  1. 创建空的全局执行上下文并初始化,然后压入调用栈。全局执行上下文中outer的指向为null。
    执行上下文示例-初始化全局执行上下文
  2. 变量提升,在此过程对变量、函数声明进行注册与绑定。函数定义时创建函数对象,将外部词法环境存入内置属性[[Scope]],并在环境记录中将函数对象地址与函数名进行绑定。注意:函数声明定义的函数是在定义时创建函数对象,不同方式声明函数,函数被创建的时机、创建方式也有所不同,此处只以函数声明方式为例。

执行上下文示例-变量提升

  1. 为myName赋值,调用foo函数,编译函数体,创建新的词法环境。

  2. 将foo函数内置属性[[Scope]]的值赋值给词法环境的outer,这一步完成了将词法环境的outer指向外部词法环境。

  3. 为foo函数创建新的执行上下文,并进行变量提升。

执行上下文示例-创建函数执行上下文

  1. 函数执行完毕,foo函数执行上下文出栈
    执行上下文示例-函数上下文出栈

  2. 程序中不存在对内存中foo词法环境、map()的引用,它们将被回收。
    执行上下文示例-垃圾回收

  3. 代码执行完毕,全局执行上下文出栈,不存在对全局代码的词法环境、全局对象、函数对象的引用,它们将被回收。

总结:创建函数对象时将当前所在词法环境记录到[[Scope]],用来帮助设置函数执行上下文的词法环境的outer值。

三、作用域链

js执行代码过程中,查找变量时,会先从当前上下文中查找,如果没有找到,就会从外部作用域的执行上下文查找,一直找到全局上下文。这样由多个执行上下文构成的链表叫做作用域链。

(一) 作用域链的形成

根据执行上下文的创建,函数执行上下文的词法环境中,outer记录了外部词法环境的引用,而外部词法环境中的outer又记录了对其自身的外部词法环境的引用,由此形成了一个链式结构,这就是ES5.1规范中的作用域链。

而在ES6之后,作用域概念变为词法环境概念,函数对象的内置属性[[Scope]]变为[[Environment]],[[Environment]]记录了函数的外部词法环境。在函数对象被创建时,将所在的词法环境记录到[[Environment]],借此在函数被调用进而创建词法环境时,将[[Environment]]的值赋给outer,使得词法环境的outer指向外部词法环境,由此形成链式结构,这就是ES6之后的作用域链。

(二)示例代码分析作用域链查找

以ES5.1规范分析示例代码一:

function bar() {
    console.log(myName)
}
function foo() {
    var myName = "foo";
    bar()
}
var myName = "window"
foo()

作用域链-示例代码1

如上图,绿色箭头线条分别是bar和foo的作用域链。执行bar函数,打印myName值时,先在自身词法作用域查找变量,找不到时顺着作用域链查找。最终在全局执行上下文的词法环境中找到变量myName。输出结果为:window。

四、闭包

在javascript中,根据词法作用域的规则,内部函数总是可以访问其外部函数中声明的变量,当通过调用一个外部函数返回一个内部函数后,即使该外部函数已经执行结束,但是内部函数引用外部函数的变量依然保存在内存中,我们就把这些变量的集合称为闭包。

分析示例代码1,可用工具网站查看执行上下文

function foo() {
  var myName = "foo"
  function inner() {
    console.log(myName);
  }
  return inner;
}
var inner = foo()
inner()
  1. 创建全局词法环境,创建全局执行上下文,初始化上下文
    闭包示例-全局执行上下文

  2. 进行变量提升,将inner、foo函数记录到词法环境的环境记录中。为foo创建函数对象
    闭包示例1-全局代码变量提升

  3. 调用foo,开始编译foo函数体

    • 创建一个新的执行上下文,并使其成为运行执行上下文
    • 根据foo函数对象创建foo词法环境,将foo的[[Scope]]赋值给词法环境的outer
    • 将执行上下文的LexicalEnvironment和VariableEnvironment都指向foo词法环境
    • 将执行上下文压入调用栈
    • 变量提升时,创建环境记录,将词法环境中的Environment Record指向环境记录对象,将函数体内的声明的标识符记录在环境记录对象中。

闭包示例1-foo执行上下文

  1. 进行变量提升,对myName和inner函数提升:

    • 提升inner函数时,快速扫描inner函数体,以确定inner函数中是否使用了自由变量(可以简单理解为外部变量)

      • 存在自由变量则创建闭包

        • 将自由变量与foo词法环境的环境记录进行匹配,将匹配到的变量放入闭包中,同时将匹配到的变量在环境记录中清除
        • 将闭包对象的内存地址推入环境记录
      • 不存在自由变量则不创建闭包

    • 为inner创建函数对象

闭包示例1-创建闭包

  1. 执行foo函数可执行代码,将inner返回给全局变量inner。foo函数执行上下文出栈并被销毁。

闭包示例1-foo出栈
6. 如上图所示,foo执行上下文出栈并被销毁,foo的词法环境本应被销毁。根据图中1、2所示,由于全局词法环境中存在着对foo词法环境的间接引用,使得foo的词法环境没有被销毁。

  1. 调用inner函数,开始编译inner函数体

  2. 执行inner函数体,查找变量myName:

    • 先在inner函数的词法环境中查找myName,找不到,根据outer进入外部词法环境
    • 进入foo词法环境中查找,在闭包中找到myName,查找结束
  3. 最终输出结果:foo

从词法环境角度来说,闭包是函数和声明该函数的词法环境的组合。有说法,在js中,每当创建一个函数,闭包就会在函数创建的同时被创建出来。关于是否所有js函数都是闭包的讨论,可以参考:https://segmentfault.com/a/1190000015311755?utm_source=tag-newest。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值