在前文 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 规范文本中重复利用一系列操作与算法。
在前文的抽象操作中我们会注意到,其中的部分操作前会有 ?
记号,这个记号代表了什么含义?
部分规范中定义的操作就与 ECMAScript 函数一样,需要处理各种控制流不同的表现行为,如通过 throws
关键字中断的执行,并附带一个异常值 Error
,或者通过 return
关键字中断函数的执行,并返回一个返回值一样,在 ECMAScript 规范中就是通过 Completion Record 类型来表达不同情况与他们附带的值的。
Records 类型是一个只在 ECMAScript 规范中使用的用来表达包含一系列数据的抽象类型,就如同抽象操作一样,不同的 JavaScript 引擎可以有不同的实现来代表 Record 类型。Record 值可以包含一个或多个键值对,这些键值对的值可以是普通 EMCAScript 值或者是其他 ECMAScript 中定义的抽象类型。在规范文本中,通常会使用双方括号的写法 [[Field]]
来代表对 Record 的字段访问。
Completion 类型作为一个具体的 Record 类型,下表就是 Completion Record 定义的键值对。
Field Name: [[Type]] Value:可以是 normal,break,continue,return,或者 throw 中的一个 Meaning:Completion 所代表的类型
Field Name:[[Value]] Value:任一 ECMAScript 值 ,或者为空 Meaning:代表过程中产出的值
Field Name:[[Target]] Value:任意 ECMAScript 字符串或者空 Meaning:在有目标 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 Type:Undefined Result:抛出一个 TypeError 错误。
Argument Type:Null
Result:抛出一个 TypeError 错误。
Argument Type:Boolean Result:返回一个新的 Boolean 对象,这个对象的 [[BooleanData]] 内部槽位设置为 argument 参数值。查阅 19.3 章节可以获取更多关于 Boolean 对象的描述。
Argument Type:Number Result:返回一个新的 Number 对象,这个对象的 [[NumberData]] 内部槽位设置为 argument 参数值。查阅 20.1 章节可以获取更多关于 Number 对象的描述。
Argument Type:String Result:返回一个新的 String 对象,这个对象的 [[StringData]] 内部槽位设置为 argument 参数值。查阅 21.1 章节可以获取更多关于 String 对象的描述。
Argument Type:Symbol Result:返回一个新的 Symbol 对象,这个对象的 [[SymbolData]] 内部槽位设置为 argument 参数值。查阅 19.4 章节可以获取更多关于 Symbol 对象的描述。
Argument Type:BigInt Result:返回一个新的 BigInt 对象,这个对象的 [[BigIntData]] 内部槽位设置为 argument 参数值。查阅 20.2 章节可以获取更多关于 BigInt 对象的描述。
Argument Type:Object Result:返回 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 外部对象的内部方法。
我们可以通过下图更好地了解这些对象的关系。
图片来源 https://timothygu.me/es-howto图片来源 https://timothygu.me/es-howto
回到 GetValue
,GetValue
在将基础类型转换成普通对象后,在步骤 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
这个函数后,如果我们对其调用并使用 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 小节中对此的定义,了解一下最后会是哪一个结果。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8