vue3源码-watch的简易实现

在vue3中提供了watchAPI可以用来侦听一个getter函数、一个ref函数生成的实例对象或者是一个响应式对象等数据。在被侦听的数据发生变化的时候触发回调函数,回调函数接收新值和旧值作为参数,而且回调函数是懒执行的。

这次通过实现一个简易的watch函数来帮助我们更深刻的理解watch的监听能力和懒执行能力背后的逻辑。

scheduler调度系统

在vue关于computedwatch的代码中都存在这样一个概念:scheduler调度器。作为一个完整的scheduler调度系统包括两个部分的实现:

  1. lazy:懒执行。computed懒执行传入的getter函数;watch懒执行的是传入的在数据变化时触发的回调函数
  2. scheduler:调度器。computed中的scheduler执行的是触发ComputedRefImpl实例对象的依赖,也就是调用了computedObj.valueeffect函数;

懒执行

ts 复制代码
// effect的额外参数
export interface ReactiveEffectOptions {
  lazy?: boolean
  scheduler?: EffectScheduler
}

// effect函数基于传入的执行函数生成一个ReactiveEffect实例,目的是分开存储逻辑和执行操作
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
  // 生成 ReactiveEffect实例
  const _effect = new ReactiveEffect(fn)
  // 如果存在 options.lazy === true 的情况下就不会立即执行一次
  if (!options || !options.lazy) {
    _effect.run()
  }
}

懒执行相对比较简单,只需要增加一个lazy参数用于判断是否立即执行run函数。

scheduler调度器

调度器的实现比较复杂,主要分为两部分作用:

  1. 控制执行顺序
  2. 控制执行规则

控制执行顺序

html 复制代码
<script>
  const { reactive, effect } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(() => {
    console.log(obj.count);
  })

  obj.count = 2

  console.log('代码结束');

</script>

正常情况下打印顺序应该是 1 2 代码结束 ,现在如果我们希望修改触发依赖的逻辑来改变打印顺序为 1 代码结束 2 应该怎么改呢?答案是可以利用触发依赖来运行替代函数scheduler,因为effect函数在声明时会触发run函数,而在obj.count触发set value函数时会触发依赖triggerEffect,如果依赖对象存在scheduler方法则运行调度器而非run函数。

html 复制代码
effect(
  () => {
    console.log(obj.count);
  },
  {
    scheduler() {
      setTimeout(() => {
        console.log(obj.count)
      })
    }
  }
)

effect传递额外的参数设置scheduler调度器。

ts 复制代码
export function effect<T = any>(fn: () => T, options?: ReactiveEffectOptions) {
    // 生成 ReactiveEffect 实例
    const _effect = new ReactiveEffect(fn)

+   // 存在 options,则合并配置对象
+   if (options) {
+     Object.assign(_effect, options)
+   }

    if (!options || !options.lazy) {
      // 执行 run 函数
      _effect.run()
    }
}

effect中增加合并参数对象到ReactiveEffect实例对象中的步骤。

到这里再尝试打印一遍,结果已经变成 1 打印结束 2 了。

控制执行规则

html 复制代码
<script>
  const { reactive, effect, queuePreFlushCb } = Vue

  const obj = reactive({
    count: 1
  })

  // 调用 effect 方法
  effect(() => {
    console.log(obj.count);
  }, {
    scheduler() {
      queuePreFlushCb(() => console.log(obj.count))
    }
  })

  obj.count = 2
  obj.count = 3

  console.log('同步代码结束');

</script>

如果我们希望控制触发打印时机为所有同步代码结束后,打印结果为 1 代码结束 3 3 ,那么使用异步执行就是理所应当的答案了。大致思路是将本来应该执行的函数在同步执行的阶段先收集起来,然后将依次执行函数的函数立即添加到微队列(事件循环的概念)中。

ts 复制代码
// 对应promise的状态
let isFlushPending = false

// promise.resolve()的快捷方式
const resolvedPromise = Promise.resolve() as Promise<any>

// 当前执行的任务
let currentFlushPromise: Promise<void>

// 待执行的任务队列:收集容器
const pendingPreFlushCbs: Function[] = []

// 队列预处理函数:暴露出去的接收回调函数的函数
export function queuePreFlushCb(cb: Function) {
  queueFlushCb(cb, pendingPreFlushCbs)
}

// 队列处理函数:收集回调函数
function queueFlushCb(cb: Function, pendingQueue: Function[]) {
  // 将所有的回调函数放入任务队列中
  pendingQueue.push(cb)
  queueFlush()
}

// 依次处理队列中的执行函数
function queueFlush() {
  if (!isFlushPending) {
    // 开始等待函数执行
    isFlushPending = true
    // 本质上是将 flushJobs 函数立即放入到微队列
    currentFlushPromise = resolvedPromise.then(flushJobs)
  }
}

// 处理队列:同步代码运行完毕运行的函数
function flushJobs() {
  // 等待完毕,函数准备开始执行
  isFlushPending = false
  flushPreFlushCbs()
}

// 依次处理队列中的任务
function flushPreFlushCbs() {
  if (pendingPreFlushCbs.length) {
    // 将pending状态转换为active
    let activePreFlushCbs = [...new Set(pendingPreFlushCbs)]
    // 清空pending的队列
    pendingPreFlushCbs.length = 0
    // 循环执行active队列
    for (let i = 0; i < activePreFlushCbs.length; i++) {
      activePreFlushCbs[i]()
    }
  }
}

此时再运行实例代码,打印成功,顺序也没问题。说明成功将收集到的scheduler调度器函数放入到了微队列中并在同步代码的最后赋值完成后触发,于是打印了两次3。

初步实现watch侦听器

ts 复制代码
// watch 配置项属性
export interface watchOptions<Immediate = boolean> {
  immedaite?: Immediate
  deep?: boolean
}

// watch函数
export function watch(source, cb: Function, options?: watchOptions) {
  return doWatch(source as any, cb, options)
}

function doWatch(
  source,
  cb: Function,
  { immedaite, deep }: watchOptions = EMPTY_OBJ
) {
  // 第一部分:收集依赖
  let getter: any

  // 判断source是否为reactive实例对象
  if (isReactive(source)) {
    getter = () => source
    // reactive只能传入对象,deep默认为true
    deep = true
  } else {
    getter = () => {}
  }

  if (cb && deep) {
    // 收集依赖:在传入的reactive实例对象中递归进行get操作
    const baseGetter = getter
    getter = () => traverse(baseGetter())
  }
  
  // 第二部分:构建effect
  let oldValue = {}

  // job执行方法:获取当前值并执行回调
  const job = () => {
    if (cb) {
      const newValue = effect.run()
      if (deep || hasChange(newValue, oldValue)) {
        cb(newValue, oldValue)
        oldValue = newValue
      }
    }
  }

  // 调度器
  let scheduler = () => queuePreFlushCb(job)
  // 依赖触发的两种方式:run触发getter,triggerEffect触发scheduler
  const effect = new ReactiveEffect(getter, scheduler)

  // 第三部分:同步代码获取第一次执行的oldValue
  if (cb) {
    if (immedaite) {
      job()
    } else {
      oldValue = effect.run()
    }
  } else {
    effect.run()
  }

  return () => {
    effect.stop()
  }
}
// 递归地访问value中的属性
export function traverse(value: unknown) {
  // 当当前访问的属性不是函数后结束递归
  if (!isObject(value)) {
    return value
  }

  for (const key in value as object) {
    traverse((value as any)[key])
  }

  return value
}

这一段有点长,但是从功能上区分主要可以分成三个部分:

  1. 收集依赖:先判断传入的参数得是reactive生成的proxy对象,核心在于traverse函数,它递归地遍历对象中所有的键值,相当于进行一次get value
  2. 构建effect:这里是最巧妙的地方,根据getterjob构建了一个effect依赖,当effect.run()时调用getter函数既可以获取到当前最新值,同时也触发了get value,这样就将当前的effect收集到了,然后在对应的属性发生set value的操作时,就会触发effect.scheduler(),也就是将job函数立即添加到微队列中,在所有同步代码执行完毕后依次执行收集到的所有jobjob的作用就是获取当前值赋值给newValue,newValue赋值给oldValue,同时执行一次回调函数。
  3. 判断immediate状态,需不需要立即执行一次job()。

总结

watch本质上还是依赖于ReactiveEffect的实现的一个收集依赖触发依赖 的过程,区别在于此时的收集依赖是被动完成 的,通过runscheduler我们得以区分使用哪种方式来更新值以及需要在触发依赖后运行的调度器,同时完成调度功能也使用到了微队列的概念帮助我们控制调度执行的时机。

相关推荐
冯宝宝^1 小时前
基于mongodb+flask(Python)+vue的实验室器材管理系统
vue.js·python·flask
cc蒲公英2 小时前
Vue2+vue-office/excel 实现在线加载Excel文件预览
前端·vue.js·excel
森叶2 小时前
Electron-vue asar 局部打包优化处理方案——绕开每次npm run build 超级慢的打包问题
vue.js·electron·npm
小小竹子2 小时前
前端vue-实现富文本组件
前端·vue.js·富文本
青稞儿3 小时前
面试题高频之token无感刷新(vue3+node.js)
vue.js·node.js
程序员凡尘4 小时前
完美解决 Array 方法 (map/filter/reduce) 不按预期工作 的正确解决方法,亲测有效!!!
前端·javascript·vue.js
Bug缔造者9 小时前
Element-ui el-table 全局表格排序
前端·javascript·vue.js
xnian_10 小时前
解决ruoyi-vue-pro-master框架引入报错,启动报错问题
前端·javascript·vue.js
罗政10 小时前
[附源码]超简洁个人博客网站搭建+SpringBoot+Vue前后端分离
vue.js·spring boot·后端
阿树梢11 小时前
【Vue】VueRouter路由
前端·javascript·vue.js