乐趣区

关于javascript:ES-拾遗之赋值操作与原型链查找

问题

这两天在排查一个 qiankun 的 bug 时,发现了一个我无法解释的 js 问题,这可要了我的命。

略去所有细枝末节,咱们间接先来看问题。
如果有这么一段代码:

(() => {
  'use strict';
  
  const boundFn = Function.prototype.bind.call(OfflineAudioContext, window);
  console.log(boundFn.hasOwnProperty(boundFn, 'prototype'));
  boundFn.prototype = OfflineAudioContext.prototype;
  console.log(boundFn.hasOwnProperty(boundFn, 'prototype'));
})();

假如咱们已知,函数通过 bind 调用后,返回的新的 boundFn 是肯定不会有 prototype 的。

那么打印后果就应该是:

false
true

因为 boundFn 不具备自有属性 ‘prototype’,所以在通过 boundFn.prototype = OfflineAudioContext.prototype 的赋值操作后,会为其创立一个新的自有属性 ‘prototype’,其值为 OfflineAudioContext.prototype。一切都在情理之中。

但你真的把这段代码粘到 chrome 控制台跑一下就会发现,报错了😑

从报错信息很容易判断,咱们在尝试给一个 readonly 的属性做赋值,但要害是,prototype 这个属性在 boundFn 上压根不存在呀!
咱们晓得,对象的属性赋值操作的根本逻辑是这样的:

  1. 如果对象上该属性不存在,则创立一个自有属性并赋值
  2. 如果对象上该属性已存在,则批改该属性的值,批改过程会触发该属性上的 data descriptor(writable 配置)检测或 accessor descriptor (setter 配置) 的调用。

毫无疑问下面代码走的应该是第一个逻辑分支,齐全不应该报错才对。

起初我还认为是浏览器兼容问题,而后尝试过几个浏览器之后,发现都是报错😑

排查的过程中发现,OfflineAudioContext.prototype 自身是 readonly 的

然而这跟咱们 boundFn.prototype 赋值有什么关系呢,即使咱们把赋值操作改成:

boundFn.prototype = 123;

报错还是会依旧。
持续查,发现 boundFn 的原型链上是有 prototype 的:

而且原型链上的这个 prototype 也是 readonly 的:
然而咱们一个写操作跟原型链有啥关系呢,不是读操作时才会按原型链查找吗???

ES Spec 追踪

各种尝试之后无果,这时候只能祭出 ecmascript spec,看看能不能从外面找到蛛丝马迹了😑

搜寻找到赋值操作 (assignment) 相干的 spec 阐明:

如果有过读 ecmascript spec 教训的话,会找到关键步骤在第 5 步 PutValue:

咱们这个场景里,PutValue 的操作会沿着 4.a.false 的门路执行。即 put 对应的调用为 base.[[Put]](reference name, W, true)
找到 [[[Put]]](https://262.ecma-internationa… 的调用算法阐明:

这里其实就能看到,如果咱们走到了最初一步第 6 步的时候,实际上产生的事件就会是:
Object.defineProperty(O, P, { writable: true, enumerable: true, configurable: true, value: V}), 也就是咱们会为对象创立一个新的属性并赋值,且这个属性是可枚举可批改的,合乎咱们之前的认知。

那其实咱们就要看看,为什么流程没有走到第 6 步。
先看第一步里的 [[[CanPut]]](https://262.ecma-internationa… 做了啥:

简略翻译下流程就是:

  1. 查找本身属性的 descriptor
  2. 如果有则依照 descriptor 的规定判断
  3. 如果没有则看对象是否有原型
  4. 如果原型是 null 则间接依据对象是否可拓展返回后果
  5. 否则去原型链上查找属性
  6. 如果原型链上找不到,则间接依据对象是否可拓展返回后果
  7. 如果原型链上能找到,则记录查找后的值对应的 descriptor
  8. 如果记录的值是 accessor descriptor,那么就依据 setter 配置决定返回值
  9. 如果记录的值是 data descriptor,那么就依据是否和拓展或者是否 writable 来给出返回值

其实到这里咱们就能发现端倪了,关键点是这几步:

这几步形容的理论就是,计算流程会始终去原型链上查找属性 P。

也就是说,即使咱们是赋值操作,只有是对象属性的赋值,都会触发原型链的查找。

那么回到下面那段代码,对应的计算流程就是:

  1. 先触发了 boundFn 本身属性里查找 prototype 的操作
  2. 发现不存在 prototype,则去原型链上找
  3. 因为 boundFn 的原型指向了 BaseAudioContext,所以返回的理论是 BaseAudioContext.prototype
  4. 而 BaseAudioContext.prototype 的 writable 配置为 false
  5. 故 [[CanPut]] 操作返回了 false
  6. 返回 false 后就间接 throw 了一个 TypeError

解法

那么如果咱们的确想给 boundFn 加一个本身属性 prototype 该怎么做呢?
其实咱们只有找到不会触发原型链查找的批改形式就能够了:

- boundFn.prototype = OfflineAudioContext.prototype;
+ Object.defineProperty(boundFn, 'prototype', { value: OfflineAudioContext.prototype, enumerable: false, writable: true})

原理就是 defineProperty API 不会有 [[getProperty]] 这种触发原型链查找的调用:

论断

赋值(assignment)操作也会存在原型链查找逻辑,且是否可写也会遵循查找到的属性的 descriptor 规定。

退出移动版