持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情
要理解这篇文章建议先看以下两篇,了解什么是
effect,什么是track,什么是trigger,什么是scheduler,毕竟每一篇的内容都是联系的
- 上一篇我们实现了Computed 实现Vue3的Computed(附源码) - 掘金 (juejin.cn)
- 这一篇我们继续研究如何去实现一个
watch
一、实现分析
- 老配方,要实现
watch之前肯定还是要了解一下它是干嘛的,有什么特性 watch: 翻译过来就有观察,监听的意思嘛,主要就是监听某个数据变化之后执行对应的操作- 也就是说,我们要实现
watch本质是实现一个能够观测响应式数据,在数据变化的时候执行相应回调函数的的执行者
watch(proxy, () => {
console.log('当数据变化的时候,我这个函数就要执行')
})
proxy.foo = '我现在来修改数据'
二、实现过程
👉数据修改
- 从上一篇我们就已经解决过这个问题,当数据修改的时候就会触发
trigger函数,从而找到scheduler函数,那我们依旧去找到scheduler函数搞事情即可,就是说在scheduler函数中去调用传watch传进来的回调函数 - 那么我们最简单的
watch就诞生了
function watch(source, cb) {
//要让它监听foo,那么就要去获取它
effect(()=>source.foo, {
//传入调度函数让它去调用传入来的回调函数
scheduler() {
cb()
}
})
}
👉测试
- 我传入回调函数打印一句话,然后前后两次修改
proxy.foo的值
watch(proxy, ()=>{
console.log('当数据变化的时候,我这个函数就要执行')
})
console.log('---第一次修改----')
proxy.foo = '我现在来修改数据'
console.log('---第二次修改----')
proxy.foo = '我又改了'
- 打印结果
👉处理传参
- 但是你会发现,目前我们的
watch只能观测proxy.foo的变化,因为我们里面是写死的,所以现在我们需要对其进行扩展 - 那我们就需要对传入的对象的每一个属性进行读取,让每个属性都能触发
track函数,才能在变化的时候去触发trigger函数 - 可以使用
set集合不存储重复值的特性,对读取的属性进行筛选,防止重复递归地去读取某个属性
//注意这里不关心返回什么值,而是注重对传入的对象的每一个属性挨个去读取
function traverse(value, seen = new Set()) {
//如果是简单数据/空数据/已经被读取过的数据就可以直接返回
if(typeof value !== 'object' || value === null || seen.has(value)) return
//否则则加入seen----防止循环引用导致死循环
seen.add(value)
for(let item in value) {
//对每一个属性递归使用
traverse(value[item], seen)
}
return value
}
- 修改后的
watch函数为
function watch(source, cb) {
//监听传入的source整个对象
effect(()=>traverse(source), {
scheduler() {
cb()
}
})
}
👉测试
- 这一次我不仅去修改proxy.foo的属性,我甚至还修改了
proxy.bar, 修改proxy.name属性
watch(proxy, ()=>{
console.log('当数据变化的时候,我这个函数就要执行')
})
console.log('---第一次修改foo属性----')
proxy.foo = '我现在来修改数据'
console.log('---第二次修改bar属性----')
proxy.bar = '我又改了'
console.log('---第三次修改name属性----')
proxy.name = '我叫dddbug'
- 打印结果
但是感觉监听一整个对象也不是很合心意,本来我只需要监听它一个属性的变化,这下好了,只要有属性变就调用函数
- 那我们可以给参数多设一种可能:可以传入调用某些需要被监听的属性的函数,这样的话我们就不用全盘接收
let getter ;
//如果传入的source为函数,则直接赋值
if(typeof source === 'function') {
getter = source
}else{
//否则就是按照我们之前的方法,全盘接收
getter = ()=>traverse(source)
}
你在测试的过程中就会觉得很不得劲,因为我们不仅是想知道数据改变了,还想要在回调函数能够拿到变更前后的数据
👉拿到变更前后数据
- 要拿到两个值,就需要在数据变化之前至少执行过一次函数,数据变化之后执行一次
- 也就是说我们需要控制函数调用的时机,把调用的控制权交到自己手里
- 也就是说我们需要用的实现
Computed中用的lazy,如果lazy为true,则暂缓执行该函数,然后再选择合适的时机合适的地方调用它
function watch(source, cb) {
//监听传入的source整个对象
let newValue, oldValue;
......
const effectFn = effect(()=>getter(), {
//将lazy设为true
lazy: true,
scheduler() {
//在这里调用是因为它监听的值发生了变化,所以这里调用函数获取得到的值一定为新值
newValue = effectFn();
cb(newValue, oldValue);
//旧数据不断更新,不然会一直都是初始化时候的值
oldValue = newValue;
}
})
//这里是一开始,传入watch函数的时候就已经调用了,所以它获取到的是旧数据
oldValue = effectFn()
}
👉测试
- 你兴致冲冲地用一下代码测试
watch(proxy, (newValue,oldValue)=>{
console.log(`新数据为:${newValue.foo}`,`旧数据为:${oldValue.foo}` )
})
console.log('---修改foo属性----')
proxy.foo = '我现在来修改数据'
- 但是却发现打印台的打印出来的消息不是你想要的
- 书上没有谈及这个问题,不过我找了一下,问题在于我们之前存放
fn()得到的结果的时候是直接用赋值,fn()返回的是proxy对象,那么我们oldValue指向的其实就是proxy的地址,当proxy的值改变的时候,oldValue的值自然也就改变了,所以我们这里给他加个深拷贝就好嘞,我们这里可以采用最简单的JSON.parse(JSON.stringify())(虽然它缺陷挺多的.....)
function effect(fn, options = {} ) {
const effectFn = () => {
.....
const res = fn(); //这里
const res = JSON.parse(JSON.stringify(fn())); //改为这个
.....
return res
}
......
}
- 再测试一下,现在的数据就正常了
三、立即执行
- 我们在使用
watch的时候还可以使用immedlate来指定回调是否需要立即执行 - 这个看到其实就感觉很容易实现了,就是把执行的那一部分代码放在
watch函数里面嘛,我一共做了两步- 将执行的代码抽取出来
- 判断
immediate是否为true,从而决定是否立即执行
function watch(source, cb,options) {
//监听传入的source整个对象
let newValue, oldValue;
.....
//把执行的代码抽取到这里
const doJob = () =>{
newValue = effectFn();
cb(newValue, oldValue)
oldValue = newValue;
}
const effectFn = effect(()=>getter(), {
//将lazy设为true
lazy: true,
scheduler() {
//在这里调用是因为它监听的值发生了变化,所以这里调用函数获取得到的值一定为新值
doJob()
}
})
//这里进行判断
if(options.immediate) {
doJob()
}else{
oldValue = effectFn()
}
}
- 注意:我们这里的
oldValue因为立即执行所以是没有赋值的,为undefined
👉测试
- 传入
getter,监听对象为proxy.foo,传入immediate为true
watch(()=>proxy.foo, (newValue,oldValue)=>{
console.log(`新数据为 ${newValue}`)
console.log(`旧数据为 ${oldValue}`)
},{immediate: true})
console.log('---修改foo属性----')
proxy.foo = '我现在来修改数据'
- 打印结果:立即调用
四、码上掘金
- 我将以上使用的代码放在里面了,相关代码打上了注释
六、简单图示
- 画了一下
watch最简单的处理逻辑,其实通过看图也能整明白computed和watch的区别了
五、源码阅读
watch到最后也是去调用dowatch函数,所以我们直接看dowatchdoWatch源码地址:core/apiWatch.ts at main · vuejs/core · GitHub- 太长了,所以我只截取了部分,源码的实现比我们简单的实现逻辑要复杂得多,不过最基本的思路是一样的
- 首先是传参设置
- 接着是处理监听源,因为支持四种,分别为
ref对象,reactive对象,数组,函数,所以分别对他们进行处理,其实就是把监听源统一处理进getter
if (isRef(source)) {
getter = () => source.value
...
} else if (isReactive(source)) {
getter = () => source
deep = true
....
} else if (isArray(source)) {
getter = () =>
source.map(s => {
.....//里面再挨个去判断
})
} else if (isFunction(source)) {
.....
getter = () =>
callWithErrorHandling(source, instance, ErrorCodes.WATCH_GETTER)
}
}
- 在处理
reactive的时候将deep设置为true了,而下面还需要对getter进一步处理 traverse的源码地址:core/apiWatch.ts at main · vuejs/core · GitHub- 这个函数和我们上面实现的
traverse函数意图基本一样,就不赘述了,反正就是让它一整个对象的每个属性都能处于被监听的状态
- 这个函数和我们上面实现的
if (cb && deep) {
const baseGetter = getter
getter = () => traverse(baseGetter())
}
- 接着使用了
job函数来处理调用传入的回调函数,这个函数跟我们上述实现的doJob的操作意图也是差不多的- 调用
effect.run()获取最新值 - 调用
cb函数,传入newValue和oldValue - 将
newValue的值赋值给oldValue
- 调用
const job: SchedulerJob = () => {
.....
const newValue = effect.run()
.....
if(....){
callWithAsyncErrorHandling(cb, instance, ErrorCodes.WATCH_CALLBACK, [
newValue,
oldValue === INITIAL_WATCHER_VALUE ? undefined : oldValue,
onCleanup
])
oldValue = newValue
} else {
// watchEffect
effect.run()
}
}
- 因为
watch支持的API选项有flush:该选项控制副作用的处理时机,flush存在三个值:sync(同步的)、pre(组件更新前,默认值)、post(组件更新后) -------这个我们并没有去实现 - 所以这里进行了判断处理
job作为scheduler调度函数,然后传入ReactiveEffect创建对应的副作用effect函数,
let scheduler: EffectScheduler
if (flush === 'sync') {
scheduler = job as any // the scheduler function gets called directly
} else if (flush === 'post') {
scheduler = () => queuePostRenderEffect(job, instance && instance.suspense)
} else {
job.pre = true
if (instance) job.id = instance.uid
scheduler = () => queueJob(job)
}
const effect = new ReactiveEffect(getter, scheduler)
- 因为
watch支持的API有immediate,为true的时候表示立刻执行,那么就使用上述的job函数去调用传进来的回调函数,此时oldValue为undefined;如果为false,则去调用effect.run(),将结果赋值给旧值,所以在数据变更之后获取的旧数据是有效的
// initial run
if (cb) {
if (immediate) {
job()
} else {
oldValue = effect.run()
}
}... else {
effect.run()
}
六、絮絮念
- 通过四篇文章的整理输出,到这里其实就差不多把响应式系统整理完了,感觉这个过程更多学到的是提出问题后解决问题的能力
- 实现一遍后再去看源码也轻松了很多,毕竟底层逻辑搞懂了
- 这本书尊滴推荐看✌