前言
ECMAScript标准是深入学习JavaScript原理最好的资料,没有其二。
通过增加对ECMAScript语言的理解,理解javascript现象后面的逻辑,提升个人编码能力。
欢迎关注和订阅专栏 重学前端-ECMAScript协议上篇
本节说的是浏览器环境, 而且说的是普通脚本而不是模块脚本的的加载和申明实例化。
这里的script你可以理解为 script节点或者脚本。
必备知识
Script Record
脚本记录,封装了关于正在评估的脚本的信息。
字段名 | 值类型 | 含义 |
---|---|---|
[[Realm]] | Realm Record 或者undefined |
脚本创建所在的领域 |
[[ECMAScriptCode]] | 解析节点 (Parse Node) | 解析源代码的结果 |
[[LoadedModules]] | 已加载的模块 | |
[[HostDefined]] | 任何值 | 保留字段给宿主环境需要附加的信息。 |
加载和执行
以经典的脚本(非模块脚本)为例:
xml
html
复制代码
<script src="./test.js"></script>
ini
javascipt
复制代码
// test.js
var a = 10;
let b = 20;
function fn1(){}
结合HTML协议和ECMAScript协议,用直观的图示,大致如下:

加载和执行流程是和宿主环境配合完成的, 这里极大的简化流程,因为Realm又从属于Agent, Agent 之上还有Agent Cluster等等。
-
InitializeHostDefinedRealm 初始化领域(内置对象,全局对象,上下文,绑定关系等)
- 创建领域记录
- CreateIntrinsics ( realmRec )
创建内置对象。后面全局对象绑定的很多对象和方法,就是这里创建的。比如Array
,Set
,isNaN
等 - 创建执行上下文 ,初始化global,创建全局环境记录等
- SetDefaultGlobalBindings ( realmRec )
给全局对象绑定属性,globalThis
,Infinity
,NaN
,undefined
,eval
,isFinite
,isNaN
,parseFloat
,parseInt
,decodeURI
,Array
,Date
,Error
,Function
,Map
,Promise``JSON
,Math
,Reflect
,Atomics
等等。 - 初始化宿主自定义的全局对象属性
windodw, document, fetch等这类不是ECMAScript内置的,而是宿主定义的。
-
Fetching scripts 加载脚本
-
Creating scripts 创建脚本记录
这里的create script不是创建 script 标签,而是 createScript Record (脚本记录),就是一个记录脚本相关信息的抽象的数据结构。
-
ParseScript ( sourceText, realm, hostDefined )
把源代码转为 脚本记录。
- Static Semantics: ParseText ( sourceText, goalSymbol )
把源代码转为预期的解析节点。
- Static Semantics: ParseText ( sourceText, goalSymbol )
-
-
Calling script 调用脚本
-
ScriptEvaluation ( scriptRecord ) 执行脚本
- 新建和设置执行上下文
- GlobalDeclarationInstantiation全局代码申明实例化
- Evaluation 执行脚本记录
-
这里Calling script 调用脚本有用户定义的函数执行吗? 答案当然是有,函数的执行都会涉及之后要提到的[[Call]]
。
如果是内置的script标签节点,那当然就不用去 fetching 此脚本, 其是和html一起返回来的。
xml
html
复制代码
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>测试</title>
</head>
<body>
<script>
var a = 10;
let b = 20;
function fn1(){}
</script>
</body>
</html>
执行上下文,环境记录,申明等
当示例代码执行到最后一行,结合下面的表格,一起看看执行上下文,环境记录,申明等等的关系
属性名 | 名词解释 |
---|---|
LexicalEnvironment | 词法环境记录 |
VariableEnvironment | 变量环境记录 |
Realm | 关联的领域 |
ScriptOrModule | 脚本或者模块记录 |
ObjectRecord | 对象环境记录 |
DeclarativeRecord | 申明环境记录 |
VarNames | var申明标识符名 |
GlobalThisValue | 全局环境记录中的的this, 即全局对象,就浏览器也是window |
bindings | 绑定关系 |
BindingObject | 对象环境记录的绑定对象。这里就是全局对象。 |
IsWithEnvironment | 这里的值是false。表示不是with语句的对象环境记录。 |

先看 let 申明的b
标志符, 其在词法环境记录(LexicalEnvironment) 指向的全局环境记录的申明环境记录上。
标志符 a
和fn1
的绑定关系呢, 其存在于两个地方:
- 作为属性挂到全局对象了 GlobalThisValue, 有同志就会说,不对啊,浏览器中,全局对象是window啊,这就要看看 html协议的 the-window-object。 window, self 返回的就是全局环境记录的 GlobalThisValue。

- 二是的对象环境记录
BindingObject
, 当然这个BindingObject
就是 全局对象。

为啥要存在两份呢,方便访问:
- 全局对象上访问, 在浏览器中全局代码执行的时候吗,this 和 window 是等同的。
- 直接访问,实际走的是从对象环境记录中查找绑定关系
看看代码就知道了,现在知道了,全局属性这么方便的能访问,有两位在后面默默付出的天使:
javascript
javascript
复制代码
console.log(this.a); // 从this上读取
console.log(window.a) // 从全局对象属性读取
console.log(a); // 从上下文从对象环境记录中查找到
这里细心的同学会发现, 之前的截图中明明有,[[VarNames]]
有标志符信息,又是干什么的呢?
出现的地方: 基本都是用于检查环境记录中是否存在某申明
检查是否有对应的申明。用于GlobalDeclarationInstantiation ( script, env ) 全局代码执行时检查申明。
小结
- 执行脚本全局顶层代码 时,词法环境记录和变量环境记录是相同 的。详情参见本文 ScriptEvaluation ( scriptRecord )章节。
VarNames
保存者var申明的标志符- 执行全局顶层代码时,函数申明的行为是等同于var申明的。 有别于函数代码执行。在全局对象和对象环境记录中均保存着关联信息。可参见 8.2.8 Static Semantics: TopLevelLexicallyDeclaredNames

- 执行全局顶层代码时,var申明和函数申明会转为全局对象的属性
- 词法申明保存在词法环境记录的申明环境记录中
你说我咋知道的,额,接着往后看。
标志符查找和取值逻辑
标志符基本查找和取值逻辑如下,比如查找标志符 a
console.log(a)
和 console.log(obj.a)
都会触发标志符a
的查找和取值。

接下来看细节,如下的 console.log(a)
实际先发生了查找引用记录,然后取值。
ini
javascript
复制代码
// test.js
var a = 10;
let b = 20;
function fn1(){}
console.log(a);
console.log(b);
如果是用下面的的图来找,那是相当的容易,不过也得先知晓两点
- 查找大都是从执行上下文 的 LexicalEnvironment 指向的环境记录开始的
- 全局环境记录 是先查询申明环境记录(DeclarativeRecord),然后再找对象环境记录(ObectRecord) 参见 9.1.1.4.6 GetBindingValue ( N, S )

下面是最后查寻到的路径,其实其先到申明环境记录查询(蓝色 )了一波,没有才到对象环境记录(红色)中查找到了 a。

标志符a 取值 更具体的协议流程如下:
- ResolveBinding ( name [ , env ] ) 执行上下文通过标志符查找绑定关系
-
- => GetIdentifierReference ( env, name, strict ) 通过标志符查找引用记录
- => GetValue ( V ) 引用记录取值,本例是从环境记录中取值
- => GetBindingValue ( N, S ) 从环境记录中取值
再简单总结
- 执行上下文 通过标志符 (绑定名)从 环境记录 中查找 引用记录
- 引用记录 取值,本例引用记录的
[[Base]]
是全局环境记录,所以是从 全局环境记录 中取值
当遇到标志符a
时,执行上下文是没法知道是否有对应申明的,查找细节如下:
- ResolveBinding ( name [ , env ] ) 取环境记录和严格模式,传递给GetIdentifierReference
执行该方法时,绝大多情况都是没有传 env参数时,在解构,剩余参数等情况会传递。 没有env的情况,会从当前执行上下文中取词法环境,作为第一个参数传递给 GetIdentifierReference ( env, name, strict ) , 此方法还会判断是不是严格模式,作为第三个参数传递给 GetIdentifierReference ( env, name, strict ) 。

- GetIdentifierReference ( env, name, strict ) 通过传入的环境记录查询引用记录,如果没有向外层继续查询

- GetValue ( V ) 取值, 本章节本示例不是属性引用,是从环境记录中取值,会取值
[[Base]]
属性上的环境记录,调用环境记录上的取值。 本示例是从全局环境记录取值,所以调用的是全局环境记录上的 GetBindingValue ( N, S )


console.log(a)
在最后一步是从 对象环境记录取值,而 console.log(b)
呢是从申明环境记录中取值。
那么 console.log(fn)
是从哪取值的呢?
为什么先看查找呢? 因为相当直观和容易理解,知道路怎么走就可以,不一定要知道路是怎么造出来的。
接下来就一起研究全局代码的情况下, 这些标志符的绑定关系是怎么实例化和初始化的。
环境记录和申明实例化方法
申明实例化其实原理也非常的简单,就是根据申明的类型不同,调用环境记录创建不同的绑定关系。
这些操作是在ScriptEvaluation ( scriptRecord ) 中的 ****GlobalDeclarationInstantiation ( script, env )****步骤实现的。
全局环境记录一共可以创建四种绑定关系,列表如下
方法 | 类型 | 相关的环境记录 | 有无值初始化 |
---|---|---|---|
env.CreateImmutableBinding | 不可变绑定,const | [[DeclarativeRecord]]申明环境记录 | 无 |
env.CreateMutableBinding | 可变绑定,let/class | [[DeclarativeRecord]]申明环境记录 | 无 |
env.CreateGlobalFunctionBinding | 函数绑定 | [[ObjectRecord]]对象环境记录 | 有 |
env.CreateGlobalVarBinding | var绑定 | [[ObjectRecord]]对象环境记录 | 无 |
本示例的实际情况如下
方法 | 作用 | 相关的环境记录 | 标志符/绑定名 |
---|---|---|---|
env.CreateMutableBinding | 创建可变绑定,let | [[DeclarativeRecord]]申明环境记录 | b |
env.CreateGlobalFunctionBinding | 创建函数绑定 | [[ObjectRecord]]对象环境记录 | fn1 |
env.CreateGlobalVarBinding | 创建var绑定 | [[ObjectRecord]]对象环境记录 | a |
环境记录有个 [[OuterEnv]]
字段,指向外部的环境记录,但是全局环境记录的这个值 为 null, 表示没有外部的环境记录,所以是最为简单的申明实例化了。
后面的函数申明实例化的时候,会因代码的不同,会新建不同数量的环境环境,然后通过 [[OuterEnv]]
关联起来。
全局申明实例化大流程
基本大流程
- 初始化领域 InitializeHostDefinedRealm
- 源代码转为脚本记录 ParseScript ( sourceText, realm, hostDefined )
- 脚本评估 ScriptEvaluation ( scriptRecord )
-
- 全局申明实例化(重点讨论) GlobalDeclarationInstantiation ( script, env )
说具体的细节前,还是再看一下基本流程图

InitializeHostDefinedRealm
初始化领域
- 创建领域
- 创建内置对象 CreateIntrinsics
- 设置领域属性
- 创建执行上下文 execution context,初始化global, 创建全局环境记录 NewGlobalEnvironment(global, thisValue)等
- 默认的全局绑定关系,就是给全局添加默认的属性和方法
- 等等
并绑定宿主自定义的全局对象属性到全局对象上。

CreateIntrinsics
创建各种内置的对象,函数等。 后续会被绑定到全局对象上。
SetDefaultGlobalBindings ( realmRec )
给传入的领域(Realm)上的全局对象绑定属性,给全局对象绑定属性, 分为如下四类
globalThis
,Infinity
,NaN
,undefined
全局对象值属性eval
,isFinite
,isNaN
,parseFloat
,parseInt
,decodeURI
等各种内置函数。Array
,Date
,Error
,Function
,Map
,Promise
等各种构造函数对象。JSON
,Math
,Reflect
,Atomics
这些其他对象
到这里,全局对象就有了各种内置的属性了。
ParseScript ( sourceText, realm, hostDefined )
把源代码转为脚本记录(Script Record)。底层调用了 ParseText ( sourceText, goalSymbol )。
这里记录两点
- ParseText 返回的是类型为Script的解析树
- 脚本记录(Script Record)
[[ECMAScriptCode]]
保存着解析树,之后脚本语句的执行和这个关系紧密。

解析完毕的节点精简后如下(放大看更信息)

ParseText ( sourceText, goalSymbol )
把源代码转换为指定类型的节点(解析树)。 比如Script, Module, StringNumberLiteral等等。 应该是类似AST的节点(解析树)。 AST的节点和结构可以参考 github.com/estree/estr...。



ScriptEvaluation ( scriptRecord )
脚本评估和执行。即执行全局代码,传入的是之前 ParseScript返回的脚本记录。
包括创建新的执行上下文, 压栈和出栈上下文,设置词语环境和语法环境记录,在当前全局环境中实例化申明。 执行代码 逻辑是没有明确定义的,因为打散到各种语句中去了。


这个阶段上下文的词法环境和变量环境记录是相同的。
接下来的核心就是 GlobalDeclarationInstantiation ( script, env ) , 申明实例化的真正战场。
GlobalDeclarationInstantiation ( script, env )
重点之重点, 整个流程18步骤,基本可以分为三类操作
- 灰色:从Script节点获取变量/词法申明 的标志符名或者节点
- 紫色:遍历操作,前两个紫色是做语法检查,比如重复申明等, 后两个是做过滤
- 蓝色:根据标志符名或者节点创建绑定关系

要想完整理解这个操作,你的先熟悉之前的文章关于Scope Analysis 的下面的一些概念。
| 名字 | 含义 | | ----------------------- | -----------javascript--- | | BoundNames | 绑定名。包含var和非var的。 | | LexicallyDeclaredName | 词法申明的名字(列表) | | LexicallyScopedDeclarations | 和 LexicallyDeclaredNames 类似, 不过这里返回的是 解析节点(Parse Node) | | VarDeclaredNames | var申明的名字(列表)。包含 TopLevelVarDeclaredNames | | VarScopedDeclarations | 和 VarDeclaredNames 类似, 不过这里返回的是 解析节点(Parse Node)。包含TopLevelVarScopedDeclarations |
对于 web浏览器,还有一些特殊的操作 B.3.2.2 Changes to GlobalDeclarationInstantiation。
所以本来的这些参数,先确认一下,只有确认了Scope Analysis 的这些参数,才能完整的推导逻辑。
ini
javascript
复制代码
// test.js
var a = 10;
let b = 20;
function fn1(){}
名字 | 值 | 解释 |
---|---|---|
LexicallyDeclaredNames | ['b'] |
词法申明名 |
LexicallyScopedDeclarations | [LexicalDeclaration] 对应的源码如下['let b = 20;'] |
词法申明节点 |
VarDeclaredNames | ['a', 'fn1'] |
变量申明 |
VarScopedDeclarations | [VariableDeclaration, FunctionDeclaration] 对应的源码如下['a = 10', 'function fn1(){}'] |
变量申明节点 |
协议一共18步骤,分段解析如下:
- 取词法申明名和变量申明名列表,并检查冲突

按照协议,那么下面的写法就会报错:

-
记录函数申明的名字和函数申明节点操作
a. 变量申明中的函数申明的绑定名 追加到 declaredFunctionNames
本示例只有:
fn1
b. 变量申明中的函数申明 加入functionsToInitialize,
本示例只有
function fn1{}
代码对应的FunctionDeclaration
。bashbash 复制代码 **这里只是记录,因为 从FunctionDeclaration节点 变为开发者使用的 function 对象,还需要进行评估操作。**

- 把 var 申明中的非函数申明 的绑定名添加到 declaredVarNames中, 本示例 只有
a
。
保存这些信息同样是为了之后把申明关系绑定到全局对象和全局环境对象上去。 这部分也会检查是否可以添加申明,因为可能已经存在绑定关系,而且不可以更改。

- 依据词法申明,在全局环境的创建绑定关系。 本示例对应代码:
["b = 20"]
a . 是在 申明环境记录中 创建绑定关系 。
b. 只有创建绑定关系(实例化),没有赋值(初始化) 。 实例化和初始化是两个操作。

- 把之前保存的函数申明,在全局环境记录中创建绑定关系(实例化)并赋值(初始化)。本示例对应代码:
["function fn1(){}"]
a. 是通过全局环境记录中的对象环境记录给全局对象创建属性
b. 全局环境记录中的对象环境记录[[VarName]]
按需添加标志符
c. 有初始化值

- 根据var申明,在全局环境记录中创建绑定关系。 本示例对应代码:
["var a=10"]
a. 是通过全局环境记录中的对象环境记录给全局对象创建属性
b. 全局环境记录中的对象环境记录[[VarName]]
按需添加标志符
其逻辑和函数申明差不多,只不过如果是之前没申明过的,初始值是undefined
。

这个GlobalDeclarationInstantiation ( script, env ) 函数申明和变量申明是有初始化值的,而let/const/class等词法申明是没有初始化的, 那在哪初始化呢?就在前面的 ScriptEvaluation ( scriptRecord ) ,参见如下标注

最终申明实例化之后关系图如下:
- let/const/class等词法申明,是没有初始化的

给之前的流程图,加上标志符和节点信息的标注,如下:

有些同学,肯定比较疑惑,为什么有时候是 申明标志符信息,有时候是申明节点信息?
- 语法检查的时候,用名字就能检查是否重复或者冲突了
- var的变量申明,虽然会实例化和初始化,但是初始化的值都是 undefined ,所以用 申明标志符信息就够了
- 词法申明实例化为啥要 申明信息呢? 因为光凭标志符的信息,你是没法识别是 let 可变申明,还是 const 不可变申明的。
- 函数实例化为 function object 是需要具体点节点信息的,显然光一个标志符名是不够的。
词法申明初始化
词法申明的初始化都是代码执行部分的逻辑了,期待后续的内容吧。
思考
那么如下的代码,标志符C的值是保存在哪,和var申明又有和区别的呢
ini
javascript
复制代码
c = 100;
var eName = 'global eName';
function log(){}
提示:
c = 100
会在[[ObjectRecord]]对象环境记录上创建绑定关系,但是 varNames中并没有c
这个标志符,
varNames: ["eName", "log"]
。