从 ECMA 规范解读 JavaScript 全局词法环境

1,353 阅读10分钟

作用域

在开始主要内容之前,先简单写一写作用域。

作用域是一套用于确定在何处以及如何查找变量的规则。这是 YDKJS 中给出的关于作用域的定义。我们知道 JavaScript 采用的是词法作用域(静态作用域),简单讲,JavaScript 的作用域是根据代码书写位置确定的。

现在给出这样一段代码,在这段代码中,innerFunc 的作用域嵌套在 func 的作用域之中,形成了作用域链

function func() {
  const a = 1
  function innerFunc() {
    const b = 3
    console.log(a, b)
  }
}

回到上面的定义:

  1. 何处:当访问变量 a 时,引擎无法在 innerFunc 作用域内找到它,但是由于作用域链的存在,引擎可以在 func 中找到变量 a
  2. 如何:有两种查找方式,分别是 LHSRHS。前者就是找到该变量所在的容器,从而进行赋值;后者就是读取到该变量本身的值。比如 var a = bb 就是 RHS 查找,a 就是 LHS 查找。

在了解了作用域之后,先引出一个问题:

Question 1: 在 JavaScript 中,作用域以及作用域链如何实现呢?

词法环境(Lexical Environment)

A Lexical Environment is a specification type used to define the association of identifiers to specific variables and functions based upon the lexical nesting structure of ECMAScript code.

根据定义,词法环境是一种存在于规范中的类型,用来定义标识符也就是变量名)与变量及函数也就是变量值)之间的映射关系。它由两部分组成:

  1. 环境记录(Environment Record),用于记录与当前词法环境相关的变量名与变量值绑定(在后文会经常提到绑定),也就是上面提到的映射关系。
  2. 对外部词法环境的引用(outer reference),可能是一个 null。这个引用在逻辑上建立了词法环境之间的嵌套关系,可以通过引用一层层寻找外部的词法环境。当然,一个外部的词法环境也可以被多个内部的词法环境所引用(其实就是一个复杂点的链表)。

le-chain

我们可以将词法环境简单表示如下:

LexicalEnvironment = {
  EnvironmentRecord: {
    // Identifier bindings
  },
  outer: <outer reference>
}

通常,一个词法环境会与特定的代码相关联,比如说函数声明、块或者 try...catch 中的 catch 子句。每当这些代码被执行时,都会重新创建一个新的词法环境。

现在,我们可以回答上文的 Question 1

在规范中,作用域通过词法环境来“实现”,多个词法环境通过 outer reference 建立引用,“实现”了作用域链。

环境记录(Environment Record)

环境记录主要有两类:

  1. 声明式环境记录(declarative Environment Record):主要用于绑定 var 声明、let 声明、const 声明、函数声明、class 以及模块导入。
  2. 对象环境记录(object Environment Record):每一个对象环境记录都有一个与之关联的绑定对象 (binding object),或者叫做 base object,通过该对象来持有绑定关系。

另外,还有 3 种特殊的环境记录,分别是全局环境记录(全局词法环境持有)、模块环境记录(模块词法环境持有)以及函数环境记录(函数词法环境持有)。其中函数以及模块环境记录都是一种声明式环境记录。本文接下来的内容会从全局代码的角度出发介绍全局词法环境及其相关概念,函数及模块就不展开了。

我们可以通过一张图来简单了解一下这些环境记录的作用及关系:

全局词法环境(global environment)

全局词法环境可以理解成全局作用域。其他所有的词法环境(包括函数词法环境以及模块词法环境)都可以通过 outer reference 将自己与全局词法环境连接起来,但是,由于全局词法环境没有外部的词法环境,所以它的外部引用是 null。它的环境记录不仅内置了一些标识符绑定,并且会有一个与之关联的全局对象(我们称之为 GlobalObject)来持有一部分绑定。我们暂且可以这样描述全局词法环境:

GlobalLexicalEnvironment = {
  EnvironmentRecord: {
    // Identifier bindings
    GlobalObject: {
      // Identifier bindings
    }
  },
  outer: null
}

介绍完全局词法环境,我们先引出以下这些问题:

Question 2: 全局词法环境的环境记录中,为什么有两处保存标识符绑定的地方?

Question 3: 全局词法环境中的 GlobalObject 是指代哪个对象,window 吗?

接下来我们来看全局词法环境持有的环境记录,它被称之为全局环境记录

全局环境记录(Global Environment Record)

全局环境记录其实并不是一个单一的环境记录,实际上它是由一个声明式环境记录以及一个对象环境记录组成。那么,这两个环境记录分别有什么作用呢?

首先来看对象环境记录。在介绍全局词法环境的时候已经写到:

全局环境记录不仅内置了一些标识符绑定,并且会有一个与之关联的 GlobalObject”。

现在我们应该知道,这个 GlobalObject 其实就是该对象环境记录的绑定对象。全局环境记录正是通过这个 GlobalObject 绑定了内置的对象,属性等,比如 undefinedArrayObject,另外它也绑定了全局代码中的函数声明、var 声明等,并且可以通过全局作用域中的 this 访问到它。举个例子,有这样一段代码:

var one = 1
function two() {}

我们可以得到对应的全局词法环境如下:

GlobalLexicalEnvironment = {
  EnvironmentRecord: {  // Global Environment Record
    [[ObjectRecord]]: {  // Object Environment Record
      GlobalObject: {  // binding object
        Array, Object,  // build-in
        one: 1,
        two: '<func two>'
      }
    }
  },
  outer: null
}

我们知道 onetwo 都可以在 window 对象上被找到。其实我们现在就可以回答上文的 Question 3:在浏览器中,全局词法环境中的 GlobalObject 其实就是 window 对象。 既然是 window 对象,那我们还可以通过 window.three = 3 的方式来修改它。在规范中,为了区分是声明的方式还是直接显式修改 GlobalObject,全局环境记录会通过 [[VarNames]] 字段加以区分,one 标识符以及 two 标识符会被添加到 [[VarNames]] 中,但 three 不会。

简单了解下 [[VarNames]] 的作用,比较下面两段代码:

var one = 1
let one = 1

window.two = 2
let two = 2

第一段代码会抛出 SyntaxError 错误,而第二段不会,正是因为 [[VarNames]] 的作用。

除了对象环境记录持有的绑定之外,全局代码中其他的声明,比如 constlet 以及 class,则会记录在声明式环境记录中。我们修改上述代码:

var one = 1
function two() {}
let three = 3
const four = 4

相应地,全局词法环境也发生了变化:

GlobalLexicalEnvironment = {
  EnvironmentRecord: {  // Global Environment Record
    [[ObjectRecord]]: {  // Object Environment Record
      GlobalObject: {  // binding object
        Array, Object,  // build-in
        one: 1,
        two: '<func two>'
      }
    },
    [[DeclarativeRecord]]: {  // Declarative Environment Record
      three: 3,
      four: 4
    }
  },
  outer: null
}

简单了解全局环境记录之后,我们可以对全局词法环境的图示进行改进。这个图示也解答了上文的 Question 2:全局词法环境通过声明式环境记录以及对象环境记录的分工合作来持有绑定。

那么,全局词法环境及其环境记录是如何被创建的呢?在规范中,有一个 NewGlobalEnvironment 方法,它从外部接受 GlobalObject 以及 thisValue 参数,可以通过以下步骤来模拟它的行为:

  • Step 1: 创建一个新的词法环境,记为 env
  • Step 2: 创建一个新的对象环境记录,记为 objRec,并将 GlobalObject 作为其绑定对象;
  • Step 3: 创建一个新的声明式环境记录,记为 dclRec
  • Step 4: 创建一个新的全局环境记录添加到 env,记为 globalRec
  • Step 5: 设置 globalRec.[[ObjectRecord]]objRec,设置 globalRec.[[DeclarativeRecord]]dclRec,设置globalRec.[[GlobalThisValue]]thisValue
  • Step 6: 设置 envouter referencenull
  • Step 7: 返回 env 作为全局词法环境。

Realm

关于这个名词的翻译,没有找到合适的中文,所以就不翻译了。

Before it is evaluated, all ECMAScript code must be associated with a realm. Conceptually, a realm consists of a set of intrinsic objects, an ECMAScript global environment, all of the ECMAScript code that is loaded within the scope of that global environment, and other associated state and resources.

根据规范的定义来看,所有的 JavaScript 代码都会有一个关联的 realm,概念上讲,realm 包括一些内置的对象,一个全局词法环境,以及与该词法环境关联的所有代码。我们主要关注它的 3 个要素:IntrinsicsGlobalObject 以及 GlobalEnv

在这里我们又提到了 GlobalObject,在上文介绍 NewGlobalEnvironment 方法的时候,我们只是简单说明了 GlobalObject 由外部传入,并没有说明来自哪里。这个问题在这一节就有答案。

我们先来看 realm 的创建过程,在执行代码之前,浏览器会负责创建 realm。根据我们关注的 3 个要素,我把一个 realm 的创建过程简化为了两个步骤:

  • Step 1: 创建一个 Intrinsics。我们可以简单地将它类比成一个 JavaScript 对象。它的属性大家都非常熟悉,比如 Object.prototypeFunction.prototypeArray 以及 undefined 等(它看起来与 window 很像);
  • Step 2:GlobalObjectGlobalEnv 赋值为 undefined

所以一个 realm 的结构看起来就像这样:

realm = {
  [[Intrinsics]]: {
    Object.prototype, Function.prototype, Array, undefined
  },
  [[GlobalObject]]: undefined,
  [[GlobalEnv]]: undefined
}

接下来,浏览器则会负责 GlobalObjectGlobalEnv 的创建。Intrinsics 上的属性会被添加到 GlobalObject 上(这也就是为什么上文说 Intrinsics 看起来与 window 很像,window 的一部分内置属性就来自 Intrinsics),并将 this 指向它。随后 GlobalObject 以及 this 就会作为参数传递给上文提到的 NewGlobalEnvironment 方法,参与 GlobalEnv 的创建,GlobalObject 也就成为了全局词法环境的绑定对象。

小结

介绍了 全局词法环境全局环境记录以及 Realm 之后,用一张图梳理一下这 3 者的联系:

如果给出这样一段代码:

var f1n = 'f1n'
function f1n() {}
function f2n() {}
var f2n = 'f2n'
const immutable = 'immutable'
let mutable = 'mutable'

大家应该都知道变量提升这个名词,从变量提升的角度分析,大家也许会把 functionvar 提升到顶部。但是现在,我们暂时忘掉这个概念,也不去纠结 const 以及 let 到底会不会提升。我们从词法环境的角度出发:

假定浏览器已经创建了 realm 以及全局词法环境

  • Step 1: 发现了 var 声明,需要取到全局词法环境的 GlobalObject
  • Step 2: 判断 GlobalObject 本身是否有 f1n 属性?目前没有;
  • Step 3:f1n 绑定到 GlobalObject 上,并初始化为 undefined
  • Step 4:f1n 这个标识符字符串加入到 [[VarNames]] 列表中;
  • Step 5: 发现 function 声明,尝试从 GlobalObject 本身中获取 f1n 属性;
  • Step 6: f1n 属性已存在,直接将 f1n 修改为函数;
  • Step 7: 判断 [[VarNames]] 中是否存在 f1n 标识符字符串?已存在,不会重复添加。
  • Step 8: 发现又一个 function 声明,尝试从 GlobalObject 本身中获取 f2n 属性;
  • Step 9: f2n 不存在,直接将函数绑定到 GlobalObject
  • Step 10: 发现 var 声明,判断 GlobalObject 本身是否存在 f2n?已存在,不做操作;
  • Step 11: 发现 const 声明,取到全局词法环境的 [[DeclarativeRecord]]
  • Step 12:immutable 作为不可变绑定添加到 [[DeclarativeRecord]],此时未初始化
  • Step 13: 发现 let 声明,将 mutable 作为可变绑定添加到 [[DeclarativeRecord]],此时未初始化

最后执行阶段,依次执行:

  • f1n = 'f1n'
  • f2n = 'f2n'
  • immutable = 'immutable'
  • mutable = 'mutable',如果没有值,则赋值为 undefined

根据规范的定义,完整的步骤要复杂的多,这里我做了一定的简化。这篇内容几乎是啃规范整理出来的内容,也许在理解上会有错误,不对的地方还请大家指出。