我正在参与掘金创作者训练营第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)