【人人都能读标准】除了JS的8种数据类型,标准还定制了哪些额外的类型?
本文为《人人都能读标准》—— ECMAScript篇的第7篇。我在这个仓库中系统地介绍了标准的阅读规则以及使用方式,并深入剖析了标准对JavaScript核心原理的描述。
上一节,我们讲了ECMAScript标准中的算法,而与算法总是形影不离的就是数据结构。
在ECMAScript中,数据类型可以分为两大类:一类称为语言类型(Language Types),也就是我们熟知的JavaScript中的8种数据类型(Undefined、Null、Boolean、String、Symbol、Number、Bigint、Object);另一类称为规范类型(Specification Types),这是标准内部使用的数据类型。
规范类型是比语言类型更加底层的数据类型,主要用来表示和存储标准算法的中间状态,且无法被ECMAScript程序直接访问。
一些经常被人讨论、但却看不见摸不着的抽象概念,在标准中都有具体的规范类型表示,比如:
- 作用域:使用不同类型的环境记录器表示;
- Realm:使用Realm记录器表示;
理解了规范类型,这些概念对于你来说就不再是“看不见、摸不着”,而是实实在在的“就在哪儿”。
标准中主要的规范类型,我用一张图为你总结:
在本节中,我会介绍这里面主要的规范类型。class相关的规范类型会留在15.类中才作介绍。内存相关的规范类型主要与SharedArrayBuffer、Atomics这种内置对象有关系,属于“冷门”知识,这里不会作介绍。
列表(List)
列表类型,顾名思义,是一个含有有序值的列表,你可以理解为标准算法内部使用的“数组”。在标准中,列表使用«»
表示,如«1, 2»。
列表非常常见。比如,我们在6.算法中用来举例的抽象操作OrdinaryToPrimitive就使用了列表类型:
记录器(Record)
记录器(Record)是最常见也是最重要的规范类型,表示字段与值的集合,你可以理解为标准算法内部使用的“对象”。
记录器类型的每一个字段使用[[]]
括住,字段的值可以是规范类型也可以是语言类型。比如以下:
{
[[Field1]]: 42,
[[Field2]]: false,
[[Field3]]: empty
}
实际上,单纯的记录器类型使用并不多;真正在标准中被广泛使用的是各种记录器的衍生类型,比如脚本记录器(Script Record)。标准使用抽象操作ParseScript对普通脚本进行语法解析,得到的结果是一个脚本记录器,这个记录器会在后续代码执行的时候被使用:
这里每一个字段的含义是:
字段名 | 含义 |
---|---|
[[Realm]] | 一个Realm记录器,Realm会在8.执行环境提到。 |
[[ECMAScriptCode]] | 语法解析得到的解析树 |
[[LoadModules]] | 加载的模块 |
[[HostDefined]] | 由宿主环境自定义的内容 |
属性描述符记录器(Property Descriptor Record)
标准使用属性描述符记录器描述对象属性的特点,属性描述符又可以进一步分为数据属性描述符和访问器属性描述符。不同类型的属性描述符,其记录器有不同的字段:
{ // 数据属性描述符
[[Value]]:Language value
[[Writable]]:Boolean
[[Enumerable]]:Boolean
[[Configurable]]:Boolean
}
{ // 访问器属性描述符
[[Get]]:Object/null
[[Set]]:Object/null
[[Enumerable]]:Boolean
[[Configurable]]:Boolean
}
我想你应该对这些字段的名称会很熟悉。在标准关于Object.getOwnPropertyDescriptor(O,P)的算法中,我们可以看到这个Object的静态方法是如何使用属性描述符记录器的:
这个静态方法主要在做两个事情:
- 使用对象O的内部方法[[GetOwnProperty]],获得属性P的属性描述符记录器;
- 通过抽象操作FromPropertyDescriptor把属性描述符记录器(规范类型)转化为对象(语言类型)。
关于对象的内部方法,我会在应用篇13.对象类型中再作介绍。
完成记录器(Completion Record)
完成记录器是非常重要的规范类型,用以表示语句或者算法执行的结果。
完成记录器有3个字段:
字段名 | 值 | 含义 |
---|---|---|
[[Type]] | break 、continue 、return 、throw 、normal 其中一个值 | 语句完成的类型 |
[[Value]] | 除完成记录器以外,任何类型的值。 | 语句执行产生的值 |
[[Target]] | 一个类型为String的值或空值 | 控制跳转位置的标签 |
其中,[[Type]]字段为normal
的完成记录器称为正常完成(normal completion) ,除此以外其他类型的完成记录器都称为硬性完成(abrupt completion) 。
所有语句的求值语义,结果都会返回一个完成记录器,表示语句执行的结果。 一般来说,当语句正常执行的时候,返回的是一个[[Type]]为normal
的完成记录器,即”正常完成“。当语句执行出现错误的时候,返回是一个[[Type]]为throw
的完成记录器,即”硬性完成“的一种。
当然,具体返回什么类型是由语句自身的求值语义决定的。有的语句可以主动创建硬性完成的记录器,如break、continue、return、throw语句。下图为break语句的求值语义:
从这张图你也可以看到,break关键词后面紧跟的“标签标识符(LabelIdentifier)”,可以修改完成记录器上的[[Target]]字段。
有了以上完成记录器的基础知识,我们就可以彻底理解语句的执行过程 —— 语句列表StatmentList的求值语义。我们在6.算法提到过,对普通脚本、函数体、块语句的求值,基于链式产生式的特点,最终都会导向对它们自身语句列表的求值。而StatementList的求值语义看起来非常简单:
在这张图中,我们有两个需要先注意的地方:
-
算法中的问号
?
是一段称为ReturnIfAbrupt的固定逻辑的简写。把这段固定逻辑“铺开”是这样的:- 执行某个子算法;
- 子算法的执行结果必须是一个完成记录器;
- 如果执行结果是硬性完成,则直接返回这个执行结果;
- 否则,使用执行结果中的值(
[[Value]]
字段)继续后续主算法的执行。
在statmentList求值语义中,这意味着,一旦某个语句的执行出现硬性完成,剩余的语句就不会执行了,整个语句列表的执行结果(返回值)就是这个硬性完成。
-
UpdateEmpty是一个比较简单的抽象操作。它的过程我就不在这里列出了,你可以点击链接查看。它的作用是:当语句都是正常完成的时候,语句列表最终返回的数值,为最后一个产生了实际值的语句所产生的值,我知道这有一点绕,但是看个标准提供的例子你就明白了:
// 以下执行结果都为1 eval("1;;;;;") eval("1;{}") eval("1;var a;")
基于以上,我们就可以用自然语言描述出语句列表的求值过程:
- 依次执行语句列表中的语句:
- 当某个语句的执行结果为硬性完成,返回这个硬性完成,终止语句列表的执行;
- 如果所有的语句都是正常完成,那么语句列表的执行结果为正常完成,且完成记录器的[[Value]]字段,为执行过程中产生的最后一个[[Value]]字段不为空的完成记录器,其[[Value]]字段的值。
从这里你可以看出,为什么像break、continue、return、throw这些语句可以提前中断后续语句的执行。
当然,中断后续语句的执行并不意味着终止整个程序的执行,具体还需要看外层是如何处理语句列表返回的硬性完成的。比如,我们从try语句的求值语义,可以看到try语句是如何“吸收”掉try块产生的执行错误,使得程序得以继续执行:
- 执行try块。
- 如果try块的执行结果是一个类型为
throw
的完成记录器,以该完成记录器的[[Value]]字段的值作为参数,调用catch块的CatchClauseEvaluation运行时语义。- ....
在17.错误处理中,我们会对throw完成记录器的捕获过程进行更加深入的探讨。
其他的衍生记录器类型
上面提到的只是一部分的记录器类型。其余的记录器类型我会在涉及它们使用场景的相关章节中再作介绍:
- Realm记录器(Realm Record):8.执行环境
- 环境记录器(Environment Record):9.作用域
- 引用记录器(Reference Record):10.作用域链
- 与class相关的记录器:15.类
Enum
TypeScript中有一个Enums类型,允许开发者定义一系列的常数:
enum Direction {
Up,
Down,
Left,
Right,
}
标准中也有一个Enum类型,作用跟它基本是一样的。
完成记录器[[Type]]字段可能的值为break
、continue
、return
、throw
、normal
,这里的每一个值都是Enum类型。
Enum的名只用于与其他的Enum作区分,不带有特殊的含义。
举一个Enum类型的完整使用例子。静态语义AssignmentTargetType可以判断某个表达式是否可以作为赋值表达式中的被赋值对象使用,如果可以,返回simple
,否则返回invalid
。这两个值都是Enum,如下图所示:
在赋值表达式AssignmentExpression的先验错误中,会通过AssignmentTargetType
检查运算符左边表达式的类型,只有返回结果为simple
的表达式才能通过检查:
因此,以下的表达式语句都是合法的:
// 普通脚本顶层代码且非严格模式下:
yield = 1;
await = 1;
而以下的语句都不能通过先验错误检查:
1 = 1;
this = 1;
a++ = 1;
(a,b) = 1;
a || b = 1;
// ❌ Uncaught SyntaxError: Invalid left-hand side expression
抽象闭包(Abstract Closure)
抽象闭包也是算法中常见的规范类型,它能够把一段逻辑以及这段逻辑需要用到的值封装起来,并在未来执行。你可以把抽象闭包理解为标准算法内部使用的一种闭包函数。
抽象闭包的创建模版一般如下所示:
Let (闭包名称) be a new Abstract Closure with parameters(参数名)that captures(参数值)and performs the following steps when called:
....(闭包的逻辑)
....
promise的链式调用就是通过抽象闭包实现。抽象操作NewPromiseReactionJob会使用抽象闭包对开发者注册的promise任务(handler
)再进行一次封装。
它的关键逻辑就在用红色圈圈出的三步。当handler
到点执行的时候(比如一个宏任务执行完毕,轮到微任务执行了),实际触发的是这个抽象闭包job:
- (步骤e)先执行handler的逻辑
- (步骤h)如果handler的执行结果是硬性完成,则reject promise(这会在后续触发这个promise上,使用catch方法及then方法第二个参数注册的promise任务)。
- (步骤i)如果执行结果是正常完成,则resolve promise(这会在后续触发这个promise上使用then方法第一个参数注册的promise任务)。
基于此,promise得到了链式调用的能力,如下面的代码所示:
Promise.resolve().then(() => { // a. then方法会创建一个新的promise
throw '触发硬性完成' // b. 硬性完成的handler导致新的promise被reject
}).catch(() => console.log("链式调用")) // c. 进而链式触发了注册在新promise上的catch方法
转载自:https://juejin.cn/post/7212079828479688762