文章参考了霍春阳的《Vue.js设计与实现》,是自己在阅读过程中的一些思考和理解
对Proxy和Reflect的理解
proxy只能代理对一个对象的基本语义,也就是允许拦截并重新定义一个对象的基本操作。单个数据例如字符串,布尔值是无法代理的。
// 基本代理
const p = new Proxy(obj, {get(){}, set(){}})
同样,函数也可以是对象。因为对函数的调用也属于基本操作。但是拦截操作不一样。
const fn = () => {console.log('233333')}
const p = new Proxy(fn, {
apply(target, thisArg, argArray) {
target.call(thisArg, ...argArray)
}
})
p() // 输出'23333'
非基本操作中典型的操作为对象上的方法调用(obj.fn())。此操作称为复合操作。因为首先要通过ge操作找到对象上的这个方法,然后再通过apply调用,相当于进行了两个基本操作。
现在来考虑一个新的问题,代码如下:
const obj = {
foo: 1,
get bar() {
return this.foo
}
}
const proxy = new Proxy(obj, {
get(target, key, reciver) {
track(target, key)
return target[key]
},
set(target, key, value, reciver) {
target[key] = value
trigger(target, key, value)
}
})
effect(() => {
console.log(proxy.bar) // 1
})
分析:我们通过effect函数,绑定了bar和当前副作用函数。因此在读取bar时,触发get操作,进而触发副作用函数,因此执行了bar的函数,得到了foo的值。
但是当修改foo的值时,会发现并没有重新执行bar函数去获取最新的foo。这里的原因是我们在get函数中,返回的是target[key]。target是原始obj,key是参数bar。所以返回的是obj.bar。因此obj中的this指向的是当前的调用对象obj。则effect中的函数可以简化为
effect(() => obj.foo)
也就是说并没有读取代理上的值,也就没有建立foo与副作用函数的联系,则修改foo并不会触发get函数和set函数的重新执行。进而无法触发bar的重新读取。
改进操作:
const proxy = new Proxy(obj, {
get(target, key, reciver) {
track(target, key)
// 这里将返回对象改为Reflect
return Reflect.get(target, key, reciver)
},
set(target, key, value, reciver) {
target[key] = value
trigger(target, key, value)
}
})
effect(() => console.log(proxy.bar))
get函数第三个参数为proxy。Reflect.get函数的第三个参数为接收者,代表get操作将在接收者上进行。
分析:同样读取proxy.bar,触发get操作,返回的是Reflect的get操作,这里将要读取的对象设置为了proxy,因此返回的就不再是obj.foo,而是proxy.foo。这样就能建立foo与副作用函数之间的联系,使得修改foo能触发set和get操作,进而再次触发bar的副作用函数,重新读取一次foo的值,实现响应式。
JS对象和Proxy简单工作原理
JS对象分为了常规对象和异质对象。具体区别见ECMA,这里不多赘述。由于Proxy对象的内部方法[[Get]]没有使用ECMA规范的10.1.8节中给出的定义实现,因此Proxy是一个异质对象。当通过代理对象访问属性值时:
const proxy = new Proxy(obj, {})
proxy.value
实际上,JS引擎会调用部署中对象proxy的内部方法。而代理对象和普通对象的区别就在于内部的[[Get]]方法是实现。当没有给Proxy指定get方法时,通过代理访问属性时,代理对象的内部方法[[Get]]会调用原始对象的内部方法[[Get]]来获取属性值。这里的逻辑是:并不是代理对象执行了自己定义的get函数,而是自己重写了内部的[[Get]]方法,因为如果没有写get函数的话,仍然执行的是原始对象的[[Get]],这里体现了内部方法的多态性。这里需要注意的是如果要删除属性,使用代理对象拦截删除操作时,要使用Reflect去删除原始对象中的属性,而不能删除代理对象上的属性,不然相当于没有删除。
Object的代理
对象上的普通属性读取可以通过代理对象的get和set进行拦截。现在考虑如何代理in操作符。
第一个:key: key in obj。
通过中ECMA-262规范的13.10.1节中,发现in操作符的运算结果是通过调用HasProperty的抽象方法得到。在Reflect的表中可以发现其对应的拦截函数为has。因此可以通过has函数实现对in操作符的代理。代码如下:
const obj = {
foo: 1
}
const proxy = new Proxy(obj, {
has(target, key){
track(target, key)
return Reflect.has(target, key)
}
})
第二个:for i in obj。
通过查看ECMA的14.7.5.6节中的定义,找到关键方法EnumerateObjectProperties(obj)。在该方法的实现中,有如下的一段代码:
for(const key of Reflect.ownKeys(obj)) {
if(typeof key === 'symbol') continue
// 属性的描述器包括value,writable, enumerable,configurable。这些属性可以在Object.defineProperty里面定义的时候设置。
const desc = Reflect.getOwnPropertyDescriptor(obj, key)
if(desc) {
visited.add(key)
// 如果可枚举,则将作为迭代对象
if(desc.enumerable) yield key
}
}
遍历的obj就是我们for…in的对象,而他通过Reflect.ownKeys()来遍历此对象。因此可以考虑用ownKeys来拦截此操作。代码如下:
const obj = {
foo: 1
}
// 一个Symbol对象来作为当前属性的标识
const ITERATE_KEY = Symbol()
const proxy = new Proxy(obj, {
ownKeys(target) {
track(target, ITERATE_KEY)
return Reflect.ownKeys(target)
}
})
可以发现用了一个Symbol对象来传入track函数,因为ownKeys只有一个参数,就是当前对象,所以用一个Symbol对象来与当前对象的副作用函数进行唯一绑定,然后Reflect.ownKeys仍然还是传入当前对象(因为也只能传一个参数…)。
但是啊,由于进行的操作是将Symbol对象与当前对象进行的绑定,众所周知,在JS中对静态对象的属性更改是默认对对象没有更改操作的。所以此时,如果对对象进行属性的添加或者修改,是没有办法触发对象与Symbol绑定的副作用函数的。因为set函数中接受的是target和key,所以操作是在key上进行的,不是在target上进行的。(这里我居然还在想为什么没有触发get,tnnd,for…in的操作是在ownKeys上的,肯定没有触发get啊,我是sb)
解决方法:
// 获取与ITERATE_KEY相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
// 加到effectsToRun中进行后续执行
iterateEffects & iterateEffects.forEach(effect => {
// 防止死循环
if(effect != activeEffect) {
effectsToRun.add(effect)
}
})
这段代码,无论是添加新属性还是修改属性,都会触发iterateEffects副作用函数队列。但是考虑到修改属性的值对于for…in循环来说,都只会循环一次,因为这里循环的是key,而不是value,has函数返回的是对象中的key,不是value!所以这里不论如何修改key的value值,对于for…in循环来说,是没有变化的,因为输出的是key的列表。但是由于修改value,会触发set方法,导致iterateEffects集合执行,所以需要通过判断来规避由于修改而产生的不必要的for…in循环。代码如下:
set(target, proxy, value, reciver) {
// 原对象中修改要变的值
target[proxy] = value
// 通过原型上的方法判断属性是否存在,如果存在则是修改,不存在则是新增
const type = Object.prototype.hasOwnProperty.call(target, key) ? 'SET' : 'ADD'
// 属性值通过Reflect.set确定
const res = Reflect.set(target, proxy, value, reciver)
// 封装出去了
trigger(target, proxy, value, reciver, type)
// effects && effects.forEach(fn => fn())
return res
},
// 枚举集合
const TriggerType = {
SET: 'SET',
ADD: 'ADD'
}
// trigger中改变为
// 只有当为新增操作时,才执行迭代器的副作用函数
if(type === TriggerType.ADD) {
// 获取与ITERATE_KEY相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects & iterateEffects.forEach(effect => {
if(effect != activeEffect) {
effectsToRun.add(effect)
}
})
}
添加属性值判断,来决定是否要执行迭代器的副作用函数。
最后是对删除操作的代理。
在ECMA13.5.1.2节中定义了delete操作符的行为,可以看到其依赖于[[Delete]]内部方法,接着对照Reflect表,可以发现其对应于deleteProperty函数。因此可以在Proxy用其进行拦截。代码如下:
deleteProperty(target, key) {
// 判断属性是否属于当前对象
const isHave = Object.prototype.hasOwnProperty.call(target, key)
// 通过Reflect进行删除
const res = Reflect.deleteProperty(target, key)
// 如果属于当前对象且已经删除成功,则触发trigger,重新执行一次循环,因为此时key减少了
if(isHave && res) {
trigger(target, key, 'DELETE')
}
}
// 在trigger中新增'DELETE'判断
// 只有当为新增或删除操作时,才执行迭代器的副作用函数
if(type === TriggerType.ADD || type === TriggerType.DELETE) {
// 获取与ITERATE_KEY相关联的副作用函数
const iterateEffects = depsMap.get(ITERATE_KEY)
iterateEffects & iterateEffects.forEach(effect => {
if(effect != activeEffect) {
effectsToRun.add(effect)
}
})
}
合理地触发响应式
1.对属性赋予相同的值时,不应该触发响应式
比如:当前的obj.value = 1,然后再次执行obj.value = 1。此时不应该触发响应式。因此需要在set方法中进行添加判断。
const oldVal = target[proxy]
// 封装出去了,添加新旧判断
if( oldVal !== value) {
trigger(target, proxy, type)
}
虽然可以完成大部分的判断,但是存在的缺陷是NAN类型。众所周知,NAN与NAN相比较是会返回false,所以二者总是不相等的。改进后代码:
// 封装出去了,添加新旧判断
if( oldVal !== value && (oldVal === oldVal || value === value)) {
trigger(target, proxy, type)
}
2.原型继承上的问题
简单封一个reactive函数表达式:
function reactive(obj) {
return new Proxy(obj, {
/* 省略一大堆函数 */
})
}
const obj = {}
const proto = {bar: 1}
// 就是返回了一个代理对象
const child = reactive(obj)
const parent = reactive(proto)
// 将child的原型设置为parent
Object.setPrototypeOf(child, parent)
effect(() => {
console.log(child.bar)
})
// 修改bar的值,会发现执行了两次副作用函数
child.bar = 2
过程分析:首先在child对象上读取bar属性,触发代理的get拦截操作,然后child上面是没有bar属性的:
Reflect.get(obj, 'bar', child)
通过查看ECMA-10.1.8.1节,发现如果属性为undefined,那么就会获取对象的原型,并调用原型的[[Get]]方法来得到最终的结果。因此在child上没有bar属性就会去parent上获取。因此在这个过程中,child和parent的属性集合都搜集了该副作用函数。
再来看set操作。当企图向child代理对象上设置bar时,会被其set函数拦截。通过查看ECMA-10.1.9.2节可知如果对象上属性为undefined,也会向原型对象上获取,并调用原型的[[Set]]方法。由于parent也是代理对象,所以也执行了set拦截函数,因为之前都收集了副作用函数,又都执行set拦截函数,所以导致了副作用函数执行了两次。
解决办法就是将第二次的副作用函数执行给屏蔽掉,也就是需要在set拦截函数中进行第二次的屏蔽。通过在set函数中输出receiver,发现两次的输出都是child的代理对象,也是可以理解的,虽然执行了的两次副作用函数来自不同的对象,但是仍然是在child的基础上向原型链中查找的。所以这里发现两次的set执行,原始target不同,但是reciver是同一个。所以只需要判断当前的代理对象reciver是当前的target的代理对象就可以了。代码如下:
// 添加判断,判断当前的代理对象是不是当前原对象的代理
// 通过代理对象上的raw属性判断
if(reciver.raw === target) {
// 新旧值判断,同时排除NAN
if( oldVal !== value && (oldVal === oldVal || value === value)) {
trigger(target, proxy, type)
}
}
浅响应式与深响应
这个概念比较简单。代码如下:
// 简单的reactive函数实现
function reactive(obj) {
return new Proxy(obj, {/* 方法省略 */})
}
// 传入一个双层对象
const active = reactive({foo: {bar: 1}})
考虑之前的Proxy的get函数。返回的是:
Reflect.get(target, proxy, reciver)
很明显,只有单层的数据返回,这里传入的{foo: {bar: 1}},在get中返回的是{bar:1},所以如果修改obj.foo.bar的值,并不会触发响应式,因为对象里面的属性值修改不算对象改变。但是如果直接修改obj.foo,是可以触发响应式的,因此这里是第一层数据。
所以需要对get函数进行修改,代码如下:
//当前对象判断
if(typeof res === "object" && res !== null) {
// 继续对对象进行响应式处理
return reactive(res) // <--为对象里面继续添加响应式
}
但是有时候,却不希望是深层的响应式,而只需要第一层。因此就产生了shallowReactive,也就是浅响应式。代码如下:
function createReactive(obj, isShallow = false) {
return new Proxy(obj, {
get(target, key, reciver)
const res = Reflect.get(target, key, reciver)
track(target, key)
// 浅响应式直接返回
if(isShallow) {
return res
}
// 深响应式继续对对象做深入的响应式
if(typeof res === 'object' && res !== null) {
return reactive(res)
}
return res
}
})
}
// ractive深层响应式方法
function reactive(obj) {
return createReactive(obj)
}
// shallow浅层响应式方法,传入true
function shallowReactive(obj) {
return createReactive(obj, true)
}
只读和浅只读
当数据是只读的时候,如果用户尝试去修改或删除时,都应该弹出警告且拒绝该操作。因此需要对set和delete代理进行修改。代码如下:
function createReactive(obj, isShallow = false, isReadonly = false) {
return new Proxy(obj, {
get(target, key, reciver)
const res = Reflect.get(target, key, reciver)
track(target, key)
// 浅响应式直接返回
if(isShallow) {
return res
}
// 深响应式继续对对象做深入的响应式
if(typeof res === 'object' && res !== null) {
return reactive(res)
}
return res
},
set(target, key, value, reciver) {
/* 省略其他操作, 只加入只读的判断 */
if(isReadonly) {
console.warn(`属性${key}是只读的`)
return true
}
},
deleteProperty(targrt, key) {
/* 省略其他操作, 只加入只读的判断 */
if(isReadonly) {
console.warn(`属性${key}是只读的`)
return true
}
}
})
}
除了不能修改和删除外,由于数据是只读的,所以也不需要为其添加响应式。因此get函数也需要一点判断,避免不必要的逻辑。代码如下:
get(target, key, reciver) {
// 同样当不是只读的情况下才为其添加响应式
if(!isReadonly) {
track(target, key)
}
}
新增readonly函数,以及其他函数的修改。代码如下:
function readonly(obj) {
return createReactive(obj, false, true)
}
这里只做到了浅只读,因为第二个参数为false,深度的对象依然会添加响应式。所以需要在get函数中添加判断,如果是只读,那么应该调用readonly函数而不是reactive函数。代码如下:
get() {
if(typeof res === 'object' && res !== null) {
// 如果是只读,那么调用readonly函数
return isReadonly ? readonly(res) : reactive(res)
}
}
在浅只读中,只需要添加第三个参数就可以了。代码如下:
function shallowReactive(obj) {
// 因为浅层本身就只有一层,所以不需要深度判断
return createReactive(obj, true, true)
}
截止到现在,大部分的功能都测试过,但是难免有部分功能与Vue.js不同,比如watch一个代理对象,深度数据监视不到、传的oldval和newval一致,这里可能有时间差的存在等等。在所难免,因为这是简化了很多后的代码。只要原理能理解好,后续看源代码的时候有很大的帮助。