ECMAScript 规范浅析与实践 — 1.闭包的形成及相关概念浅析

简介

本文为 ECMAScript 规范阅读经验总结。

通过阅读规范,可以将 JavaScript 中一些模糊的,复杂的特性搞清楚,比如说,对于 this 关键字,经常会在网上看到这样的表述:this 最后的指向取决于调用该函数是如何被调用的,以及箭头函数没有自己的 this 。那么对于这种情况,如何判断 this 的指向呢:

JavaScript 复制代码
class ClassA {
    name = 'a'
    func = () => {
        console.log(this)
    }
}
​
const a = new ClassA()
const b = {name: 'b' , func: a.func}
​
// 判断 this
b.func()

再比如,"闭包"究竟是什么时候生成的,具体是什么样子,这些问题都可以在规范中找到正确的答案。

闭包

在 JavaScript 中,如果一个函数A将一个函数B返回,并且B函数引用了A函数中声明的变量,那么即使A函数已经执行完毕,只要被返回的B函数不被销毁,A函数中的变量就仍然能够被B使用,我们将这种情况成为 B函数 拥有着对 A函数 的 闭包

然而,在整个 JavaScript 规范中,如果我们搜索闭包的英文:closure , 并不能找到对这种情况的官方描述,因为整个 JavaScript 的变量标识符查找,是由 执行上下文,环境记录器,声明实例化等一系列完整复杂且精密的系统组成的,闭包的出现,是由整个系统决定的,想要了解清楚,我们首先要一一认识系统中的部件。

一、概念介绍

先来两个简单概念:

Agent

首先是抽象概念 Agent , 它是 执行上下文栈以及线程的持有者,用伪代码表示,大概是这个样子:

JavaScript 复制代码
class Agent {
    executionContextStack: stack<ExecutionContext>
    thread: Thread
}

我们可以把它暂时理解为 JavaScript 线程的管理者,而他拥有的执行上下文栈,以及栈中执行上下文拥有的环境记录器,才是我们要关注的闭包生成的关键。

Realm

其次是 Realm 领域, 它提供了代码执行的基础资源,我们知道每个构造器的 prototype 对应着一个原型对象,比如我们编写代码 new Array() ,Array 构造器所对应的原型 %Array.prototype% 就存在于 Realm 的 Intrinsics 属性中,它大概长这个样子:

JavaScript 复制代码
class Realm {
    Intrinsics: Array<IntrinsicObject>
    GlobalObject: Object
    globalEnv: GlobalEnvironmentRecord
    .....
    .....
}

执行上下文 ExecutionContext

接下来是执行上下文以及环境记录器:

执行上下文 ExecutionContext 用来跟踪 JS 代码的执行并且保存一些代码执行必需的信息 它由 Agent 中的执行上下文栈管理,当调用一个函数时,会创建一个新的执行上下文,并压入栈顶;当这个函数执行完毕,对应的执行上下文就会从栈顶弹出 并且无论是全局代码执行还是函数内的代码执行,都会有且只有一个正在运行的执行上下文(running execution context) , 并且该执行上下文处于执行上下文栈的栈顶

上下文(context)的概念很抽象,根据《Vue3设计与实现》中的描述,可以将上下文看作程序在某个范围内可以访问的全局对象,举个实际的例子: 将一棵树上有限个节点的 label 属性加上一个后缀:

JavaScript 复制代码
const context = {
    suffix: '--suffix',
    curNum: 0,
    limit: 3
}
​
const tree = {
 id: 0,
 label: 'root',
 children: [
     {
         id: 1,
         label: '1',
         children: [
             {
                 id: 3,
                label: '1-1'
             }
         ]
     },
     {
         id: 2,
         label: '2'
     },
 ]
}
​
function modifyTree(node) {
    if(context.curNum >= context.limit) return 
​
    node.label += context.suffix
    context.curNum++
​
    for(let e of (node.children || [])) modifyTree(e)
}
​
modifyTree(tree)

对于函数 modifyTree 来说 , context 为它提供了: 需要添加的后缀字符串,已经添加后缀的节点数量,一共要为多少节点添加后缀 三个变量,并且无论是深度遍历到任何一层都可以访问到,这就是上下文的作用。

对于 JavaScript 程序来说,执行上下文为它提供了包括但不限于如下必需的资源:1.提供基础资源的 Realm , 2.用来记录标识符绑定状态的几个环境记录器 Environment Record , 执行上下文大概长这个样子:

JavaScript 复制代码
class ExecutionContext {
    // realm
    Realm;
    
    // environment record
    LexicalEnvironment;
    VariableEnvironment;
    PrivateEnvironment;
    
    ......
    ......
}

其中 LexicalEnvironmentVariableEnvironment 可以大致理解为:

LexicalEnvironment 用来记录使用 let , const 关键字进行词法声明的标识符;

VariableEnvironment 用来记录使用 function 进行函数声明、用 var 进行变量声明的标识符;

PrivateEnvironment 用来记录 class 内的私有字段,暂不讨论。

环境记录器 EnvironmentRecord

环境记录器 EnvironmentRecord 用来记录我们声明的变量(标识符),并且环境记录器通过它的 OuterEnv 字段构成了一个从内层指向外层的链表成为我们熟知的作用域链,在进行标识符解析的时候,会在整条链上进行标识符查找。

为了对标识符进行记录并且索引外层记录器,环境记录器需要几个函数来对变量标识符进行增删改查以及初始化和赋值,还需要一个 OuterEnv 字段,所以它大概长这个样子:

JavaScript 复制代码
abstract class EnvironmentRecord {
  // outerEnv
  outerEnv: EnvironmentRecord | null
​
  // create
  createMutableBinding(n: string , d: boolean): void
  createImmutableBinding(n: string , s: boolean): void
  
  // read
  hasBinding(n: string): boolean
  getBindingValue(n: PropertyKey , s: boolean): any
  
  // update
  
  setMutableBinding(n: PropertyKey , v: any , s: boolean): void
  
  // delete
  deleteBinding(n: string): boolean
  
  // initalization
  initializeBinding(n: PropertyKey, v: any): void
  ......
}

基类环境记录器有子类分别为 声明式环境记录器 DeclarativeEnvironmentRecord对象式环境记录器 ObjectEnvironmentRecord

其中声明式环境记录器又有一子类为函数式环境记录器 FunctionEnvironmentRecord ,用于函数调用时记录函数中声明的变量,并对 this 的具体指向起很大的作用,是我们讨论的重点。

而对象式环境记录器依附于一个对象比如全局对象 , 并将全局对象的属性绑定于环境记录器本身。

特别地,全局环境记录器 GlobalEnvironmentRecord 是声明式,对象式环境记录器的结合体,一般作为作用域链的末尾,并且其 outerEnv 指针指向 null。

执行上下文中的 LexicalEnvironmentRecordVariableEnvironmentRecord 字段只是代表有两种作用不同的环境记录器,至于两个具体是什么类型的环境记录器要按照规范根据具体情况创建,有的时候,两个字段指向同一个环境记录器是常有的事,代表这个环境记录器既要记录 let,const 声明,也要记录 function , var 声明。

环境记录器可以脱离执行上下文而存在,即使有些上下文被栈弹出。

声明实例化

最后一个需要介绍的概念是 声明实例化。

全局声明实例化 GlobalDeclarationInstantiation

在全局代码运行之前,需要创建 Realm ,全局环境记录器 GlobalEnvironmentRecord , 然后创建一个全局执行上下文(如果执行的是脚本代码则为称之为 scriptContext) 并推入执行栈顶作为正在运行的执行上下文,并且将此上下文中的两个环境记录器指向全局环境记录器, 紧接着便需要进行全局声明实例化,实例化的过程中,所有在全局声明的变量,函数,类等标识符都会被收集在全局环境记录器中,收集完毕之后,才开始运行全局代码。

函数声明实例化 FunctionDeclarationInstantiation

在函数调用之后与函数体代码运行之前,需要创建新的执行上下文(一般被称作 calleeContext) 并推入执行栈顶作为正在运行的执行上下文,同时将此上下文的两个环境记录器指向一个新创建的函数式环境记录器 ,然后进行函数声明实例化,实例化的过程中,会使用这个记录器来进行标识符的记录与绑定,在声明实例化完成之后,才开始运行函数体代码。

二、程序运行实践

介绍完了概念,我们使用一段简单的代码来将整个流程梳理一下

JavaScript 复制代码
<script>
    var varVariable
    let letVariable
​
    function outerFunc() {
        let obj = {
            text: 'Hello world.'
        }
​
        function innerFunc() {
            obj.text = 'Good Joe.'
        }
​
        return innerFunc
    }
​
    const res = outerFunc()
​
    res()
</script>

在 Agent 中的代码执行之前,需要调用 InitializeHostDefinedRealm() 方法初始化一个 Realm 来提供基础资源 ,此方法会实例化一个 Realm ,并且实例化它的 Intrinsics 字段来保存所有的 ECMAScript的固有对象,实例化它的 globalObject 字段创建一个全局对象(比如 window ),实例化它的 globalEnv 字段来创建一个 全局环境记录器

这之后,通过 ParseScript() 来解析脚本生成一颗解析树,并对语法错误(SyntaxError)进行检查。

如果没有语法错误,则调用 ScriptEvaluation() 方法开始执行脚本, ScriptEvaluation() 会先创建一个全局执行上下文,并且将此上下文的两个记录器都指向全局环境记录器,然后将上下文推入栈中成为正在执行上下文,紧接着进行全局声明实例化,最后逐行执行脚本语句:

而在全局声明实例化中,会将全局变量,函数,类等标识符按照具体规则将标识符绑定到全局环境记录器上,并且该实例化实例化,该初始化初始化。

特别地,如果遇到函数声明语句 function funcName() {... },不仅会将函数名绑定到全局环境记录器中,并且会调用 InstantiateFunctionObject(globalEnv , ......) 方法来创建一个函数对象,这里我们稍后再介绍。

在全局声明实例化结束后,脚本开始执行,我们将断点打在脚本第一行看下 Chrome 中的 Debugger 输出:

Chrome 将全局环境记录器拆分为 词法环境记录用Script 表示,变量环境用 Global 表示,此处用 var 声明的变量和函数被记录在 Global 中,用 let 和 const 声明的变量被记录在 Script 中。

再之后,程序继续运行,在程序运行到 outerFunc 被调用时,会创建新的执行上下文 (calleeContext),并且创建函数式环境记录器用来记录函数中声明的变量,同样的,如果遇到函数声明语句 function funcName() {... },不仅会将函数名绑定到该函数式环境记录器,并且会调用 InstantiateFunctionObject(globalEnv , ......) 方法来创建一个函数对象。

让我们将断点打到 outerFunc 被调用的时候查看一下 Debugger:

Local 代表的就是 outerFunc 创建的环境记录器,并且从Local 到 Global 是一条由 outerEnv 连接起来的作用域链条。

然而,此时函数刚刚开始运行,let obj = {...} 初始化语句还没有运行 (let声明此处应该没有变量提升,值为undefined是 Chrome 显示的问题),但是函数声明语句 function innerFunc() {} 已经在此前的函数声明实例化的过程中被创建成了一个函数对象了

最后,让我们将断点打在 innerFunc 被调用的时候:

同理,先进行函数声明实例化,再执行函数体代码,但是这次闭包出现了, 就在Local 和 Script 中间,作用域链中出现了一个属于 outerFunc 的 Closure。

其实,秘密就存在于 outerFunc 函数声明实例化时对于 innerFunc 函数声明语句处理 的过程以及 innerFunc 调用之前的调用过程中,即 InstantiateFunctionObject() 方法和 FunctionDeclarationInstantiation() 方法之中。

之前我们讲过,在函数声明实例化的过程中,如果遇到函数声明语句,会调用 InstantiateFunctionObject()函数声明创建为一个函数对象,它大概长这个样子:

JavaScript 复制代码
// 由 [[]] 包裹的字段为 js 内部插槽,仅供 js 程序运行时内部使用,不向外提供给开发者。
export class FunctionObject extends OrdinaryObject {
    
    ['[[Call]]']
​
    ['[[Environment]]']: EnvironmentRecord
​
    ['[[FormalParameters]]']
    
    ['[[ThisMode]]']: 'lexical' | 'strict' | 'global'
​
    ['[[Construct]]']
​
    ......
}

其中,函数对象的 [[Call]] , [[Construct]] 字段是在函数被调用 / 被new时会执行的内部方法,[[FormalParameters]] 保存了函数的形参, [[ThisMode]] 记录了函数是否是箭头函数,细节暂不讨论。

但是函数对象的 [[Environment]] 字段,记录了函数对象被创建时正在执行上下文的词法环境记录器 LexicalEnvironmentRecord ,在这个例子中,outerFunc 声明实例化过程时,正在执行上下文就是为 outerFunc 创建的 calleeContext , 其词法环境记录器 LexicalEnvironmentRecord 就是用来绑定 outerFunc 函数中创建变量,函数,类等标识符的函数式环境记录器 FunctionEnvironmentRecord ,那么在创建 innerFunc 函数对象时,会将该记录器作为参数传入 InstantiateFunctionObject() 方法,规范中详述如下:

InstantiateFunctionObject() 方法最终会调用 OrdinaryFunctionObject 方法来创建函数对象,并且传入环境记录器:

至此,由 innerFunc 声明的函数对象已经被创建,其内部插槽 [[Environment]] 指向着 outerFunc 执行时创建的函数式环境记录器,无论这个函数对象在程序运行流程中被返回到任何地方,其 [[Environment]] 都不会改变,牢牢指向其出生时所记录的环境记录器。

而在 innerFunc 函数被调用时,会在函数对象上调用 [Call] 内部方法 ,在这个过程中同样会创建自己的执行上下文 calleeContext 和 函数式环境记录器 FunctionEnvironmentRecord , 并且在创建过程中,新的记录器 outerEnv 指针恰恰指向的是该函数对象的 [[Environment]] 字段,规范中详述如下:

[[Call]] 调用 PrepareForOrdinaryCall 来创建上下文和环境记录器:

PrepareForOrdinaryCall 调用 NewFunctionEnvironment 来创建函数式环境记录器:

至此,innerFunc 函数体真正执行之前,甚至声明实例化之前(PrepareForOrdinaryCall 略早于声明实例化) ,innerFunc 自己的函数式环境记录器已经和其函数对象 [[Environment]] 中记录的环境记录器通过 outerEnv 相连,作用域链已经形成,在标识符索引时,自然能够通过 innerFunc 的记录器作为起点,层层向上索引,直到找到标识符。

函数对象 [[Environment]] 字段对于出生所在执行上下文中环境记录器的保存,以及函数调用时对于新老记录器通过 outerEnv 的连接,共同构成了"闭包"现象。

参考资料

ECMAScript 规范

everyone-can-read-spec

相关推荐
有一个好名字1 分钟前
Vue Props传值
javascript·vue.js·ecmascript
pan_junbiao7 分钟前
Vue使用axios二次封装、解决跨域问题
前端·javascript·vue.js
秋沐18 分钟前
vue3中使用el-tree的setCheckedKeys方法勾选失效回显问题
前端·javascript·vue.js
南斯拉夫的铁托2 小时前
(PySpark)RDD实验实战——取最大数出现的次数
java·javascript·spark
GoppViper2 小时前
uniapp js修改数组某个下标以外的所有值
开发语言·前端·javascript·前端框架·uni-app·前端开发
好看资源平台2 小时前
JavaScript 数据可视化:前端开发的核心工具
开发语言·javascript·信息可视化
会蹦的鱼3 小时前
React学习day07-ReactRouter-抽象路由模块、路由导航、路由导航传参、嵌套路由、默认二级路由的设置、两种路由模式
javascript·学习·react.js
DT——8 小时前
Vite项目中eslint的简单配置
前端·javascript·代码规范
真的很上进10 小时前
【Git必看系列】—— Git巨好用的神器之git stash篇
java·前端·javascript·数据结构·git·react.js
qq_2780637111 小时前
css scrollbar-width: none 隐藏默认滚动条
开发语言·前端·javascript