共计 2504 个字符,预计需要花费 7 分钟才能阅读完成。
问题
这两天在排查一个 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 上压根不存在呀!
咱们晓得,对象的属性赋值操作的根本逻辑是这样的:
- 如果对象上该属性不存在,则创立一个自有属性并赋值
- 如果对象上该属性已存在,则批改该属性的值,批改过程会触发该属性上的 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… 做了啥:
简略翻译下流程就是:
- 查找本身属性的 descriptor
- 如果有则依照 descriptor 的规定判断
- 如果没有则看对象是否有原型
- 如果原型是 null 则间接依据对象是否可拓展返回后果
- 否则去原型链上查找属性
- 如果原型链上找不到,则间接依据对象是否可拓展返回后果
- 如果原型链上能找到,则记录查找后的值对应的 descriptor
- 如果记录的值是 accessor descriptor,那么就依据 setter 配置决定返回值
- 如果记录的值是 data descriptor,那么就依据是否和拓展或者是否 writable 来给出返回值
其实到这里咱们就能发现端倪了,关键点是这几步:
这几步形容的理论就是,计算流程会始终去原型链上查找属性 P。
也就是说,即使咱们是赋值操作,只有是对象属性的赋值,都会触发原型链的查找。
那么回到下面那段代码,对应的计算流程就是:
- 先触发了 boundFn 本身属性里查找 prototype 的操作
- 发现不存在 prototype,则去原型链上找
- 因为 boundFn 的原型指向了 BaseAudioContext,所以返回的理论是 BaseAudioContext.prototype
- 而 BaseAudioContext.prototype 的 writable 配置为 false
- 故 [[CanPut]] 操作返回了 false
- 返回 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 规定。