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我们得以区分使用哪种方式来更新值以及需要在触发依赖后运行的调度器,同时完成调度功能也使用到了微队列的概念帮助我们控制调度执行的时机。

相关推荐
2401_8576009525 分钟前
SSM 与 Vue 共筑电脑测评系统:精准洞察电脑世界
前端·javascript·vue.js
2401_8576009525 分钟前
数字时代的医疗挂号变革:SSM+Vue 系统设计与实现之道
前端·javascript·vue.js
GDAL26 分钟前
vue入门教程:组件透传 Attributes
前端·javascript·vue.js
轻口味31 分钟前
Vue.js 核心概念:模板、指令、数据绑定
vue.js
2402_8575834937 分钟前
基于 SSM 框架的 Vue 电脑测评系统:照亮电脑品质之路
前端·javascript·vue.js
java_heartLake1 小时前
Vue3之性能优化
javascript·vue.js·性能优化
ddd君317743 小时前
组件的声明、创建、渲染
vue.js
前端没钱3 小时前
从 Vue 迈向 React:平滑过渡与关键注意点全解析
前端·vue.js·react.js
顽疲4 小时前
springboot vue 会员收银系统 含源码 开发流程
vue.js·spring boot·后端
羊小猪~~4 小时前
前端入门之VUE--ajax、vuex、router,最后的前端总结
前端·javascript·css·vue.js·vscode·ajax·html5