logo

理解 ECMAScript 规范

02:49

阅读次数: 0

tags: blog

Part1

原文: https://v8.dev/blog/understanding-ecmascript-part-1

即使你了解JavaScript,阅读其语言规范,ECMAScript语言规范或简称为ECMAScript规范,也可能会感到相当艰巨。至少在我第一次阅读它时,就有这种感觉。

让我们从一个具体的例子开始,透过规范来理解它。以下的代码展示了Object.prototype.hasOwnProperty的使用:

const o = { foo: 1 };
o.hasOwnProperty('foo'); // true
o.hasOwnProperty('bar'); // false

在这个例子中,o 没有一个叫做hasOwnProperty的属性,所以我们沿着原型链向上寻找它。我们在o的原型,也就是Object.prototype中找到了它。

为了描述Object.prototype.hasOwnProperty是如何工作的,规范使用了类似于伪代码的描述:

Object.prototype.hasOwnProperty(V)

hasOwnProperty方法被调用并带有参数V时,将执行以下步骤:

  1. Let P be ? ToPropertyKey(V).
  2. Let O be ? ToObject(this value).
  3. Return ? HasOwnProperty(O, P).

还有。。。

抽象操作HasOwnProperty用于确定一个对象是否具有指定的属性键的自身属性。它将返回一个布尔值。此操作是带有参数OP的调用,其中O是对象,P是属性键。这个抽象操作执行以下步骤:

  1. Assert: Type(O) is Object.
  2. Assert: IsPropertyKey(P) is true.
  3. Let desc be ? O.[[GetOwnProperty]](P).
  4. If desc is undefined, return false.
  5. Return true.

但是,什么是“抽象操作”? [[ ]]内部的东西是什么?为什么在函数前面有一个?符号?断言意味着什么呢?

让我们去寻找答案!

语言类型和规范类型

让我们从一些看起来熟悉的东西开始。规范使用了像undefined, true和false这样我们已经从JavaScript中了解的值。它们都是语言值,也是规范定义的语言类型的值。

规范也在内部使用语言值,例如,一个内部数据类型可能包含一个可能的值是true和false的字段。相比之下,JavaScript引擎通常不在内部使用语言值。例如,如果JavaScript引擎是用C++编写的,那么它通常会使用C++的true和false(而不是它对JavaScript的true和false的内部表示)。

除了语言类型,规范还使用规范类型,这些类型仅出现在规范中,而不是在JavaScript语言中。JavaScript引擎不需要(而且可以自由地)实现它们。在这篇博客文章中,我们将了解规范类型Record(及其子类型Completion Record)。

抽象操作

抽象操作是在ECMAScript规范中定义的函数;它们的定义目的是为了简洁地编写规范。JavaScript引擎不必在引擎内部将它们实现为单独的函数。它们不能直接从JavaScript调用。

内部插槽和内部方法

内部插槽和内部方法使用 [[ ]] 包围。

内部插槽是一个JavaScript对象或规范类型的数据成员。它们用于存储对象的状态。内部方法是JavaScript对象的成员函数。

例如,每个JavaScript对象都有一个内部揽槽[[Prototype]]和一个内部方法[[GetOwnProperty]]。

内部插槽和方法不可从JavaScript访问。例如,你不能访问o.[[Prototype]]或调用o.[[GetOwnProperty]]。JavaScript引擎可以实现它们以供自己内部使用,但不一定要这样做。

(译者:这里还记得抽象操作和内部方法的区别吗?)

有时,内部方法会委派给同名的抽象操作,例如在普通对象的[[GetOwnProperty]]中:

[ [ GetOwnProperty ] ] (P)
当用属性键P调用O的[[GetOwnProperty]]内部方法时,将执行以下步骤:
返回 ! OrdinaryGetOwnProperty(O, P)。 

(我们将在下一章中找出感叹号的含义。)

OrdinaryGetOwnProperty不是一个内部方法,因为它没有与任何对象相关联;相反,它操作的对象被作为一个参数传递。

OrdinaryGetOwnProperty被称为“ordinary”,因为它操作的是普通对象。ECMAScript对象可以是普通类型或者奇特类型。普通对象必须对一组被称为基本内部方法的方法具有默认行为。如果一个对象偏离了默认行为,那么它就是奇特的。

最知名的奇特对象是数组,因为它的length属性的行为方式不是默认的:设置length属性可以从数组中移除元素。

基本内部方法在这里.

完成记录

那么问号和感叹号是什么呢?为了理解它们,我们需要研究一下完成记录!

完成记录是一个规范类型(仅为规范目的定义)。JavaScript引擎不需要有一个相应的内部数据类型。

完成记录是一个“记录”——一种具有固定的命名字段集的数据类型。完成记录有三个字段:

名字描述
[[Type]]normalbreakcontinuereturnthrow。除了normal以外的所有类型都是突然完成
[[Value]]完成发生时产生的值,例如,函数的返回值或异常(如果有的话)。
[[Target]]用于定向控制转移(与本文无关)。

每个抽象操作都隐式返回一个完成记录。即使看起来抽象操作会返回一个简单类型,例如Boolean,它也会被隐式地包装成一个带有normal类型的完成记录(参见隐式完成值)。

注意1:在这方面,规范并不完全一致;有一些辅助函数返回裸值,其返回值被直接使用,而不是从完成记录中提取值。这通常可以从上下文中清楚地看到。

注意2:规范编辑者 正在研究如何更明确地处理完成记录。

如果一个算法抛出一个异常,这意味着返回一个带有[[Type]] throw和[[Value]]是异常对象的完成记录。我们现在暂时忽略break、continue和return类型。

ReturnIfAbrupt(argument)意味着执行以下步骤:

  1. 如果argument是突然的,那么返回argument
  2. 将argument设置为argument.[[Value]]。

也就是说,我们会检查一个完成记录;如果它是一个突然完成的记录,我们会立即返回。否则,我们会从完成记录中提取值。

ReturnIfAbrupt可能看起来像函数调用,但事实并非如此。它会导致出现ReturnIfAbrupt()的函数返回,并非ReturnIfAbrupt函数本身返回。它的行为更像C-like语言中的宏。

ReturnIfAbrupt可以这样使用:

  1. 让 obj 成为 Foo() 。(obj是一个完成记录。)
  2. ReturnIfAbrupt(obj)。
  3. Bar(obj)。 (如果我们还在这里,那么obj就是从完成记录中提取出的值。)

现在问号起作用了:? Foo()相当于ReturnIfAbrupt(Foo())。使用简写是非常实用的:我们不需要每次都明确写出错误处理代码。

类似地,Let val be ! Foo()相当于:

  1. 让 val 成为 Foo()。
  2. 断言:val 不是一个突然的完成。
  3. 将val设置为val.[[Value]]。

利用这些知识,我们可以像这样重写Object.prototype.hasOwnProperty:

  Object.prototype.hasOwnProperty(V)

1.Let P be ToPropertyKey(V).
2.If P is an abrupt completion, return P
3.Set P to P.[[Value]]
4.Let O be ToObject(this value).
5.If O is an abrupt completion, return O
6.Set O to O.[[Value]]
7.Let temp be HasOwnProperty(O, P).
8.If temp is an abrupt completion, return temp
9.Let temp be temp.[[Value]]
10.Return NormalCompletion(temp)

…我们也可以像这样重写 HasOwnProperty :

  HasOwnProperty(O, P)

1.Assert: Type(O) is Object.
2.Assert: IsPropertyKey(P) is true.
3.Let desc be O.[[GetOwnProperty]](P).
4.If desc is an abrupt completion, return desc
5.Set desc to desc.[[Value]]
6.If desc is undefined, return NormalCompletion(false).
7.Return NormalCompletion(true).

我们也可以在没有感叹号的情况下重写[[GetOwnProperty]]内部方法:

  O.[[GetOwnProperty]]

1.Let temp be OrdinaryGetOwnProperty(O, P).
2.Assert: temp is not an abrupt completion.
3.Let temp be temp.[[Value]].
4.Return NormalCompletion(temp).

在这里我们假设temp是一个全新的临时变量,不会与其他任何东西相冲突。

我们还获得了这样的知识,当一个return语句返回的东西不是一个Completion Record时,它会被隐式地包装在NormalCompletion之内。

另外:Return?Foo()

规范使用了Return?Foo()这种符号——为什么使用问号?

Return ? Foo()展开为:

1. Let temp be Foo().
2. If temp is an abrupt completion, return temp.
3. Set temp to temp.[[Value]].
4. Return NormalCompletion(temp).

这与Return Foo()是相同的;对于突然的和正常的完成,它的行为方式是一样的。

只有出于编程方式上原因,才使用Return?Foo(),以使其更明确地表示Foo返回一个完成记录。

断言

规范中的断言断定了算法的不变条件。它们是为了清晰逻辑而添加的,并不增加对实现的任何要求——实现不需要检查它们。

继续

抽象操作委托给其他抽象操作(参见下图),但是基于此博客文章,我们应该能够明确它们的工作原理。我们会遇到属性描述符,这只是另一种规范类型。

 Object.prototype.hasOwnProperty 的函数调用图

总结

我们阅读了一个简单的方法——Object.prototype.hasOwnProperty——和它调用的抽象操作。熟悉了与错误处理相关的简写?和!。我们还了解了语言类型、规范类型、内部槽和内部方法。

Part2

原文: https://v8.dev/blog/understanding-ecmascript-part-2

了解规范的一个有趣方法是从我们已知的 JavaScript 功能开始,并找出它是如何指定的。

我们知道属性是在原型链中查找的:如果一个对象没有我们要读取的属性,我们就会沿着原型链向上查找,直到找到它(或找到不再具有原型的对象) 。

const o1 = { foo: 99 };
const o2 = {};
Object.setPrototypeOf(o2, o1);
o2.foo;// → 99

原型遍历是在哪里定义的?

让我们尝试找出这个行为是在哪里定义的。 一个好的起点是对象内部方法列表。

[[GetOwnProperty]][[Get]] - 我们对不限于对象本身的属性的遍历感兴趣,所以我们将选择 [[Get]]

不幸的是,属性描述符规范类型还有一个名为[[Get]] 的字段,因此在浏览 [[Get]] 规范时,我们需要仔细区分这两种独立的用法。

[[Get]]是一个重要的内部方法。 普通对象实现基本内部方法的默认行为。 异构对象可以定义它们自己的内部方法[[Get]],这与默认行为有所偏差。在这篇文章中,我们专注于普通对象。

[[Get]]的默认实现委托给OrdinaryGet

`[[Get]](P,Receiver)`
当调用O的[[Get]]内部方法,参数为属性键P和ECMAScript语言值Receiver时,执行以下步骤:
返回?OrdinaryGet(O,P,Receiver)。 

接下来,我们将看到Receiver是调用访问器属性的getter函数时用作this值的值。

OrdinaryGet定义如下:

OrdinaryGet(O,P,Receiver)
当调用抽象操作`OrdinaryGet`时,参数为对象O,属性键P和ECMAScript语言值Receiver时,执行以下步骤:
1. 断言:IsPropertyKey(P)为true。 
2. 让desc为?O.[[GetOwnProperty]](P)。 
3. 如果desc为undefined,则 
	a. 让parent为? O.[[GetPrototypeOf]]()。 
	b. 如果parent为null,则返回undefined。 
	c. 返回?parent.[[Get]](P, Receiver)。 
4. 如果IsDataDescriptor(desc)为true,则返回desc.[[Value]]。
5. 断言:IsAccessorDescriptor(desc)为true。 
6. 让getter为desc.[[Get]]。
7. 如果getter为undefined,则返回undefined。 
8. 返回?Call(getter,Receiver)。 

原型链遍历在第3步内:如果我们在自身属性中找不到属性,我们调用原型的[[Get]]方法,它再次委托给OrdinaryGet。如果我们仍然找不到属性,我们调用其原型的[[Get]]方法,它再次委托给OrdinaryGet,依此类推,直到我们找到属性或到达没有原型的对象。

让我们看看当我们访问o2.foo时,这个算法是如何工作的。首先,我们使用O为o2,P为"foo"调用OrdinaryGet。由于o2没有名为"foo"的自身属性,因此O.[[GetOwnProperty]]("foo")返回undefined,因此我们进入步骤3的if分支。在步骤3.a中,我们将parent设置为o2的原型,即o1。由于parent不为null,因此我们在步骤3.b中不返回。在步骤3.c中,我们使用属性键"foo"调用parent的[[Get]]方法,并返回它返回的任何内容。

父对象(o1)是普通对象,因此它的[[Get]]方法再次调用OrdinaryGet,这次使用O为o1,P为"foo"。o1有一个名为"foo"的自身属性,因此在步骤2中,O.[[GetOwnProperty]]("foo") 返回关联的属性描述符,并将其存储在desc中。

属性描述符是一种规范类型。数据属性描述符直接将属性的值存储在[[Value]]字段中。访问器属性描述符在[[Get]]和/或[[Set]]字段中存储访问器函数。在这种情况下,与"foo"关联的属性描述符是数据属性描述符。

在步骤2中我们存储在desc中的数据属性描述符不是undefined,因此我们不进入步骤3的if分支。接下来,我们执行步骤4。由于属性描述符是数据属性描述符,因此我们在步骤4中返回其[[Value]]字段,即99,随后完成了。

Receiver是什么,它从哪里来?

在访问器属性的情况下,Receiver参数仅在步骤8中使用。在调用访问器属性的getter函数时,它作为this值传递。

OrdinaryGet在整个递归过程中始终将原始Receiver传递,未更改(步骤3.c)。让我们找出Receiver最初来自哪里!

搜索[[Get]]被调用的地方,我们找到了一个名为GetValue的抽象操作,它操作引用(References)。引用是一种规范类型,由基值(base value)、引用的名称(referenced name)和严格引用标志(strict reference flag)组成。在o2.foo的情况下,基值是对象o2,引用的名称是字符串"foo",严格引用标志是false,因为示例代码是松散的。

另一个话题:为什么Reference不是Record?

另一个话题:Reference不是Record,尽管听起来它可能是。它包含三个组件,这三个组件同样可以表达为三个命名字段。Reference不是Record只是出于历史原因。

回到GetValue

让我们看看如何定义GetValue:

GetValue(V)
1. ReturnIfAbrupt(V)。 
2. 如果Type(V) 不是Reference,则返回V。 
3. 让base为GetBase(V)。 
4. 如果IsUnresolvableReference(V)为true,则抛出ReferenceError异常。 
5. 如果IsPropertyReference(V)为true,则 
	a. 如果HasPrimitiveBase(V)为true,则 
		1. 断言:在这种情况下,base永远不会是undefined或null。 
		2. 将base设置为!ToObject(base)。 
	b. 返回`? base.[[Get]](GetReferencedName(V), GetThisValue(V))`。
6. 否则, 
	1. 断言:base是环境记录。 
	2. 返回`? base.GetBindingValue(GetReferencedName(V), IsStrictReference(V))`

我们示例中的Reference是o2.foo,这是一个属性引用。因此,我们进入第5步。在步骤5.a中,我们不进入分支,因为基值(o2)不是原始值(Number、String、Symbol、BigInt、Boolean、Undefined或Null)。

然后我们在步骤5.b中调用[[Get]]。我们传递的Receiver是GetThisValue(V)。在这种情况下,它只是引用的基值:

GetThisValue(V)

1. 断言:IsPropertyReference(V) 为true。 
2. 如果IsSuperReference(V) 为true,则 
	1. 返回引用V的thisValue组件的值。 
3. 返回GetBase(V)。 

对于o2.foo,我们不进入步骤2的分支,因为它不是Super Reference(例如super.foo),但我们进行步骤3并返回Reference的基值,即o2。

将所有内容组合在一起,我们发现将Receiver设置为原始Reference的基值,然后在原型链遍历期间保持它不变。最后,如果找到的属性是访问器属性,我们在调用它时将Receiver用作this值。

特别是在getter中,this值指的是我们尝试获取属性的原始对象,而不是在原型链遍历期间找到属性的对象。

让我们尝试一下!

const o1 = { x: 10, get foo() { return this.x; } };
const o2 = { x: 50 };
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 50

在这个例子中,我们有一个名为foo的访问器属性,并为其定义了一个getter。getter返回this.x。

然后我们访问o2.foo - getter会返回什么?

我们发现当我们调用getter时,this值是我们最初尝试获取属性的对象,而不是找到属性时的对象。在这种情况下,this值是o2,而不是o1。我们可以通过检查getter是否返回o2.x或o1.x来验证这一点,确实,它返回o2.x。

它起作用了!我们能够根据我们在规范中阅读到的内容来预测这段代码片段的行为。

访问属性 — 为什么调用[[Get]]

规范在哪里说当访问属性(例如o2.foo)时会调用Object内部方法[[Get]]?当然,这肯定是定义在某个地方的。别轻信我的话!

我们发现Object内部方法[[Get]]是从在引用上操作的抽象操作GetValue中调用的。但GetValue从何处调用?

MemberExpression的运行时语义

规范的语法规则定义了语言的语法结构。运行时语义定义了这些语法结构的“含义”(在运行时如何评估它们)。

如果您对无上下文文法不熟悉,现在是了解一下的好时机!

我们将在以后的篇章中深入研究语法规则,现在让我们保持简单!特别是,在这一集中,我们可以忽略语法结构的下标(Yield、Await等)。

以下产生描述了MemberExpression的外观:

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

这里有7个MemberExpression的产生。MemberExpression可以只是一个PrimaryExpression。或者,可以通过将另一个MemberExpression和Expression组合在一起来构造MemberExpression:MemberExpression [ Expression ],例如o2['foo']。或者它可以是MemberExpression . IdentifierName,例如o2.foo — 这是我们例子中相关的产生。

生产MemberExpression : MemberExpression . IdentifierName的运行时语义定义了评估时要采取的步骤集:

运行时语义:对于MemberExpression : MemberExpression . IdentifierName的评估

1. 让baseReference为评估MemberExpression的结果。 
2. 让baseValue为? GetValue(baseReference)。 
3. 如果由此MemberExpression匹配的代码是严格模式代码,则让strict为true;否则,让strict为false。 
4. 返回?EvaluatePropertyAccessWithIdentifierKey(baseValue,IdentifierName,strict)。 

该算法委托给抽象操作EvaluatePropertyAccessWithIdentifierKey,因此我们也需要阅读它:

EvaluatePropertyAccessWithIdentifierKey(baseValue,identifierName,strict)

抽象操作EvaluatePropertyAccessWithIdentifierKey的参数是值baseValue、一个解析节点identifierName和一个布尔参数strict。它执行以下步骤:
1. 断言:identifierName是一个IdentifierName 
2. 让bv为`? RequireObjectCoercible(baseValue)`。 
3. 让propertyNameString为identifierName的StringValue。 
4. 返回一种Reference类型的值,其基值组件为bv,引用的名称组件为propertyNameString,且严格引用标志为strict。 

也就是说,EvaluatePropertyAccessWithIdentifierKey构造一个Reference,它使用提供的baseValue作为基值,identifierName的字符串值作为属性名,strict作为严格模式标志。

最终,这个Reference传递给GetValue。这在规范的几个地方定义,取决于Reference最终如何被使用。

MemberExpression作为参数

在我们的例子中,我们将属性访问用作参数:

console.log(o2.foo);

在这种情况下,行为定义在ArgumentList产生的运行时语义中,该语法规则在参数上调用GetValue:

运行时语义:ArgumentListEvaluation

ArgumentList:AssignmentExpression

1. 让ref为评估AssignmentExpression的结果。
2. 让arg为`? GetValue(ref)`。 
3. 返回一个仅包含arg的列表。 

o2.foo看起来不像AssignmentExpression,但它是一个,所以这个产生是适用的。要找出原因,您可以查看此额外内容,但在这一点上,这并不绝对必要。

步骤1中的AssignmentExpression是o2.foo。步骤1的ref,即评估o2.foo的结果,是上述提到的Reference。在步骤2中,我们对其调用GetValue。因此,我们知道Object内部方法[[Get]]将被调用,原型链遍历将发生。

总结

在本集中,我们看到规范如何跨所有不同的层面定义语言特性,本例中是原型查找:触发该特性的语法结构以及定义它的算法。

读后感

理解 ECMAScript 规范 这一合集,第一章主要讲了规范中的一些基础语法,注意,这里是规范的语法,

类似于 ! 和 ?,问号比较常用,它的作用类似于解除包装后的值,这一语法挺像rust 的 Some去匹配Option。

介绍完基本语法,第二章讲了非常实用的一个js操作,就是取值。

obj.foo 这个操作经历了什么,文章中是从底向上描述的。

如果是从顶向底描述,则是,obj.foo 这一个字符串先被词法分析器解析,解析之后,获得一个Reference对象,这个对象中包含了基值 obj 和键值 foo,之后就会传给GetValue

GetValue是一个规范内部的方法,接受一个参数V,这个参数的类型是一个Reference,Reference中就包含了基值和键值,读这个方法,可以了解js的一些基本执行原理,下面是我自己对这个方法的描述;

如果V不是Reference类型,就直接返回V,
获取Reference中的Base,即基值,然后判断基值是否为原始类型,就是number,string这些,如果是的话,就得把这些类型转为包装类型再调用其方法。如果不是原始类型,就调用`[[Get]]`方法,这个方法需要传入V的键值名字,还有一个Receiver,
Receiver是通过GetThisValue(V)获取的,这里的GetThisValue,其实相当于获取V的Base,基值。

[[Get]] 本身也是一个方法,

[[Get]](P,Receiver)里面直接调用了?OrdinaryGet(O,P,Receiver),所以,直接看看OrdinaryGet的实现

OrdinaryGet 中详细定义了取值的规范。

下面我就把重要的过程描述一下:

取obj中的foo的值的时候,首先看对象本身有没有这个键值,如果没有,就调用GetPrototypeOf得到对象的原型,然后再调用原型的`[[Get]](P,Receiver)`,这里就会产生递归,就会沿着原型链递归的查找键值。

那如果对象本身有这个键值呢?那就直接返回就好了,这里要判断一下,是能直接取到值,还是被getter函数包装了,如果被getter函数包装了,就再调用一下getter函数,然后返回值。这里规范内部调用getter的时候,还需要传Receiver

上面的过程,可以明显看出,整个调用过程的Receiver就没变过!

还可以注意到,Receiver一直传,一直传,传到了哪里?有什么用?

用一个例子来解释下。

看下面的代码:

const o1 = { x: 10, get foo() { return this.x; } };
const o2 = { x: 50 };
Object.setPrototypeOf(o2, o1);
o2.foo;
// → 50

调用o2.foo的时候,就找到了一个o1的getter函数,这里重要了,因为调用的是getter,所以调用getter方法,

getter方法传入的Receiver就是o2,进入到函数中,再使用this时,这里的this就是指的Receiver o2,因为上面所说,Receiver就没变过。

所以,直接得出 o2.foo的值就是50,而不是o1中的10。

从这个例子可以看出,规范中的一些定义,可以帮我们理清一些js怪异的行为,还是比较有帮助的。

怎么去阅读规范,那就是直接打开网页 https://tc39.es/ecma262/

logo