实现Vue3的Watch(附源码浅析)

201 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第7天,点击查看活动详情

要理解这篇文章建议先看以下两篇,了解什么是effect,什么是track,什么是trigger,什么是scheduler,毕竟每一篇的内容都是联系的

一、实现分析

  • 老配方,要实现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 = '我又改了'
  • 打印结果 image.png

👉处理传参

  • 但是你会发现,目前我们的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'
  • 打印结果 image.png

但是感觉监听一整个对象也不是很合心意,本来我只需要监听它一个属性的变化,这下好了,只要有属性变就调用函数

  • 那我们可以给参数多设一种可能:可以传入调用某些需要被监听的属性的函数,这样的话我们就不用全盘接收
  let getter ;
  //如果传入的source为函数,则直接赋值
  if(typeof source === 'function') {
    getter = source
  }else{
  //否则就是按照我们之前的方法,全盘接收
    getter = ()=>traverse(source)
  }

你在测试的过程中就会觉得很不得劲,因为我们不仅是想知道数据改变了,还想要在回调函数能够拿到变更前后的数据

👉拿到变更前后数据

  • 要拿到两个值,就需要在数据变化之前至少执行过一次函数数据变化之后执行一次
  • 也就是说我们需要控制函数调用的时机,把调用的控制权交到自己手里
  • 也就是说我们需要用的实现Computed中用的lazy,如果lazytrue,则暂缓执行该函数,然后再选择合适的时机合适的地方调用它
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 = '我现在来修改数据'
  • 但是却发现打印台的打印出来的消息不是你想要的 image.png
  • 书上没有谈及这个问题,不过我找了一下,问题在于我们之前存放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
  }
    ......
}
  • 再测试一下,现在的数据就正常了 image.png

三、立即执行

  • 我们在使用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,传入immediatetrue
watch(()=>proxy.foo, (newValue,oldValue)=>{
  console.log(`新数据为 ${newValue}`)
  console.log(`旧数据为 ${oldValue}`)
},{immediate: true})
console.log('---修改foo属性----') 
proxy.foo = '我现在来修改数据'
  • 打印结果:立即调用

image.png

四、码上掘金

  • 我将以上使用的代码放在里面了,相关代码打上了注释

六、简单图示

  • 画了一下watch最简单的处理逻辑,其实通过看图也能整明白computedwatch的区别了 image.png

五、源码阅读

  • watch 到最后也是去调用dowatch函数,所以我们直接看dowatch
  • doWatch源码地址: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函数,传入newValueoldValue
    • 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函数去调用传进来的回调函数,此时oldValueundefined;如果为false,则去调用effect.run(),将结果赋值给旧值,所以在数据变更之后获取的旧数据是有效的
  // initial run
  if (cb) {
    if (immediate) {
      job()
    } else {
      oldValue = effect.run()
    }
  }... else {
    effect.run()
  }

六、絮絮念

  • 通过四篇文章的整理输出,到这里其实就差不多把响应式系统整理完了,感觉这个过程更多学到的是提出问题后解决问题的能力
  • 实现一遍后再去看源码也轻松了很多,毕竟底层逻辑搞懂了
  • 这本书尊滴推荐看✌