前言
ECMAScript标准是深入学习JavaScript原理最好的资料,没有其二。
通过增加对ECMAScript语言的理解,理解javascript现象后面的逻辑,提升个人编码能力。
欢迎关注和订阅专栏 重学前端-ECMAScript协议上篇
暂时性死区
全局顶层代码
如下的代码,在letA 没有被初始化值之前访问,被抛出 ReferenceError, 这就是暂时性死区。 当然 class 和 const 也是如此。
javascript
console.log("varA:", varA); // undefined
console.log("letA:", letA); // Uncaught ReferenceError: letA is not defined
var varA = 'varA';
let letA = 'letA';
那其背后的实现逻辑是什么呢?
从之前的文章已经知道,代码执行之前都有一个申明实例化的过程(代码未执行)
- 全局代码 的全局申明实例化 GlobalDeclarationInstantiation
- 普通函数的 函数申明实例化 FunctionDeclarationInstantiation
这个过程有两个概念
- 创建绑定关系(实例化): 仅仅是创建了标志符,没有设置对应的值
- 初始化: 设置标志符对应绑定关系的值
申明实例化的过程,对待变量申明,函数申明,词法申明是有一定区别的:
- 全局顶层代码,函数顶层代码等申明实例化的时候,函数申明和变量申明 也会初始化,
- 函数申明的初始化值是对应的函数对象
- 而变量申明初始化值是
undefined
- let/const/class 申明实例化时,只会被实例化,没有被初始化值。
全局顶层代码 变量申明 实例化和初始化:

let/const/class 只有实例化,没有初始化
本示例全局顶层代码申明实例化完毕之后,执行上下文,环境记录,标志符绑定关系图如下,

说完实例化和初始化,接着说标志符的查找,红色线就是 console.log("letA:", letA);
执行时,标志符 letA
的查找路线。
再简单重复一下标志符取值逻辑:
- 执行上下文通过标志符 查找( ResolveBinding)到 引用记录 (Reference Record)
- 通过引用记录(Reference Record) 取值 (GetValue)
- 如果 不可达,抛出 ReferenceError
- 如果 是属性引用,从对象上取值
- 否则 从环境记录上,通过标志符 获取绑定值
因为是通过标志符取值,此场景下面 V 必然是引用记录。 那啥时候不是呢? 比如 var varA = 10
, 这个varA被初始化值,右边也会发生一次取值 GetValue 行为,这时候的 V 就不是引用记录。

本示例的环境记录是全局环境记录, 其 GetBindingValue底层逻辑就是 先从 申明环境先查找,再到 对象环境记录查找。

申明环境 GetBindingValue逻辑如下:
如果找到了绑定关系,但是没有初始化,就抛出异常。 letA
就是如此

而对象环境记录 GetBindingValue逻辑 , 就等同从从属性上取值,本示例varA
自然就不会报错。

总结
- 环境记录上的绑定关系,实例化和初始化值是两种操作。
- 全局顶层代码,函数顶层代码等申明实例化的时候,变量申明和函数申明会实例化也会初始化,而词法申明只会实例化,初始化是代码执行后。
- let, class, const 等词法申明的绑定关系,是保存在 申明环境记录上的
- 申明环境记录上的绑定在进行取值操作时,如果值没有初始化,抛出 ReferenceError。
到这里,看起一切都很合理,那么协议是怎么知道,某个绑定关系是否已经初始化呢? 因为关系图为了直观和简洁并没有体现, 其实绑定关系还有额外的字段。 相对完整的绑定关系,大致如下。

函数顶层代码
函数顶层代码的情况是类似的,只不过变量申明和词法申明都保存在 申明环境记录下。
javascript
function log(){
debugger
let letA = 'letA';
var varA = 'varA';
}
log();
函数申明实例化完毕后,执行上下文,环境记录和标志符绑定关系如下:

变量申明会被实例化和初始化。

词法申明只有实例化

思考
如下代码会报错,请画出 console.log(p)
执行时,执行上下文,环境记录,标志符的关系图。
javascript
for(let p of [function log(){ console.log(p)}] ) {
p();
}
// Uncaught ReferenceError: Cannot access 'p' before initialization
with 语句
javascript
const obj = {
name: "object name"
}
const name = "name";
with(obj) console.log(name); // object name
console.log(name); // name
with 的逻辑其实很简单,就是进入 with 语句的时候吗,新建了一个对象环境记录 Object Environment Record,
全局环境记录就是 申明环境记录 + 对象环境记录的结合体。
其由一个内部字段 [[IsWithEnvironment]]
用来区分这两种情况的 , true 表示是 with 语句创建的。
字段名 | 值 | 含义 |
---|---|---|
[[BindingObject]] | 一个Object | 对象环境记录绑定的对象。 |
[[IsWithEnvironment]] | Boolean值 | 是否为 with 语句创建。 true:表示with语句, false:全局环境 |
比如如下示例 的 [[BindingObject]
就是 obj。
javascript
var obj = {name:1};
width(obj){
......
}
with 语句的评估过程也是非常简单的 Runtime Semantics: Evaluation, 下面标注了 环境记录的变化

因为 with 的小括号里面是表达式,所以先评估求值
- 评估表达式,评估结果 为 val
- 对 val 取值,并转为对象
- oldEnv 为 当前执行上下文的 LexicalEnvironment
- 新建环境记录 newEnv,
[[outerEnv]]
指向 oldEnv - 当前执行上下文的 LexicalEnvironment 指向 newEnv
- 评估 with 后面的表达式
- 当前执行上下文的 LexicalEnvironment 指向 oldEnv
进入with语句前的关系图

进入with 语句后,执行 console.log(name)
时的关系图如下
这个时候,标志符 name
在 对象环境记录上找到了,就直接返回了。

离开with语句后,当前执行上下文的 LexicalEnvironment 又指回了全局环境记录。
这个时候,执行 console.log(name)
, 标志符 name
在全局环境记录上的申明环境记录上找到了。
with 语句的本质是通过 对象环境记录 延长了 环境记录链路。
注意事项
- 对象环境记录是进入with语句时那个时刻创建的, 如果with后面表达式变更了 绑定对象的的指向,环境记录不会变化。
javascript
let obj = {a: 1};
with(obj){
console.log(a); // 1
obj = {a:2};
console.log(a); // 1
}
- with 语句性能差
javascript
var a = {
b: 1
};
function testNoWith(times) {
var obj = a, i = times, j = 0;
for (; i; i--) {
j = obj.b;
obj.b = i;
}
}
function testWith(times) {
var i = times, j = 0;
const aa = a;
with (aa) {
for (; i; i--) {
j = b;
b = i;
}
}
}
var startTime = performance.now();
testWith(10 * 10000);
console.log(performance.now() - startTime);
startTime = performance.now();
testNoWith(10 * 10000);
console.log(performance.now() - startTime);
使用 node 16.16.0 测试,
- testNoWith 1~2 ms
- testWith 60+ms
大家知道全局环境记录上也是有 对象环境记录的,比如下面 testGlobal
的 标志符 b 就是从 全局环境记录上的对象环境记录查找到的。但是速度上,与 testLocal
基本没有差别
javascript
var b = 1;
function testLocal(times) {
var j, i = times;
var b = 1;
for (; i; i--) {
j = b;
b = i;
}
}
function testGlobal(times) {
var j, i = times;
for (; i; i--) {
j = b;
b = i;
}
}
var startTime = performance.now();
testLocal(10 * 10000);
console.log(performance.now() - startTime);
startTime = performance.now();
testGlobal(10 * 10000);
console.log(performance.now() - startTime);
使用 node 16.16.0 测试,
- testLocal 1~2 ms
- testGlobal 1-2 ms
从协议上来看, 都是 对象环境记录,差别却很大,个人猜测 ,估计是JS引擎实现 with 语句时,引擎难以对相关代码进行有效的优化。
毕竟: 协议归协议,实现归实现。
if 语句
对应协议 14.6 The if Statement。
javascript
const price = 100;
if(price <= 10){
consoel.log("超值")
} else if ( price > 10 && price <= 100 ){
console.log("实惠");
} else {
console.log("贵")
}
协议里面的IfSatement
语法貌似你只能看到 if else , 那 else if 呢?

如果你点开 Statement
, 你会发现其包含 IfSatement
语句, 这不就是嵌套了嘛

上面的 if 语句最后的解析节点大致如下

或者你打开 astexplorer.net/ , 贴入代码,也可以得到类似的结果,只不过属性键有区别。

所以if语句的执行,本质执行起来就有点递归的味道了。 为什么要特别说明这点,为了有些同志去阅读协议的时候,减少一些弯路,也更好的理解 源码 到 解析树的,解析树 到 代码执行的 一些逻辑。

申明和赋值
javascript
var varA = 10;
const constA = 'constA';
function test(){
var varC;
varC = 'varC';
xB = 'xB'
}
test();
代码 | 解析节点 | 中文说明 |
---|---|---|
var varA = 10 | VariableStatement | 声明变量的语句 |
const constA = 'constA' | LexicalDeclaration | 词法申明 |
varA = 'varC' xB = 'xB' | AssignmentExpression | 赋值表达式 |
申明
var varA = 10
大家可能听过这种说法等于: 申明和赋值。
对,也没完全对吧。 如果说对吧,const constA = 'constA';
如何解释呢? , const 常量应该是没法赋值的,因为赋值操作是可以重复的对吧。
对于标志符绑定关系而言, 用 实例化 和 初始化 这两个词更合理,对应协议里面经常出现的两个英文单词
- instantiation 实例化
表示环境记录有对应的标志符了,但是没有值。 - initialization (initialize) 初始化
给实例化的标志符设置绑定的值。
这里的标志符绑定关系的 实例化 要和 全局申明实例化 GlobalDeclarationInstantiation和 函数申明实例化FunctionDeclarationInstantiation等这实例化逻辑过程 区分开来。
全局申明实例化 和 函数申明实例化等这种申明实例化过程 函数申明,变量申明 对应的标志符是既有 实例化也有初始化的。
申明实例化的过程, var 申明变量的语句,排除其他有值的情况,标志符是进行初始化的, 默认 undefined
, 而词法申明的标志符的是仅仅只有实例化,没有初始化的。
全局代码实例化过程中创建 var 绑定关系时,如果全局对象不存在该绑定会,会实例化,并初始化值 undefined。

函数申明实例化 FunctionDeclarationInstantiation过程,比如函数在没有参数表达式的情况,var 对应的标志符会默认初始化值 undefined。

在申明实例化这个过程中, var 申明 和 词法申明的在标志符绑定关系初始化这块,前者是有初始化值,而后者是没有的。 再配合 词法申明一般是存在 申明环境记录中的,而申明环境记录的一个特点就是,如果去获取一个没有初始化值的标志符的绑定值,抛出 ReferenceError,这就是 暂时性死区。
前面提到了,申明实例化过程,const 没有初始化, 那值又是什么时候,怎么初始化的呢?
答案是代码执行的初始化的,是通过 Initializer, Initializer 本身其实也是赋值表达式。
一起看看 const constA = 'constA'
对应的实际解析节点信息, 注意 Initializer 子节点。
json
{
"type": "LexicalDeclaration"
},
"strict": false,
"LetOrConst": "const",
"BindingList": [
{
"type": "LexicalBinding",
"strict": false,
"BindingIdentifier": {
"type": "BindingIdentifier",
"strict": false,
"name": "constA"
},
"Initializer": {
"type": "NumericLiteral",
"strict": false,
"value": 10
}
}
]
}
所以当代码指定到 const constA = 'constA'
时,这里执行的是
Initializer
解析节点逻辑,有了值- 给标志符
constA
绑定值
当然 var , let 申明也是如此。
赋值表达式
赋值表达式那就要复杂不少了。
先看看 varC = 'varC'
对应的解析节点,
开发者眼中的描述:右边的值 赋值给 变量 varC
协议描述的逻辑: AssignmentExpression 节点的执行结果 通过 AssignmentOperator 运算 赋值给 LeftHandSideExpression
再大白话一点, 把右边的表达式的执行结果 赋值 到 左手边的表达式。
json
{
"type": "ExpressionStatement",
"strict": false,
"Expression": {
"type": "AssignmentExpression",
"strict": false,
"LeftHandSideExpression": {
"type": "IdentifierReference",
"strict": false,
"escaped": false,
"name": "varC"
},
"AssignmentOperator": "=",
"AssignmentExpression": {
"type": "StringLiteral",
"strict": false,
"value": "varC"
}
}
}
对比一下varC = 'varC'
和 xB = 'x'
解析点解,几乎一样,但是执行结果却不一样。

javascript
var varA = 10;
const constA = 'constA';
function test(){
var varC;
varC = 'varC';
xB = 'xB'
}
test();
console.log("xB:", xB); // xB: xB
console.log("varC:", varC); // Uncaught ReferenceError: varC is not defined
xB 在 test()
函数调用后依然存在。
来一起分析原因,设置值这种行为,在协议里面是通过 PutValue ( V, W ) 来描述这种行为的。(V是引用记录, W是值)
详细看下面协议描述截图的第二步骤
- 2 当引用不可达的时候
- a 严格模式抛出异常
b 取全局对象
c 在全局对象上设置属性值
- d 返回
本示例,就是如此, xB对应的引用记录,不可达, 又是非严格模式,于是在全局对象上,创建新的属性。

所以,为了安全和职业生涯,严格模式 走起来。
属性赋值
javascript
const obj = {};
obj.a = 'obj.a';
console.log(obj.a);
依旧是看解析节点更好的理解背后的逻辑, obj.a = 'obj.a'
对应的解析节点
json
{
"type": "ExpressionStatement",
"strict": false,
"Expression": {
"type": "AssignmentExpression",
"strict": false,
"LeftHandSideExpression": {
"type": "MemberExpression",
"strict": false,
"MemberExpression": {
"type": "IdentifierReference",
"strict": false,
"escaped": false,
"name": "obj"
},
"IdentifierName": {
"type": "IdentifierName",
"strict": false,
"name": "a"
},
"PrivateIdentifier": null,
"Expression": null
},
"AssignmentOperator": "=",
"AssignmentExpression": {
"type": "StringLiteral",
"strict": false,
"value": "obj.a"
}
}
}
这一看 是不是 和前一小结的varC = 'varC'
对应的解析节点极为类似, 都是
LeftHandSideExpression AssignmentOperator AssignmentExpression
流程也是基本一致的,只不过左边是先获取 对象属性对应的 引用记录
- 左边:先取对象属性 对应的 引用记录
- 右边:计算表达式的值
- 调用 PutValue ( V, W ) 通过引用记录赋值

那么思考一下面的代码
javascript
const obj = Object.create({a:'a'});
console.log(obj.a);
obj.a = 'obj.a';
console.log(obj.a);
块级作用域是如何生效的
javascript
var varA = 10;
{
let letA = 10;
console.log("letA:", letA); // letA: 10
}
console.log("letA:", letA); // Uncaught ReferenceError: letA is not defined
这个就不解释了,详情查看 《作用域链》章节的Block部分,就当做是复习。
函数的length属性
仁兄,接题:
javascript
function funA(paramA, paramB, paramsC) { }
function funB(paramA, paramB, paramsC, ...rest) { }
function funC(paramA, paramB = undefined, paramsC) { }
function funD(paramA, paramB = 1, paramsC, ...rest) { }
const bFun1 = funA.bind(undefined, 1);
const bFun2 = bFun1.bind(undefined, 1);
function printLength(fn){
console.log(`${fn.name}:${fn.length}`);
}
printLength(funA);
printLength(funB);
printLength(funC);
printLength(funD);
printLength(bFun1);
printLength(bFun2);
函数.length 计算原则,详情参见: Static Semantics: ExpectedArgumentCount, 注意这是静态语义分析。
剩余参数
剩余参数,不参与形参长度的计算,详见协议描述

示例代码
javascript
function funB(paramA, paramB, paramsC, ...rest) { }
function printLength(fn){
console.log(`${fn.name}:${fn.length}`);
}
printLength(funB); // 3, rest不参与长度计算
参数默认值
两点:
- 形式参数列表的预期参数数量是指位于剩余参数或第一个带有初始值设定项的形式参数左侧的形式参数数量
- 在第一个带有初始值设定项的参数之后允许出现没有初始值设定项的形式参数,但这样的参数被认为是可选的,其默认值为 undefined。
只计算第一个有默认值左侧的参数个数,右侧的及时没有设置默认,其默认值为undefined.
对应的协议描述如下:

示例
javascript
function funD(paramA, paramB = 1, paramsC, ...rest) { }
function printLength(fn){
console.log(`${fn.name}:${fn.length}`);
}
printLength(funD); // 1 , paramB有默认值,其以及后都不参与长度计算
Function.prototype.bind
bind之后函数的长度会减少,减少bind绑定的参数长度,详情见协议 Function.prototype.bind

javascript
function funA(paramA, paramB, paramsC) { }
const bFun1 = funA.bind(undefined, 1);
const bFun2 = bFun1.bind(undefined, 1);
function printLength(fn){
console.log(`${fn.name}:${fn.length}`);
}
printLength(bFun1); // 2, funA.bind绑定参数一个,这里 3- 1
printLength(bFun2); // 1, bFun1.bind绑定参数一个,这里 2- 1
小节
- 剩余参数不参与形参长度计算
- 形参长度只计算第一个有默认值参数左侧的个数
- bind之后返回的函数的形参长度会减少,减少对应的绑定的参数个数,当然至少为0。多次bind,多次减少。
window.window
先看一下,下面的的等式都等于true。
javascript
window === window.window
window.window === window.window.window
window.window.window === window.window.window.window
为啥要搞这个这个看起来貌似很奇葩的设计。
要解答这个问题,还得请出this,我们经常说浏览器中的全局对象是window, 这句话对了,也还没完全对。
全局对象的真实身份是全局执行上下文查找的 this
绑定关系, 其就是全局环境记录的 [[GlobalThisValue]]
。

window呢是为了方便访问全局对象(全局环境记录的this绑定关系的值),弄出来的一个属性。
javascript
this === window // true
再看一段代码,假设只有this, 没有window属性的时候。
想输出全局对象的aName,怎么输出????
javascript
var aName = "global的name";
function a() {
var aName = "local的name";
// 想输出全局对象的aName???
console.log(????);
}
a();
alert("哈哈")
那就得做外的工作,
javascript
var xxxx = this;
var aName = "global的name";
function a() {
var aName = "local的name";
// 想输出全局对象的aName???
console.log(xxxx.name);
}
a();
alert("哈哈")
于是就可以在全局this上面加一个window属性等于this
javascript
this.window = this;
// 全部对象属性,可以直接访问,不需要带着this
window === this;
这样就可以直接通过window获得this了, 是不是很赞。
我们就可以推导 ,基于 window === this
javascript
this === this.window (this即window,其有window属性) === this.window.window
// 去掉this
window === window.window
当然,现在有globalThis, 来统一访问全局对象。
javascript
globalThis === this === window