从 ECMAScript 认识 JS(章二):跟原型链比划比划
从 ECMAScript 语言规范和浏览器引擎的视角认识 JavaScript
强烈建议按照文章顺序阅读,点击 🔼 上方专栏可以查看所有文章。
前言
大家好,我是早晚会起风。在上一章中,我们讲了一大堆基础概念,它们是理解 ECMAScript 规范必不可少的部分。在阅读本篇文章之前,强烈建议读者先阅读上一章。
这篇文章,我们来点实际的,从规范的角度精确理解 JavaScript 原型链。对于原型链,已经有很多经典书籍以及数不胜数的文章来解释了。但绝大部分解释都是在进行总结归纳,在理解过程中我们总会产生一些疑问,比如 JavaScript 内部对象属性的查找规则真的和总结归纳的一致吗,还是这只是一种用于理解原型链的心智模型?原型链就像一个黑盒,我们始终看不到在它内部发生的事情。
今天,我们就来从根本上解决这个问题。文章中会涉及到规范中的一些专有词汇,为了表达的准确性,我可能会使用英文词汇而不是中文词汇,不用感到畏惧,它们理解起来都很简单。
一个例子
首先我们先举一个例子,来看看我们是怎样查找一个对象的属性。
const foo = { name1: 'grace' }
const bar = { name2: 'walk' }
Object.setPrototypeOf(foo, bar)
foo.name2 // walk
这里我们定义了对象 foo
,并将它的原型设置为另一个对象 bar
。这样我们就可以通过 foo.name2
来拿到 bar 对象上 name2 的值。
在我们的理解中,当查找对象上的属性时,JavaScript 会沿着原型链一直向上查找,直到找到对应的属性,或者一直到原型链顶端(null)也没找到该属性,返回 undefined。
那么 JavaScript 真是这样做的吗?在这个过程中发生了什么?
对象属性查找过程
essential internal method —— [[Get]]
对象上查找属性的方法在规范中定义在 essential internal methods 中,规范定义如下,
the essential internal methods used by this specification that are applicable to all objects created or manipulated by ECMAScript code. Every object must have algorithms for all of the essential internal methods. However, all objects do not necessarily use the same algorithms for those methods.
essential internal methods 直译过来叫做基础内部方法,它用于描述 JavaScript 中对象的一些行为。每一个对象,不论是 ordinary object
还是 exotic object
都有这些方法对应的实现,但是这些方法的具体实现可能不一致。
当我们执行 foo.name2
获取对象值时,就会调用 [[Get]]
这个 essential internal method。
这个方法在不同的对象中的实现会有不同,如果我们在规范中按关键字进行搜索,我们会得到以下结果,
其中,除了 10.1.8
是描述 ordinary object
中的 [[Get]] 算法外,其它小节都是给一些 exotic object
定义的算法。比如这里就有 arguments exotic object、Integer-Indexed exotic object、module namespace exotic object 和 Proxy exotic object。这里只是列举,暂时我们还不需要考虑这些,如果读者感兴趣,可以自行跳转阅读。
也就是说 [[Get]] 方法与其具体的实现是一对多的关系。
[[Get]] 的定义
在规范 10.1.8
小节中,[[Get]] 方法定义如下,
the essential internal methods used by this specification that are applicable to all objects created or manipulated by ECMAScript code. Every object must have algorithms for all of the essential internal methods. However, all objects do not necessarily use the same algorithms for those methods.
- Return ? OrdinaryGet(O, P, Receiver).
该方法返回的其实是执行 OrdinaryGet(O, P, Receiver) 的结果。结合我们上边的例子 foo.name2
来看,foo
就是这里的 O,name2
是 P。而最后一个参数 Receiver 也是 foo
对象,稍后我们会讲为什么。
OrdinaryGet 方法的步骤如下,
- Let desc be ? O.[GetOwnProperty].
首先看第 1 步,该方法会通过 [[GetOwnProperty]] 这个内部方法来获取属性 P 的属性描述符,它返回的结果是一个 Property Descriptor 或者 undefined。
Property Descriptor 属性描述符
我们简单介绍一下 Property Descriptor,它直译过来叫做属性描述符,用于描述对象属性的组成和操作。根据属性的不同,它可能会返回两种类型的结果—— DataDescriptor 和 AccessorDescriptor。
// data descriptor
{
[[value]]: ...,
[[Writable]]: ...,
[[Enumerable]]: ...,
[[Configurable]]: ...
}
// accessor descriptor
{
[[Get]]: ...,
[[Set]]: ...,
[[Enumerable]]: ...,
[[Configurable]]: ...
}
是不是看着有些面熟?没错,当我们调用 Object.getOwnPropertyDescriptor() 这个 API 时,返回的结果就是在 Property Descriptor 的基础上转换返回的结果。
执行流程
回到正题,当步骤 1 [[GetOwnProperty]]
执行的结果 desc是 Property Descriptor 时,说明此时已经查找到了属性,就会跳过第 2 步,进入步骤 3~7。
这几步做的事情就是解析 desc 返回结果,根据它的类型执行不同的步骤,
- 如果 desc 类型是 DataDescriptor,则执行步骤 3 直接返回 [[Value]]。
- 如果 desc 类型是 AccessorDescriptor,则执行步骤 4~7 返回 [[Get]] 函数执行结果。
当 [[GetOwnProperty]]
返回的结果 desc 为 undefined 时,说明当前对象上没有定义该属性,此时就会进入步骤 2 。
第二步首先通过 [[GetPrototypeOf]]
来获取当前对象的原型 parent,获取到之后就在 parent 这个对象执行 [[Get]]
方法,即 OrdinaryGet。通过这种递归的形式, JavaScript 就会沿着原型链一级级向上查找有没有该属性。如果执行到某次时 parent 为 null
,则说明已经查找到了原型链的尽头,此时直接返回 undefined,表示未找到该属性。
解析下来整个步骤也比较清晰了,流程如下图所示,
所以,规范中对于查找对象属性的流程和我们通常理解的流程基本一致,即沿着原型链一直向上查找,直到找到并返回该属性,或者最终没有找到,返回 undefined。
再深入一点
ok,到这里我们已经深入地理解了对象查找属性时所做的事情,但是还有一些事情没有搞清楚。文章开头我们说到,当在对象上查找属性时,会调用 essential internal method,但这是在什么时候调用的呢?规范中有定义吗?
另外,我们刚才提到在调用 OrdinaryGet 方法时,还传入了 Receiver 这个对象,我们现在也没搞清楚它从哪儿来。
接下来,我们再深入一点,从根本上理清这些事情。
语法定义
我们现在想要探索清楚的,是当我们通过 foo.name2
获取值时发生在背后的整个过程。这样我们就不得不提到语法定义了。规范中有一个语法叫做 MemberExpression,它长这样,
这种形式初次见到会比较陌生,这是因为规范对于语法的定义采用了 context-free grammers 这种形式,相关内容我们之后再说。这里我们忽略一些关键字(如 Yield、Await),在这些定义中有一条是 MemberExpression.IdentifierName
,对应的就是我们上面例子中的 foo.name2
。
当我们执行 MemberExpression: MemberExpression.IdentifierName 这条运行时语义(Runtime semantics) ,步骤如下,
步骤的重点在于第 4 步的 EvaluatePropertyAccessWithIdentifierKey 方法,规范定义如下,
可以看到这个方法最后会返回一个 Reference Record,形式如下,
{
[[Base]]: baseValue,
[[ReferencedName]]: propertyNameString,
[[Strict]]: strict,
[[ThisValue]]: empty,
}
Reference Record 与 GetValue
Reference Record 翻译过来叫做引用记录。注意这里的“引用”指的可不是我们通常意义上的引用对象,不属于 ECMAScript Language Types,而是 ECMAScript Specification Types 中的一种(如果忘了,点击这里)。也就是说 Reference Record 只存在于规范中,是为了更方便的描述规范而产生的。
关于 Reference Record 的内容,我在很早之前的一篇文章中也有提及过,这里就不再赘述了。我们现在只需要知道,Reference Record 定义的是一种中间状态,用于解释 JavaScript 背后的各种行为特性,比如进行 delete
typeof
等操作背后发生的事情,以及 this
的指向等等。Reference Record 涉及的范围十分广泛,比如一个表达式的执行结果之前就可能会经历从 Reference Record 取值的过程。
规范中还定义了一个叫做 GetValue(V) 的抽象方法,专门用于从 Reference Record 中返回真正的结果。
对于 foo.name2
这个例子来说,Reference Record 内容如下,
{
[[Base]]: foo,
[[ReferencedName]]: name2,
[[Strict]]: strict,
[[ThisValue]]: empty,
}
当我们执行 GetValue 时,因为 Record 中的 [[Base]] 是一个对象 foo,所以它一个 Property Reference,会进入步骤 3。在步骤 3 中,正常情况下会执行到 3.c. ,即 baseObj.[[Get]](V.[[ReferencedName]], GetThisValue(V))
。
到这里,我们就已经非常清楚了,[[Get]] 方法是在 GetValue 这个方法中被调用的,它接收的第 3 个参数 Receiver 就是 GetThisValue(V) 的结果。
GetThisValue(V) 定义如下,
因为 foo.name2
拿到的 Reference Record 中的 [[ThisValue]] 为 empty,所以 IsSuperReference(V) 返回的结果为空。最终 GetThisValue 的执行结果就是 V.[[Base]],即 foo
对象本身。
那么 Receiver 这个参数有什么用呢?我们来看另一个例子,
const foo = { name: 'grace' }
const bar = { name: 'walk', get getName() { return this.name; } };
Object.setPrototypeOf(foo, bar);
console.log(foo.getName); // grace
现在你可以解释为什么打印结果是 grace
而不是 walk
了吗?
这是因为我们最开始传入的 Receiver 对象会在原型链向上查找的过程中不断被传递,所以 this.name
这里的 this 指向的始终是 foo
这个对象。这个时候再看一遍 OrdinaryGet 的执行过程,你就会豁然开朗。
至此,完整的流程如下图所示,
别走,还剩一些疑问...
我们在解释 foo.name2
语法定义的执行步骤时只关注了步骤 4,
那么步骤 1、2 呢?可以看到步骤 1 的结果也是一个 Reference Record,它是从哪里来的,为什么在这里会产生一个 Reference Record?另外,Evaluation 又是什么?
Runtime Semantics: Evaluation
首先看步骤 1,规范把 MemberExpression 执行的结果赋值给 baseReference。这里的 MemberExpression 指代的是 foo 对象。foo 对象的执行过程被定义为 Evaluation,在规范中定义如下,
IdentifierReference : Identifier
- Return ? ResolveBinding(StringValue of Identifier).
执行过程中返回的是 ResolveBinding 方法的调用结果。ResolveBinding 我们这里就不展开讲了,它的作用是沿着词法作用域一层层向外查找变量定义,并返回一个 Reference Record 作记录。
所以,步骤1、2 的作用就是在作用域中找到 foo 对象的定义。这部分还涉及到了规范中关于作用域的部分,我们在后面的章节再讨论。
写在最后
在这一章节中,我们以原型链为展开,分析了对象属性查找时的具体流程。在这个过程中,我们认识到了规范中的很多定义,比如 essential internal methods、Property Descriptor 和 Reference Record 等等。这些知识不仅仅与原型链有关,同时与 JavaScript 中的其他内容有着千丝万缕的关系,在后面的章节中,我们不时地还会遇到。
参考资料
Understanding the ECMAScript spec, part 2
最后,欢迎大家点赞评论收藏,有大家的支持才有更新的动力嘛~
转载自:https://juejin.cn/post/7161581808780476453