事情的起因是这样的,最近在阅读 Koa 代码时发现 Koa 中对于 ctx 的属性"代理"比较有意思。
刚好在验证这一过程中发现 Chrome 浏览器中对于这种方式存在一些悠久的 Bug 。
于是写下这篇文章记录点滴并且希望可以帮助到更多的前端同学避免踩坑。
文章重点内容主要阐述 JavaScript 中 Getter/Setter 属性访问/操作符的”屏蔽“作用。
我会花稍多的篇幅来描述它的展现和效果,但是请你相信我。这是一篇技术科普文章,并不是所谓抖机灵的非技术爽文。
首先我们先来看看一段非常普通的 JS 代码:
const parent = {
name: '19Qingfeng'
}
const child = Object.create(parent)
child.name = 'WangHaoyu'
console.log(child)
console.log(child.name)
相信这段代码对于大家来说都是信手拈来,我们通过 Object.create 方法创建了一个新的对象 child ,同时让 child 对象的 protot 指向了 parent 对象从而实现了继承的关系。
我们来看看打印结果:
当执行 child.name = 'WangHaoyu'
时,实质上相当于我们在为 child 实例对象上进行赋值操纵自然而然和原型上的 name 属性并不会有任何关系。
同样当我们访问 child.name 时,因为实例本身存在 name 属性自然是不会去原型链上查找了。理所应当输出 WangHaoyu 。
看到有些朋友可能会有不耐烦了,觉得如何简单的基础为什么要拿出来浪费大伙儿的时间。
别着急,让我们继续,这不过是正式开始前的基础铺垫而已。
在 JavaScript 定义对象时,我们同时可以通过 [[Getter]]、[[Setter]] 来为属性绑定对应的执行函数。
简单来说,比如这样:
const obj = {
_name: null,
get name() {
return this._name
},
set name(value) {
this._name = value
}
}
obj.name = '19Qingfeng'
console.log(obj.name) // 19Qingfeng
比如上边的示例中我们为 obj 对象定义了一个 get 属性访问符 name ,同时也为 name 定义了对应的 setter 属性操作方法。
当执行 obj.name = '19Qingfeng'
时,实际上是会调用 obj 上的名为 name 的 setter 函数,从而修改 obj 实例对象上的 _name 值。
同样当调用 console.log(obj.name)
时,相当于进行了 LTS 查询(当然上边的赋值操作不仅仅会触发 LTS 同时会触发 RHS)。
访问 obj 上的 name 属性时会触发对应的 get 函数执行,从而得到 obj 上的 _name 属性,从而打印出 19Qingfeng 。
实际上上边的代码相当于:
const obj = {
_name: null
}
Object.defineProperty(obj, 'name', {
get() {
return this._name
},
set(value) {
this._name = value
}
})
obj.name = '19Qingfeng'
console.log(obj.name) // 19Qingfeng
同样,我相信上述的基础操作对于大伙来说没有任何难度。但是我们将继承与 Getters/Setter 结合而来就会出现意想不到的效果。
关于所谓的屏蔽效果,我们先来看看这样一个小例子:
// 创建parent对象 拥有get/set以及_name
const parent = {
_name: null,
get name() {
return this._name
},
set name(value) {
this._name = value
}
}
// 创建一个空对象child 相当于 child.__proto__ = parent 实现原型继承
const child = Object.create(parent)
// 为child实例赋值name属性
child.name = '19Qingfeng'
console.log(child, 'child')
大家可以稍微想想这里的操作,其实它并不难。我们通过 Object.create 创建了一个空对象,同时让他继承与 parent 对象。
在之后我们通过 child.name = '19Qingfeng'
尝试为 child 实例添加一个 name 属性值为 19Qingfeng。
此时如果按照我们基于 JavaScript 的理解的话,我们为 child 实例上添加了一个 name 属性,实质上是和原型上的 parent 中的同名操作符没有任何关系对吧。
最开始我也是天真的这样以为的,当我们进行 child.name = '19Qingfeng'
赋值时,应该仅仅为 child 实例上添加一个 name 为 19Qingfeng 的属性就可以了。
可是结果并不是这样,我们来看看打印结果:
如果你之前不是很了解所谓的“屏蔽效果”,那么此时的结果对你来说一定是会出乎意料。
我们明明是在实例 child 上进行了赋值,可是为什么 child 上并没有出现所谓的 name 属性,而是拥有了一个名为 _name 的 19Qinfeng ?
其实这正是我想和大家重点强调的的所谓 Getter/Setter 产生的屏蔽效应:
比如上边我们为 child 的 name 属性进行赋值操作时完整过程如下:
但是,如果 child 的原型链中查询到了 name 属性,那么此时情况就会稍微有些复杂。
对于 child 中的 name 属性的修改,会屏蔽原型上所有 name 属性的修改,这也是我们的理解。
当然如果屏蔽真的如此简单的话,那么我完全没有必要和大家来强调它,如果实例中并不直接存在 name 属性但是此时原型上存在同名的 name 时
child.name = '19Qingfeng'
是不会产生任何效果的,换句话说它并不会为自身实例添加属性同时也无法修改原型上的同名属性,在严格模式下甚至这一行为会提示错误。
结合第三种情况下,我们来解释刚才的例子。首先,child 对象中本身并不存在 name 属性,但是它继承与 parent 对象。child 的原型上存在所谓的名为 name 的 getter 和 setter 。
当我们调用 child.name = '19QIngfeng'
时,会满足屏蔽效果下的第三种情况:
针对于实例上的 child.name = '19QIngfeng'
并不会为实例添加属性,并且会调用原型上最近的 setter 操作,此时相当于会执行 this._name = value
。
因为我们通过 child.name
调用,所以 setter 里的 name 会指向对应的 child
,自然 setter 中的逻辑就相当于为 child 实例添加了一个普通属性 _name 值为 19QIngfeng 。
其实至此,我想和大家阐述的重点就已经完成了。所谓 Getter/Setter 在某些情况下会发生属性的屏蔽,至于是什么情况下看到这里相信大家都已经了解的非常清楚了。
最后,我们再来看看 Chrome 下对于这一情况的展现。当然,你可能也会发现我们之前使用的所有 console 控制台都是来自于 FireFox ,这是我刻意为之的。
针对于这样一段代码,我相信此时大家对于它的执行机制都已经了然于胸了:
const parent = {
_name: null,
get name() {
return this._name
},
set name(value) {
this._name = value
}
}
const child = Object.create(parent)
child.name = '19Qingfeng'
console.log(child)
如果此时你仍然对于这段代码还是模糊,那么我希望你可以翻回去重新理解下文章之前的描述的执行过程。
我们来看看 Google Chrome 下的 console:
很明显,child 实例对象上压根不应该存在所谓的 name 普通属性,他应该仅仅存在对应的 _name 属性。
这这许是 Chrome 下的小问题,如果你选择使用 Chrome 的结果打印来理解 Getter/Setter 的属性屏蔽效果,那么此时我相信你是永远无法绕出来的。
不过这一切已经显得不是那么重要的,重要的是我之前已经和大家讲述过的结论。
当我们为一个对象进行赋值操作时,并不是仅仅会直接为实例上进行赋值操作,不同情况下会存在截然不同的效果。
至于它可以为我们带来什么,了解 ES6 的同学可以稍微回忆下 ES6 中的 Proxy ,也就是 Vue 3 中针对于 reactive 实现的数据劫持。
我们完全可以利用 Getter/Setter 在上边提到过的“屏蔽”效果来实现类似于 ES6 中的 Proxy 中的 Get 和 Set 陷阱的 polyfill 方案。
当我们为实例上进行取值/赋值操作时,如果原型上存在同名的 Getter/Setter 并且实例本身不存在时,那么十几上是会触发最近原型上的 Getter/Setter 从而屏蔽本次实例上的操作。
其实 Koa 中正是利用这一效果来实现类似于 Proxy 的简单劫持,具体代码代码大家可以在 koa/lib/application.js
中的 createContext/constructor 中看到。
最后我们就到了文章的结尾了,首先在文章的结尾感谢每一个可以阅读到这里的同学。
这篇文章中所强调的知识点其并不存在什么大的难度,甚至可以说是一个非常细小的知识点。
但是毕竟”至千里“一定是”积跬步“而来的,对吧。