我正在参与掘金创作者训练营第5期, 点击了解活动详情
本篇内容基于【拆解Vue3】计算属性是如何实现的?实现。
雏形
还是先来看看Vue官方的描述,再来逐步实现reactive。
- 为 JavaScript 对象创建响应式状态,可以使用
reactive方法;
reactive的目标就是为了实现JavaScript对象的响应式。在前面我们已经利用proxy初步实现了响应式,但使用的data都较为简单,在本篇中,我们会针对JavaScript对象,来完善我们的响应式设计。
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
set(target, key, newValue) {
target[key] = newValue
trigger(target, key)
return true
}
})
}
响应式数据为什么需要Reflect?
我们先用reactive函数封装proxy,这样语法上就与真实的reactive一致了。接下来我们看看针对不同的对象,我们的reactive是否能产生预期的结果。
const data = {
foo: 1,
get bar() {
return this.foo
}
}
const obj = reactive(data)
effect(() => console.log('worked', obj.bar))
在这个例子中,我们定义了一个data对象,其中包含了一个get,即读取data.bar的值时,返回this.foo。this出现的地方总会让人先想到,这个this会有预期的指向吗?
我们的预期是,当读取data.bar时,返回data.foo的值。同样,在effect中,我们读取了obj.bar的值,该副作用函数会被obj.bar收集,但obj.bar返回了this.foo,理应也被obj.foo收集。那按照上述的预期,当修改obj.foo的值时,也应该会有响应式的trigger去触发副作用函数。
从结果看,我们修改obj.foo时,并没有触发副作用函数effect。这里发生了什么呢?我们来打上断点调试一下看看。
调试的结果很清楚了,我们实际上只将effect作为obj.bar的副作用函数收集了,但obj.foo没有收集到副作用函数。问题很清楚了,收集副作用函数的逻辑有问题,那就再来看看我们是如何进行副作用函数收集的。
function reactive(obj) {
return new Proxy(obj, {
get(target, key) {
track(target, key)
return target[key]
},
...
})
}
根据调试信息我们发现,在收集副作用函数时,target是Object。这意味着,我们在读取obj.bar时,get中的target实际上是原始对象data,返回的target[key]就是data.bar。对于data.bar触发的get bar()来说,它的this指向不是Proxy代理对象,而是原对象data,作用于原对象上的副作用函数当然无法被收集。为了解决这个问题,我们需要指定Proxy内的this指向,使得这些this指向代理对象。
我们可以使用Reflect来指定调用过程中的this,Reflect拥有与Proxy相同的方法,并且Reflect可以通过recevier参数,来调整this的指向。为了说明这一点,同样来看一个例子。
const obj = {
foo: 1,
get foo() {
return this.foo
}
}
const trick = {
foo: 22
}
console.log(Reflect.get(obj, 'foo', trick))
执行这段代码,结果正如我们预期的那样,输出了22。验证了Reflect的有效性后,我们来更新reactive的代码。
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, newValue, receiver) {
trigger(target, key)
return Reflect.set(target, key, newValue, receiver)
}
})
}
我们可以发现,使用Reflect后,this指向了receiver这个Proxy代理对象。
我们在控制台中使用++obj.foo,来触发副作用函数。这里还有一点需要注意,++obj.foo相当于obj.foo = obj.foo + 1,所以先触发了get操作,执行了副作用函数,而set是后被触发的,因此输出的值仍是未进行++操作的旧值。
更完善的响应式
在前面的实现中,我们对于任何set操作,其实都会触发trigger去执行对应的副作用函数。这可能与我们的预期不符,因为set并不一定会改变响应式对象的值,而响应式对象的值不改变,就不应该被触发trigger。参考下面这个例子。
const data = {
foo: 1,
}
const obj = reactive(data)
effect(() => console.log('worked', obj.foo))
在这个例子中,并没有改变obj.foo的值,副作用函数却被触发了。这个问题也好解决,我们只需要判断一下,set的值与原值是否相等,若相等,就不再触发trigger就好了。
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, newValue, receiver) {
const oldValue = target[key]
if(oldValue !== newValue) {
trigger(target, key)
}
return Reflect.set(target, key, newValue, receiver)
}
})
}
这里看到!==又皱起眉头,不禁想到了那个经典面试题NaN !== NaN。显然,我们打的这个补丁,对于NaN无效。
const data = {
foo: NaN,
}
const obj = reactive(data)
effect(() => console.log('worked', obj.foo))
为了解决这个边缘case,我们不得不利用NaN === NaN的真值为false的特性,用来判断是否新值与旧值均为NaN,若均为NaN,我们也不需要执行trigger。
const bothNaN = (newValue, oldValue) => !(newValue === newValue || oldValue === oldValue)
const isEqual = (newValue, oldValue) => bothNaN(newValue, oldValue) || oldValue === newValue
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, newValue, receiver) {
const oldValue = target[key]
if(!isEqual(newValue, oldValue)) {
trigger(target, key)
}
return Reflect.set(target, key, newValue, receiver)
}
})
}
前面在实现effect的时候,我们考虑到了嵌套的情况,并进行了响应的处理。首次启发,我们考虑一下对象嵌套,我们的代码能否给出预期的结果。我们用原型链来实现这个case。
const obj = {}
const son = reactive(obj)
const preObj = { bar: 1 }
const father = reactive(preObj)
Object.setPrototypeOf(son, father)
effect(() => console.log('worked', son.bar))
这里我们把father置为son的原型,给了一个effect打印son.bar的值。当然,son中并不包含bar,回忆一下原型链的知识,找不到的值会沿着原型链从下到上寻找,所以son会取到father的bar作为自己的bar值输出。接着,让我们尝试修改son.bar,看看能否按我们的预期执行。
等等,这里的副作用函数怎么执行了两次?上面我们讲的是普通对象的,在响应式对象上使用原型链,这中间发生了什么?
从执行结果看,son与father都执行了effect。首先比较清楚的一点是,son上没有bar,就需要去父级获取,son读取了father的bar,此时也会触发father的get,从而导致effect被father收集。
收集理清楚了,接下来考虑触发。在执行son.bar = 2时,首先触发的是son的set并触发了trigger执行副作用函数,这是副作用函数第一次被执行。
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, newValue, receiver) {
const oldValue = target[key]
if(!isEqual(newValue, oldValue)) {
trigger(target, key)
}
console.log(target) // (*)
return Reflect.set(target, key, newValue, receiver)
}
})
}
为了搞清楚每次执行effect的target是谁,我们在(*)处打印target的值。
第一次在执行时,我们可以清楚的看到son上没有bar。我们返回了Reflect.set(target, key, newValue, receiver),son上找不到key,就不得不顺着原型链往上找,在father上找到了这个key,从而触发了father的set。
这里一定要注意,此处仅仅是触发了father的set,并没有修改father的bar,这是因为自始至终,receiver都是son。我们打印一下看看。
发现了这点,就好办了,我们只需要通过receiver就能判断到底更新的是不是当前的target。这里要当心,target是原始对象,而receiver是响应式对象,我们不能直接比较。简单起见,我们直接去获取receiver的原始对象与target进行对比。
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key)
if(key === 'raw') { // (1)
return target
}
return Reflect.get(target, key, receiver)
},
set(target, key, newValue, receiver) {
const oldValue = target[key]
if(target === receiver.raw && !isEqual(newValue, oldValue)) { // (2)
trigger(target, key)
}
return Reflect.set(target, key, newValue, receiver)
}
})
}
我们在(1)处增加了一个属性raw,用来获取响应式对象的原始对象,在set中就可以利用这个属性判断是否是自身的副作用函数被触发了。
参考资料
- 《Vue.js设计与实现》霍春阳
- Vue.js (vuejs.org)
- Tiny-Vue: 一个实现了 Vue 核心功能的微型前端框架。 (gitee.com)