伪代码书写规范_ECMAScript 规范文本阅读导引 Part 2

在前文 ECMAScript 规范文本阅读导引 - Part 1 中,我们了解了 ECMAScript 规范文本该如何入手查阅,这次我们将通过一个问题来深入 ECMAScript 规范文本,看看日常工作中我们所使用的 JavaScript 是如何按照规范文本的定义执行的。

基本类型的属性访问

JavaScript 对象上的成员属性广为人知是通过遍历原型链查找的,比如 ({}).hasOwnProperty 中虽然对象字面量上没有定义 hasOwnProperty 成员,但是因为对象字面量的原型默认就是 Object.prototype,所以 Object.prototype 上的 hasOwnProperty 成员也就可以从这个对象字面量上访问到了。那我们经常会在一些基本类型值上也会对其的属性进行访问,那这些属性又是在哪儿定义的呢?难道基本类型也有原型定义吗?

'foobar'.substring(3);
// -> 'bar'

注意:下文中会有许多对 ECMAScript 规范文本的直接引用,截止我们撰文的时间 2020 年 3 月 10 日,在此之后最新版本的 ECMAScript 规范文本可能会有更新,在阅读的时候可以参考最新规范文本阅读。

哪儿定义了成员属性访问的语法?

从成员表达式的文法生成式可以看到,成员表达式有 7 个可能的生成式。成员表达式可以是一个单独的 PrimaryExpression,也可以是一个成员表达式加上一个由方括号包裹的 Expression:MemberExpression [ Expression ],比如 obj['foo']

MemberExpression:
PrimaryExpression
MemberExpression [ Expression ]
MemberExpression . IdentifierName
MemberExpression TemplateLiteral
SuperProperty
MetaProperty
new MemberExpression Arguments

而 'foobar'.substring 即是 MemberExpression . IdentifierName 所表达的文法。回到我们的问题,“基本类型的属性是如何访问的?”,属性访问是发生在运行时的,那么我们可以先来看看这段成员表达式的运行时语义。

了解更多上下文无关文法:https://en.wikipedia.org/wiki/Context-free_grammar

成员表达式的运行时语义

语法的运行时语义定义了这个通过这个语法的定义解析完成后,在运行时是如何表达他的含义的,比如成员表达式的运行时语义中定义了上文中 MemberExpression . IdentifierName 生成式如 foo.bar 这样的表达式在运行时是如何在 foo 上取出他的成员属性 bar 的值。

大多数 ECMAScript 规范中的运行时语义是由一系列算法步骤组成的,不过不像常规的伪代码,会使用更加精确的方式描述操作步骤。

MemberExpression : MemberExpression . IdentifierName
1. Let baseReference be the result of evaluating MemberExpression.
2. Let baseValue be ? GetValue ( baseReference ).
3. If the code matched by this MemberExpression is strict mode code , let strict be true; else let strict be false.
4. Return ? EvaluatePropertyAccessWithIdentifierKey ( baseValue , IdentifierName , strict ).

可以看到操作的第 4 步将更多具体的操作代理给了另外一个抽象操作 EvaluatePropertyAccessWithIdentifierKey

EvaluatePropertyAccessWithIdentifierKey ( baseValue, identifierName, strict )
1. Assert: identifierName is an IdentifierName.
2. Let bv be ? RequireObjectCoercible ( baseValue ).
3. Let propertyNameString be StringValue of identifierName.
4. Return a value of type Reference whose base value component is bv , whose referenced name component is propertyNameString, and whose strict reference flag is strict.

这段算法返回了一个引用类型,并且没有对对象执行任何具体的操作,那么这个属性引用类型是如何转换成具体的值的呢?我们回到我们的例子,可以发现,除了 'foobar'.startsWith 这段属性访问之外,代码中还有一次函数调用:

'foobar'.startsWith('foo');

引用类型常常被用在像 delete,typeof,赋值操作,super 关键字等等特性中。比如赋值操作的左操作 obj.foo = 'bar' 中 obj.foo 就是一个引用类型,只有在最终的赋值操作中,引用才会被真正地具像化。

引用类型包含解析后的名字或者属性绑定。单个引用包含三个部分,引用基底,引用名,与是否是严格引用 flag。引用基底通常会是 undefined,一个对象,布尔值,字符串,Symbol,数字,BigInt,或者是 Environment Record。如果基底是 undefined 代表这个引用无法被解析。引用名会是字符串或者 Symbol,即我们能在 JavaScript 中使用的键类型。

所以我们继续查看以下调用表达式代表的运行时语义。

CallExpression : CoverCallExpressionAndAsyncArrowHead
1. Let expr be CoveredCallExpression of CoverCallExpressionAndAsyncArrowHead.
2. Let memberExpr be the MemberExpression of expr.
3. Let arguments be the Arguments of expr.
4. Let ref be the result of evaluating memberExpr.
5. Let func be ? GetValue(ref).
6. If Type(ref) is Reference, IsPropertyReference(ref) is false, and GetReferencedName(ref) is "eval", then
a. If SameValue(func, %eval%) is true, then
i. Let argList be ? ArgumentListEvaluation of arguments.
ii. If argList has no elements, return undefined.
iii. Let evalArg be the first element of argList.
iv. If the source code matching this CallExpression is strict mode code, let strictCaller be true. Otherwise let strictCaller be false.
v. Let evalRealm be the current Realm Record.
vi. Return ? PerformEval(evalArg, evalRealm, strictCaller, true).
7. Let thisCall be this CallExpression.
8. Let tailCall be IsInTailPosition(thisCall).
9. Return ? EvaluateCall(func, ref, arguments, tailCall).

调用表达式的运行时语义抽象操作中,在第 5 步通过 GetValue 获取 MemberExpression 运行时语义抽象操作返回的引用类型表达的值。

上文中我们提到了“抽象操作”这个词,抽象操作的写法 OperationName(arg1, arg2) 与函数类似,也可以接受一个或多个参数,而他们与普通 JavaScript 函数不同的是,他们不能被在 JavaScript 中直接访问到,只是作为一个书写惯例,便于在 ECMAScript 规范文本中重复利用一系列操作与算法。

Records & Completion Records

在前文的抽象操作中我们会注意到,其中的部分操作前会有 ? 记号,这个记号代表了什么含义?

部分规范中定义的操作就与 ECMAScript 函数一样,需要处理各种控制流不同的表现行为,如通过 throws关键字中断的执行,并附带一个异常值 Error,或者通过 return 关键字中断函数的执行,并返回一个返回值一样,在 ECMAScript 规范中就是通过 Completion Record 类型来表达不同情况与他们附带的值的。

Records 类型是一个只在 ECMAScript 规范中使用的用来表达包含一系列数据的抽象类型,就如同抽象操作一样,不同的 JavaScript 引擎可以有不同的实现来代表 Record 类型。Record 值可以包含一个或多个键值对,这些键值对的值可以是普通 EMCAScript 值或者是其他 ECMAScript 中定义的抽象类型。在规范文本中,通常会使用双方括号的写法 [[Field]] 来代表对 Record 的字段访问。

Completion 类型作为一个具体的 Record 类型,下表就是 Completion Record 定义的键值对。

Field NameValueMeaning
[[Type]]

可以是 normal,break,continue,return,或者 throw 中的一个 

Completion 所代表的类型

[[Value]]

任一 ECMAScript 值 ,或者为空

代表过程中产出的值

[[Target]]

任意 ECMAScript 字符串或者空 

在有目标 label 记号的控制流转移中的 label 记号,比如 break outer_loop,更多可以查阅 MDN break 语句了解更多相关信息

[[Type]] 是 normal 的 Completion Record 就可以叫做 Normal Completion,而除了 Normal Completion 之外的 Completion 类型都可以称为 Abrupt Completion。大部分时候,我们只会碰到 [[Type]] 为 throw 的 Abrupt Completion。其他三个 Abrupt Completion 只会在一些具体的文法元素被执行的时候才会出现。

在 ECMAScript 规范文本定义中,并不会出现类似 JavaScript 代码中的 try-catch 代码块,每一个可能的错误情况(或者是 Abrupt Completion)都需要被显示地处理。而如果没有一些便捷手段来处理这些情况,所有抽象操作中对错误的处理都需要写成以下四个步骤:先获取返回值;再在第二步中判断这个返回的 CompletionRecord 是不是一个 Abrupt Completion,如果是的话,就将这个 Abrupt Completion 作为这次操作的返回值返回;第三步从 CompletionRecord 中获取包裹的返回值;第四部才能开始我们真正的处理。就像下面这段描述一样:

1. Let resultCompletionRecord be AbstractOp().
2. If resultCompletionRecord is an abrupt completion, return resultCompletionRecord.
3. Let result be resultCompletionRecord.[[Value]].
4. result is the result we need. We can now do more things with it.

在 ES2016 以后,规范中就新增了几个简洁的写法,以上同样的文本可以写成下面 3 个步骤,其中第 2 步与第 3 步通过 ReturnIfAbrupt 处理所有的 Abrupt Completion,并自动将 result 的 [[Value]] 解包。

1. Let result be AbstractOp().
2. ReturnIfAbrupt(result).
3. result is the result we need. We can now do more things with it.

更进一步,通过引入 ? 记号,操作的描述就完全不再需要处理 CompletionRecord,而 result 已经是 [[Value]] 解包后的值了。

1. Let result be ? AbstractOp().
2. result is the result we need. We can now do more things with it.

与 ? 记号类似,在 ECMAScript 规范文本中也会出现 ! 记号,这相当于对于这个操作返回值断言返回值必须是 Normal Completion。

1. Let val be ! OperationName().

// 相当于 ⬇️

1. Let val be OperationName().
2. Assert: val is never an abrupt completion.
3. If val is a Completion Record, set val to val.[[Value]].

可以在 ECMAScript 规范中的 ReturnIfAbrupt 简写符号 了解更多相关内容。

对象内部槽位

回到属性访问操作,CallExpression 在运行时需要对一个具体的函数进行函数调用操作,所以在第 5 步通过 GetValue 获取 MemberExpression 返回的引用类型对应的值:

GetValue ( V )
1. ReturnIfAbrupt(V).
2. If Type(V) is not Reference, return V.
3. Let base be GetBase(V).
4. If IsUnresolvableReference(V) is true, throw a ReferenceError exception.
5. If IsPropertyReference(V) is true, then
a. If HasPrimitiveBase(V) is true, then
i. Assert: In this case, base will never be undefined or null.
ii. Set base to ! ToObject(base).
b. Return ? base.[[Get]](GetReferencedName(V), GetThisValue(V)).
6. Else,
a. Assert: base is an Environment Record.
b. Return ? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V)) (see 8.1.1).

对比我们的例子代码可以看到,如果属性引用的基底是一个基本类型值,那么其中的步骤 5.a 就会对其执行 ToObject 抽象操作,因为基本类型实际上不像一个对象,有格子用于存储方法的内部存储,可以对各种操作进行重写,所以需要先将其转换成一个以如 String.prototype 为原型的对象,再对其取属性。ToObject 会根据参数的类型进行不同的操作,如对我们例子中的字符串基本类型,根据定义即是创建一个新的以参数中的字符串为数据源的 String 对象并返回。

ToObject ( argument )

Argument TypeResult
Undefined抛出一个 TypeError 错误。
Null抛出一个 TypeError 错误。
Boolean

返回一个新的 Boolean 对象,这个对象的 [[BooleanData]] 内部槽位设置为 argument 参数值。查阅 19.3 章节可以获取更多关于 Boolean 对象的描述。

Number

返回一个新的 Number 对象,这个对象的 [[NumberData]] 内部槽位设置为 argument 参数值。查阅 20.1 章节可以获取更多关于 Number 对象的描述。

String

返回一个新的 String 对象,这个对象的 [[StringData]] 内部槽位设置为 argument 参数值。查阅 21.1 章节可以获取更多关于 String 对象的描述。

Symbol

返回一个新的 Symbol 对象,这个对象的 [[SymbolData]] 内部槽位设置为 argument 参数值。查阅 19.4 章节可以获取更多关于 Symbol 对象的描述。 

BigInt

返回一个新的 BigInt 对象,这个对象的 [[BigIntData]] 内部槽位设置为 argument 参数值。查阅 20.2 章节可以获取更多关于 BigInt 对象的描述。 

Object返回 argument 参数。

也就是说 GetValue 会对基本类型值转换成对象后,再对这个对象进行成员属性的访问,而对对象的成员属性访问即是 GetValue 步骤 5.b 中可以看到是通过 [[Get]] 这个操作,那么这是一个什么样的操作呢?[[这个记号]] 又代表了什么意思?

上文我们提到,ECMAScript 中访问 Record 类型的某个键值对就是使用 [[这个记号]] 的,除此之外,ECMAScript 中对对象的内部槽位与内部方法的访问也是通过类似的记号,到底是哪一种取决于使用的上下文中记号出现的位置,不过可以确定的是,通过 [[这个记号]] 访问属性是我们在 JavaScript 中都无法访问、观察到的属性。

在 ECMAScript 中,每一个 Object 都有一系列的内部方法,这些方法经常会在 ECMAScript 中定义的其他各种抽象操作中被调用。常见的有如:

  • [[Get]],用来获取对象上的一个成员属性(如 obj.prop);

  • [[Set]],用来给对象上的一个成员属性赋值(如 obj.prop = 42);

  • [[GetPrototypeOf]],用来获取对象的原型(如 Object.getPrototypeOf(obj));

  • [[GetOwnProperty]],用来获取对象的自有属性的属性描述符(如 getOwnPropertyDescriptor(obj, "prop"));

  • [[Delete]],用来删除对象上的一个属性(如 delete obj.prop)。

而函数就是一些有额外的 [[Call]] 内部方法的对象(还可以有 [[Construct]] 内部方法),因此函数也可以称为可调用的对象。

除了这些内部方法,JavaScript 对象还有很多内部槽位,这些槽位就是 ECMAScript 规范中用来存储对象的数据的地方。比如大多数对象都有的 [[Prototype]],值得注意的是我们刚提到了 [[GetPrototypeOf]]这个内部方法,那这两个有什么区别?大多数对象有 [[Prototype]] 内部槽位,但所有对象都会实现 [[GetPrototypeOf]] 内部方法。比如 Proxy 对象并没有他们自己的 [[Prototype]] 内部槽位,但是他们实现了 [[GetPrototypeOf]] 内部方法,这个内部方法会将调用代理给注册的 handler 或者代理对象的 [[GetPrototypeOf]]

可以在 9.1 章节 Ordinary Object Internal Methods and Internal Slots 了解更多详细的 Object 内部方法。

可以在 9.5 章节 Proxy Object Internal Methods and Internal Slots 了解更多关于 Proxy 外部对象的内部方法。

另外,ECMAScript 还将所有的对象分为两个类型,分别是普通对象和外部对象。大多数我们使用的对象都是普通对象,这意味着这些对象的内部方法都是在 9.1 章节 Ordinary Object Internal Methods and Internal Slots 中定义的默认方法。除此之外,我们还使用了非常多种类型的外部对象,这些对象会重新定义许多普通对象默认的内部方法,比如我们对 Array 类型使用下标赋值时 arr[1] = 123 或者 arr.length = 100,就会使用到 Array 外部对象类型重新定义的 [[DefineOwnProperty]] 对这个对象产生额外的操作,如数组扩缩容等。

可以在 9.4.2 章节 Array Exotic Objects 了解更多关于 Array 外部对象的内部方法。

我们可以通过下图更好地了解这些对象的关系。

9f71914f64974f830cf8b2505dbe7b35.png

图片来源 https://timothygu.me/es-howto

回到 GetValueGetValue 在将基础类型转换成普通对象后,在步骤 5.b 中通过调用对象的 [[Get]] 内部方法来获取对象的属性值:

`[[Get]]` ( P, Receiver )
1. Return ? OrdinaryGet(O, P, Receiver).

可以看到普通对象的 [[Get]] 操作将具体的内容代理给了 OrdinaryGet 抽象操作来处理。而通过 OrdinaryGet 抽象操作我们就可以不断地遍历对象与他的原型链上的所有原型对象的属性,直到找到期望的属性为止(步骤 3,如果没有找到属性,就继续调用原型的 [[Get]] 方法)。

OrdinaryGet ( O, P, Receiver )
1. Assert: IsPropertyKey(P) is true.
2. Let desc be ? O.[[GetOwnProperty]](P).
3. If desc is undefined, then
a. Let parent be ? O.[[GetPrototypeOf]]().
b. If parent is null, return undefined.
c. Return ? parent.[[Get]](P, Receiver).
4. If IsDataDescriptor(desc) is true, return desc.[[Value]].
5. Assert: IsAccessorDescriptor(desc) is true.
6. Let getter be desc.[[Get]].
7. If getter is undefined, return undefined.
8. Return ? Call(getter, Receiver).

其中,Property Descriptor 类型 也是一个 Record 类型,在 JavaScript 里我们通常使用对象字面量表示,如 Object.defineProperty(obj, 'foo', { enumerable: true, configurable: false, value: 'bar' })

const it = 'foobar';
'foobar'.substring(3);
// -> 'bar'

到此为止,我们就可以总结出,'foobar' 在属性 substring 的访问过程中被转换成了一个 String 对象,然后通过 String.prototype 获取到 String.prototype.substring,最后通过以过程中获得的 String 对象为 receiver,3 为参数调用这个函数我们就可以得到刚开始例子中的 "bar" 了。

String.prototype.substring

在获取到了 String.prototype.substring 这个函数后,如果我们对其调用并使用 undefined 作为 receiver(函数调用的 this 值)会发生什么?

String.prototype.substring.call(undefined, 2, 4)

我们根据以往 JavaScript 的使用经验,推测大概有两种可能:

  • String.prototype.substring() 将 undefined 转换成字符串类型 "undefined",然后取这个字符串的索引为 2 的字符到索引为 4 的字符(即索引为 [2, 4) 的范围),即最后结果为 "de"。

  • String.prototype.substring() 抛出一个错误,拒绝以 undefined 作为 Receiver 输入。

遗憾的是在 MDN 上并没有对此有详细的说明,如果各位看官有兴趣可以翻阅一下 ECMAScript 21.1.3.22 小节中对此的定义,了解一下最后会是哪一个结果。

更多链接

  1. TC39, ECMAScript® 2020 Language Specification - Draft, March 10, 2020, https://tc39.es/ecma262

  2. Timothy Gu, How to Read the ECMAScript Specification, https://timothygu.me/es-howto

  3. Marja Hölttä, Understanding the ECMAScript spec, part 1, https://v8.dev/blog/understanding-ecmascript-part-1

  4. Marja Hölttä, Understanding the ECMAScript spec, part 2, https://v8.dev/blog/understanding-ecmascript-part-2

36fa95595e49dde3771aa26a353558ea.png

be11a8adaa5c9d6452e3edad926af062.png

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值