likes
comments
collection
share

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

作者站长头像
站长
· 阅读数 62

从 ECMAScript 语言规范和浏览器引擎的视角认识 JavaScript

强烈建议按照文章顺序阅读,点击 🔼 上方专栏可以查看所有文章。

前言

在上一章中,我们从浏览器引擎的角度理解了作用域查找背后的逻辑。这一章我们会从语言规范的角度来再次理解它,最后这两者可以结合在一起,从规范和引擎实现的角度,更加深入的理清作用域与闭包。

与之前的章节一样,分析规范的过程中不免会涉及到大量的规范内容,但是看完之后你一定会有所收获。

话不多说,我们开始吧~

环境记录 Environment Record

我们之前提到,在 ECMAScript 规范中定义了两种类型—— Language Types 和 Specification Type,后者只存在于规范中。Environment Record 就是其中一种 Specification Type。

Environment Record 直译过来叫做环境记录(文中我会交替使用英文名称与中文名称)。根据规范描述,它用于解析在嵌套函数(nested functions)和块(blocks)中的名称解析。每当我们执行代码时(比如函数),就会创建一个新的 Environment Record 来绑定对应的标识符。

每个 Environment Record 都有一个 [[OuterEnv]] 字段用于引用外部的 Environment Record,这样形成了嵌套结构。最外层的环境记录的 [[OuterEnv]] 值为 null。

环境记录的层次结构如下图所示,

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

今天我们重点讲 Declartive Environment Record 和 Function Environment Record。

Declarative Environment Record

从名称就可以看出,它关联的是我们代码作用域(scope)中的声明,可能包含 variable, constant, let, class, module, import, and/or function declarations 等等。下面我们举一个简单的例子,

// 假设 test 是一个 function
import test from 'test'

const a = 'grace'

{
    const a = 'walk;
    test(a);
}

这段代码中就包含了两层 Declarative Environemnt Record,下面我用伪代码来表示环境记录,

// 第一个环境记录
ModuleEnvironment = {
    test: ...
    a: 'grace',
    [[OuterEnv]]: Global Environment Record
}

// 第二个环境记录
DeclarativeEnvironmment = {
    a: 'walk',
    [[OuterEnv]]: ModuleEnvironment
}

在这个例子中,当我们调用 test(a) 时,DeclarativeEnvironment 中是没有定义 test 方法的,所以就通过 DeclarativeEnvironmment.[[OuterEnv]] 访问到了 ModuleEnvironment 找到了 test 方法的定义。

这个例子中特殊的一点是 ModuleEnvironment 是一个 Module Environment Record,它基于 Declarative Environment Record,用于表示一个模块的环境记录,额外提供了不可变的引入绑定。

(关于 Module Environment Record 的部分,我们后边有机会再讲)

变量查找

具体的变量查找方式,在规范中也有定义,叫做 GetIdentifierReference,它是环境记录中的一个抽象方法(abstract operation)。

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

整个过程和我们之前专栏第二章讲到的原型链查找很相似。

2. Let exists be ? env.HasBinding(name).

步骤 2 首先判断当前环境记录 env 内有没有要查找的变量 name。如果有,exists 会被置为 true,否则为 false。

3. If is true, then Return the Reference Record.

如果在当前 env 找到了变量,就返回一个 Reference Record 。后续会从这个引用记录中返回真正的变量值。(如果有疑问,请见这里

4. Else,

a. Let outer be env.[[OuterEnv]].

b. Return ? GetIdentifierReference(outer, name, strict).

如果没有找到,则将 outer 设置为 env 的外层的环境记录,再次调用 GetIdentifierReference 去查找,直到找到 name 变量并返回。

类似与查找到原型链顶部 null,环境记录查找也可能查找到 null。这个时候表明在全局作用域中也没有找到 name 变量,此时就对应步骤 1,返回一个空的 Reference Record。

这个 Reference Record 比较特殊,它的 [[Base]] 为 unresolvable,会在后续取值的过程中抛出一个 ReferenceError 错误。这个错误我们平时见得就非常多了,当我们给一个变量赋值为另一个未定义的变量时就会出现这种情况。

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

规范中的执行上下文 Execution Contexts

Execution Contexts 用于追踪代码的执行。无论在何时,总会有一个当前正在执行的上下文,我们叫做 running execution context。我们知道执行上下文是一个栈,遵循先入后出(LIFO,last-in / first-out)的算法。

每当一个新的执行上下文被创建时,它都会推入栈的顶部,同时成为 running execution context。当这个执行上下文的代码执行完毕后,它会被推出栈,然后当前的栈顶元素成为 running execution context。

执行上下文通过一些状态组件(State Components)来跟踪栈的状态。对于所有类型的执行上下文,规范中定义了以下一些组件,

  • code evaluation state:执行上下文的运行状态,有执行(perform)、暂停(suspend)和 重新执行(resume)三种。
  • Function:当前执行上下文是否由函数创建,如果是,那么值为一个函数对象,否则值为 null
  • Realm:必要的资源,浏览器环境下为 windows 对象。
  • ScriptOrModule:代码所在的脚本记录或者模块记录。The Module Record or Script Record from which associated code originates.

对于代码创建的执行上下文,还有以下一些状态组件,

  • LexicalEnvironment:标识用于处理当前执行上下文的标识符引用的环境记录
  • VariableEnvironment:标识保存由此执行上下文中的VariableStatement创建的绑定的环境记录
  • PrivateEnvironment:ClassElements 创建的 Private Names。null 表示不是 class。

对于这里的绝大部分内容我们都可以忽略,现在只需要关注两个组件。

code evaluation state

首先是 code evaluation state,它很好理解。我们举一个例子来看,

function bar () {
    console.log('bar');
}

function foo () {
    console.log('before bar');
    bar();
    console.log('after bar');
}

foo();

我们可以利用 Chrome 控制台提供的能力查看这个例子调用时的堆栈,

首先,我们通过全局调用 foo 函数,此时堆栈如图所示,foo 就是当前的 running execution context,状态为 perform。

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

打印完 'before bar’ 后,我们调用了 bar 函数,此时堆栈如图所示,

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

bar 对应的执行上下文此时为 perform 状态,foo 此时为 suspend 状态,即被挂起。

当我们打印 'bar’ 执行完成 bar 函数后,bar 对应的执行上下文就会销毁了。此时 running execution context 又指向了 foo,状态就变成了 resume —— 重新执行。

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

LexicalEnvironment

LexicalEnvironment 是一个还将记录,它保存在执行上下文中的。例如我们执行一个函数时就会创建一个执行上下文,当我们执行代码查找变量时,就会调用执行上下文上环境记录的 GetIdentifierReference 方法。

再谈闭包

通过刚才的分析我们已经知道,当我们执行函数时,会创建执行上下文,其中有环境记录对应的信息。通过环境记录的 [[OuterEnv]],我们就形成了一条链条。这条链条与我们代码的嵌套结构,也就是我们所说的词法作用域一致。

我们知道在 JavaScript 中函数也是一种对象。当我们在全局/函数中声明一个函数对象(function obejct)时,对象上会有一个叫做 [[Environment]] 的内部插槽,他保存的就是这个函数声明所在的环境记录。

从 ECMAScript 认识 JS(章四):深入理解变量查找与闭包

所以,根据规范描述,我们可以认为,每一个函数都是闭包。怎样理解?我们举一个经常见到的例子。

var a = 'grace';

function foo () {
    var a = 'walk';
    return function bar () {
        console.log(a);
    }
}

const bar = foo();

bar();

这个例子我们都知道,因为闭包的存在,最后打印出来 a 的值是 walk 。我们利用今天的知识,写一下 foo 和bar 对应的环境记录。

fooEnvironment = {
    a: 'walk',
    [[OuterEnv]]: Global Environment Record
}

barEnvironment = {
    [[OuterEnv]]: fooEnvironment
}

当我们在 foo 函数中声明 bar 函数时,bar 对象的 [[Environment]] 内部插槽已经被定义为 fooEnvironment 了。所以不论我们在什么时机,比如返回 bar 函数后在全局调用,又或者是在一个 setTimeout 中调用,bar 函数查找变量 a 时,始终是沿着 [[OuterEnv]] 一路向上查找。

在这个例子中,bar 在自己的环境记录 barEnvironment 中没有找到变量 a,就接着去 fooEnvironment 查找并找到返回。即使在全局环境调用 bar 时, foo 对应的执行上下文已经被销毁了,环境记录的引用依然还存在。这就是我们通常所说的闭包。

那为什么说每一个函数都是闭包呢?

从规范我们可以知道,闭包的内部机制是 Environment Record。而每一个函数都会有它对应的 Environemnt Record,所以闭包并不是当我们在 foo 函数中 return bar 函数时才产生的,它们在函数创建时就已经存在了。这也对应于我们上一章说道的,V8 引擎在编译 JavaScript 代码时就已经知道变量查找对应的位置了。

写在最后

这节我们从 Environment Record 开始,重新理解了我们认知中的变量查找与闭包。文章最后对函数的创建和执行过程进行了非常浅层的分析。函数在 JavaScript 中是最为重要的一个组成部分,在后面的章节中我们还会花费更多的时间来深入讲解函数。

参考资料

ECMAScript 规范

最后,欢迎大家点赞评论收藏,有大家的支持才有更新的动力嘛~

转载自:https://juejin.cn/post/7170907637037891621
评论
请登录