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


theme: v-green

本文为稀土掘金技术社区首发签约文章,14天内禁止转载,14天后未获授权禁止转载,侵权必究!

大家好,我是早晚会起风。这是专栏《从 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。

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

image

今天我们重点讲 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)。

image

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

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

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

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

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

  1. 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 错误。这个错误我们平时见得就非常多了,当我们给一个变量赋值为另一个未定义的变量时就会出现这种情况。

image

规范中的执行上下文 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。

image

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

image

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

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

image

LexicalEnvironment

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

再谈闭包

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

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

image

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

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 规范

最后,欢迎大家一键三连,有大家的支持才有更新的动力嘛~

© 版权声明
THE END
喜欢就支持一下吧
点赞6 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容