从零实现Vue响应系统(二、computed与watch)

从零实现Vue响应系统(二、computed与watch)

在上一篇我们已经大致理解了 Vue 的响应式系统的基础架构是如何实现的,本篇我们将继续深入,实现 computed 和 watch 这两个重要的特性。

5298字

本文中的完整代码地址:https://github.com/nonhana/demo-vue/blob/main/src/js/responsiveData.js

调度执行

有时候我们想要在触发副作用函数重新执行的时候,自己手动操控副作用函数的执行时机、次数以及方式。这个就是所谓的 可调度性。同时,这也是 Vue 中实现 watchcomputed 等响应式 API 的最重要的基础设施。

给个非常简单的例子:

javascript
effect(() => {
  console.log(obj.count)
})

obj.count++

console.log('运行结束')

如果这么写,我们的运行结果会是:

但是,如果想要调一下顺序,比如我想把 “运行结束” 放到 0 和 1 的中间,有没有什么办法能够在不调整代码的情况下完成呢?这个时候,响应系统就需要支持手动调度了

不能改变实际应用的代码本身,那么我们需要从 effect 函数的参数下手。我们需要设计一个选项参数 options,允许用户指定调度器,这个调度器可以让我们自己写一些对于这个副作用函数的执行逻辑。这里的 fn 参数就是上面这个副作用函数,可以看成是一个回调参数的传入。

javascript
effect(
  () => {
    console.log(obj.count)
  },
  {
    scheduler(fn) {},
  }
)

我们可以看到这第二个参数是一个对象,它就是我们集中设置调度方式的配置对象。其中有一个 scheduler,它允许我们指定具体的调度函数。

相应的,我们需要修改 effect 函数使其支持第二个参数:

javascript
// 副作用函数注册函数
function effect(effectFn, options = {}) {
  const fn = () => {
    cleanup(effectFn)
    activeEffectFn = effectFn
    effectFnStack.push(effectFn) // 在调用之前先把这个副作用函数压入栈
    effectFn()
    effectFnStack.pop() // 在调用之后再把这个副作用函数弹出栈
    activeEffectFn = effectFnStack[effectFnStack.length - 1] // 恢复上一个副作用函数
  }
  effectFn.options = options
  effectFn.deps = [] // 函数也是对象哦
  fn()
}

加上了 options 之后,我们就可以在 trigger 阶段手动调用 scheduler ,使其按照用户想要的方式进行触发:

javascript
// 触发副作用函数的重新执行
function trigger(target, key) {
  const depsMap = bucket.get(target)
  if (!depsMap)
    return
  const effects = depsMap.get(key)

  const effectsToRun = new Set()
  effect
  && effects.forEach((effectFn) => {
    if (effectFn !== activeEffectFn) {
      effectsToRun.add(effectFn)
    }
  })
  effectsToRun.forEach((effectFn) => {
    if (effectFn.options.scheduler) {
      effectFn.options.scheduler(effectFn)
    }
    else {
      effectFn()
    }
  })
}

如果当前的副作用函数存在手动传入的 scheduler,我们就可以把这个副作用函数作为参数传入到 scheduler 当中,然后执行我们自己写的函数执行逻辑就可以了。

好的, 接下来我们就可以自己手动实现这个 scheduler 里面的具体逻辑以完成需求了。

javascript
effect(
  () => {
    console.log(obj.count)
  },
  {
    scheduler(fn) {
      // 这里写逻辑,在这里把 fn 扔到下一轮宏任务
      setTimeout(fn)
    },
  }
)

我们可以看到执行结果按照我们的预期输出了:

但是有时候,我们也不一定只是想要控制副作用函数的执行流程,可能还想控制执行次数。很多时候我们会对一个值进行连续的操作:

javascript
effect(() => {
  console.log(obj.count)
})

obj.count++
obj.count++

一般的输出是直接输出 0 1 2,但是我们只是想要的是最终的结果,也就是输出 0 2 而省略掉中间的过渡状态。

我们也可以同样基于 scheduler 来改写代码,不过需要处理一些额外的数据结构。

javascript
const jobQueue = new Set()
const p = Promise.resolve()

let isFlushing = false
function flushJob() {
  if (isFlushing)
    return
  isFlushing = true
  p.then(() => {
    jobQueue.forEach(job => job())
  }).finally(() => {
    isFlushing = false
  })
}

effect(
  () => {
    console.log(obj.count)
  },
  {
    scheduler(fn) {
      jobQueue.add(fn)
      flushJob()
    },
  }
)

obj.count++
obj.count++

这里的实现方法稍微有点复杂。我们可以一点点来分析。

首先是这里的 jobQueue,也就是任务队列,它是一个 Set,目的是为了 自动去重

这里的 scheduler 在每次触发的时候,都会将当前的副作用函数添加到 jobQueue 中,并且执行 flushJob 来刷新任务列表。

flushJob 函数利用了 Promise.then 的回调,将 “遍历 jobQueue 并执行任务” 这一工作放到了微任务里面,每次调用这个函数的时候,都会先判断 isFlushing 这个标志位,看需不需要重新执行。

然后我们看实际的代码执行流程。可以看到有两个连续的 obj.count++,那么 scheduler 实际上会被连续的执行两次,jobQueue 会被压入两个相同的 fn,但是因为其去重特性最后只会有一个。flushJob 函数也因为 isFlushing 的存在,在执行第一个 obj.count++ 的时候 isFlushing 已经被置为 true了,之后的 obj.count++ 实际上不会做任何的事情。

而当 flushJob 开始执行时,由于微任务执行顺序在同步代码之后的特性,两个 obj.count++ 实际上是会先执行,之后才轮到微任务。不得不说这里的 .then 用的是真巧妙 所以在执行副作用函数前 obj.count 的值已经是 2 了。所以这样就实现了期望的输出。

顺带一提,Vue 中多次连续修改响应式数据(比如 ref )最后也只会触发一次更新,原理跟这个类似。

计算属性computed与lazy

在我的第一段实习期间,由于我本人 Vue 的基本功还不算特别扎实,甚至还没有所谓的 响应式驱动 的编程思想,如果涉及到某些要根据响应式变量而变化的内容,我的第一反应往往还是直接往 {{ }} 里面写 n 元表达式。经过我的 mt 拷打过后,我才渐渐地养起了 能用 computedwatch 绝对不自己造驱动函数 的思想。后来的实践也证明,我的这一做法是对的。

lazy延迟副作用函数执行

其实基于上面的一些实现,我们已经可以初步的自己造一个计算属性 computed 出来了,不过在此之前还是得先稍微铺垫一些背景。

如果是一步步跟到这里的,应该会察觉到目前的 demo 响应系统的 effect 函数实际上是会立即执行一次的。但是很多时候我们肯定不是想让其立即执行的,而是只听我们 trigger 的话执行,类似于 Vue3 里面的 watch 不带 immediate 选项。

这个时候我们就可以往 options 里面塞东西来告诉 effect 函数,说这里面的副作用函数不要立马执行:

javascript
effect(
  () => {
    console.log(obj.name)
  },
  {
    lazy: true,
  }
)

接下来我们改一下 effect 以处理这个新的配置项:

javascript
// 副作用函数注册函数
function effect(effectFn, options = {}) {
  const fn = () => {
    cleanup(effectFn)
    activeEffectFn = effectFn
    effectFnStack.push(effectFn) // 在调用之前先把这个副作用函数压入栈
    effectFn()
    effectFnStack.pop() // 在调用之后再把这个副作用函数弹出栈
    activeEffectFn = effectFnStack[effectFnStack.length - 1] // 恢复上一个副作用函数
  }
  effectFn.options = options
  effectFn.deps = [] // 函数也是对象哦
  if (!options.lazy) {
    fn() // 如果没传 lazy,立即执行
  }
  return fn
}

这样子可以不立即执行了,并且我们拿到了经过加工后的副作用函数,执行了他就可以进行注册了。

我们可以拿着它到任何我们想要调用的地方进行调用:

javascript
const effectFn = effect(
  () => {
    console.log(obj.name)
  },
  {
    lazy: true,
  }
)

effectFn()

基于lazy的computed实现

不过这跟我们的计算属性又有什么关系呢?

实际上,我们平常写的副作用函数可能跟 Rust 里面的表达式语句差不多,它可能仅仅是一个箭头函数,提供一个返回值,然后我们 可以手动调用它并拿到返回值

javascript
const effectFn = effect(() => obj.age + obj.count, { lazy: true })

const value = effectFn()

看看,是不是跟我们平常写的计算属性差不多?

typescript
const num1 = ref<number>(0)
const num2 = ref<number>(12)
const total = computed(() => num1.value + num2.value)

为了能够做到这一点,我们可以改改 effect,使其能够保存并返回我们的计算结果:

javascript
// 副作用函数注册函数
function effect(effectFn, options = {}) {
  const fn = () => {
    cleanup(effectFn)
    activeEffectFn = effectFn
    effectFnStack.push(effectFn) // 在调用之前先把这个副作用函数压入栈
    const res = effectFn()
    effectFnStack.pop() // 在调用之后再把这个副作用函数弹出栈
    activeEffectFn = effectFnStack[effectFnStack.length - 1] // 恢复上一个副作用函数
    return res
  }
  effectFn.options = options
  effectFn.deps = [] // 函数也是对象哦
  if (!options.lazy) {
    fn() // 如果没传 lazy,立即执行
  }
  return fn
}

改完啦,我们先稍微测测看上面的代码:

成功了!接下来我们手写一下计算属性:

javascript
/** 计算属性 computed */
function computed(getter) {
  const effectFn = effect(getter, { lazy: true })
  const obj = {
    get value() {
      return effectFn()
    },
  }
  return obj
}

这里的 getter 函数实际上就是类似于 () => obj.age + obj.count 这样的返回一个单纯 JS 表达式的函数。把它作为副作用函数,然后创建一个 lazy 的包装后的副作用函数 effectFn。然后就是创建一个对象,这个对象里面有个 get 访问器属性,在使用 obj.value 调用的时候会自动触发 effectFn 函数,然后这个函数我们刚刚改过了,可以直接返回 getter 执行后的结果。

我们来测!

javascript
// 原始数据
const data = {
  num1: 1,
  num2: 2,
}

// 对原始数据使用 Proxy 进行代理
const obj = new Proxy(data, {
  // ...
})

// ...

const sumRes = computed(() => obj.num1 + obj.num2)
console.log(sumRes.value)

你将会看到输出结果是很干净的 3

不过我们都知道吧,Vue3 里面的 computed 是会对值进行 缓存 的。我们如果把 console.log(sumRes.value) 多写几遍,目前的实现实际上是会每一次访问都执行一遍 effectFn 的,对于复杂的 getter 来说开销是很大很大的。

我们来试试给 computed 塞一个缓存的功能:

javascript
/** 计算属性 computed */
function computed(getter) {
  let value // 用来缓存上一次的值
  let dirty = true // 标记是否需要重新计算值,true 表示需要重新算

  const effectFn = effect(getter, {
    lazy: true,
    // 加个调度器,当 getter 里面依赖项的值发生变化就说明得重新算一遍 value 了
    // 所以要把 dirty 置为 true
    scheduler() {
      dirty = true
    },
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn()
        dirty = false // 每次更新完 value 的值,都先把 dirty 给置为 false
      }
      return value
    },
  }
  return obj
}

我们利用闭包,加了 valuedirty 变量。

  • value 就是缓存本身,缓存上一次的值,如果不需要重新计算,就直接返回它。
  • dirty 是一个标识符,标识在当前的调用中是不是需要重新计算一遍缓存的值。

obj.value 这里的逻辑很简单,当 dirty 为 true 的时候说明得重新算,那就直接执行一遍 effectFn 拿到值给 value,然后把 dirty 置为 false,为了下次不重复计算。如果 dirty 就是 false,那就直接 return value 就行了。

不过问题在于,我们需要在 effect 那边注册 getter 的时候,需要传入一个 scheduler 函数来自定义逻辑。我们知道 getter 本身其实就是我们的 副作用函数,因此它里面的响应式变量发生变化的时候是会重新执行一遍 getter 函数的,但是因为 getter 函数的作用只是单纯的计算没有任何副作用,甚至可以说就是一个纯函数,所以在计算函数中它的重新执行可以说是没啥用。而自定义的 scheduler 则为其根据响应式变量变化而重新执行这一行为赋予了意义——重新把 dirty 变为 true 以在下一次调用 obj.value 的时候重新刷新 value 值。

这样子我们的缓存基本上就算实现完了。

计算属性的依赖注册

基于上面的实现,我们目前的计算属性基本可以说是可以使用了,不过还是有一点小瑕疵。我们可以写写这样的代码:

javascript
const sumRes = computed(() => obj.num1 + obj.num2)

effect(() => {
  console.log(sumRes.value)
})

obj.num1++
obj.num1++
obj.num1++

写出这样的代码,我们肯定想的是,这个 sumRes 是计算属性,并且依赖于 obj.num1obj.num2 ,那么我改了 obj.num1 之后同样把 sumRes 也改了,肯定会重新触发 console.log(sumRes.value) 吧?但是现在还不行,目前只能打印一次。

问题是什么呢?我们可以回头看看 sumRes.value 干了啥,它被调用的时候实际上是会执行一遍 effectFn ,而 effectFn 本身就是 effect 的执行结果。我们都知道如果给 effect 传了 lazy 的选项,那么它的返回值就是 包装好的副作用函数 fn,而实际上最开始不传 lazy 的时候,effect 的立即执行也就是这个包装好的副作用函数。所以实际上,在 effect 里面调用 sumRes.value 的时候,执行包装好的副作用函数,在执行的时候 activeEffect 实际上只是传入 computed 里面的 getter 函数而已,而 () => console.log(sumRes.value) 这个副作用函数显然并没有被注册到。所以这实际上是因为 effect 嵌套,外层 effect 没有使用到内层的 effect 的响应式变量,导致外层的这个副作用函数没能注册到内层的响应式变量对应的 key。

为了更直观一点,我们可以直接把 computed 的过程写在 effect 里面:

javascript
effect(() => {
  const effectFn = effect(() => obj.num1 + obj.num2, { lazy: true })
  console.log(effectFn())
})

这样是不是就清楚了?在执行内层的 effect 的时候,我们用到了响应式变量 obj.num1obj.num2,而外层没用到这两个变量。 如果在外层也用一下实际上就能立马解决这个问题:

javascript
const sumRes = computed(() => obj.num1 + obj.num2)

let temp1, temp2
effect(() => {
  console.log(sumRes.value)
  temp1 = obj.num1
  temp2 = obj.num2
})

obj.num1++
obj.num1++
obj.num1++

看一下结果:

所以解决的办法就是类似于上面的方式,我们可以在调用计算属性的 value 的时候,手动的在依赖列表里面把 obj.value 给注册一下 就可以了。

javascript
/** 计算属性 computed */
function computed(getter) {
  let value // 用来缓存上一次的值
  let dirty = true // 标记是否需要重新计算值,true 表示需要重新算

  const effectFn = effect(getter, {
    lazy: true,
    scheduler() {
      if (!dirty) {
        dirty = true // 标记 dirty,让下一次访问重新计算
        trigger(obj, 'value') // 通知依赖
      }
    },
  })

  const obj = {
    get value() {
      if (dirty) {
        value = effectFn() // 调用 effectFn,重新计算值
        dirty = false // 计算完成后重置 dirty 状态
      }
      track(obj, 'value') // 注册依赖
      return value
    },
  }

  return obj
}

我们手动使用 track 函数把 obj.value 作为依赖追踪的目标对象和其目标属性。这样子写之后,我们再来执行一遍最开始的那部分代码,实际上是会构筑成这样的数据结构:

这个时候,我们无论重复修改多少次 getter 里面的代码,都能够正常的触发 effect 内部的代码了。

watch 的实现原理

基础实现

当然,Vue 中著名的响应式 API 除了 computed 以外就是 watch 了。watch 的本质就是观测一个响应式数据,当数据变化时通知并执行相应的回调函数。

我们通常在开发中都会这么写:

javascript
watch(obj, () => {
  console.log('数据发生了变化')
})

obj.num1++

obj 本身就是一个响应式数据,使用 watch 来对其进行观测,当响应式数据发生变化后,触发回调函数的执行。

根据上面对于 computed 的实现,我们应该都能够猜出来,watch 肯定也是利用了 effect 以及 options.scheduler 来进行实现的。

在之前的例子中,我们都知道了 options.scheduler 是用户手动指定的触发原副作用函数的执行逻辑,它会自动把原副作用函数当作参数传进来,然后执行用户自定义的规则。

不过问题是,它传进来原副作用函数,我们非得执行它吗?不用吧? 实际上这个 options.scheduler 就相当于一个回调函数,我们可以手动的写我们自己的逻辑而完全不用到我们的原来的副作用函数。所以可以利用这个特点,手动写一个 watch。它需要接受两个参数,一个是响应式数据源,一个是回调函数。

javascript
/** watch 监听响应式数据 */
function watch(source, callback) {
  effect(() => traverse(source), {
    scheduler() {
      callback()
    },
  })
}

// 递归地读取数据,触发 Proxy 的 get 进行依赖收集
function traverse(value, seen = new Set()) {
  if (typeof value !== 'object' || value === null || seen.has(value))
    return
  seen.add(value)
  for (const k in value) {
    traverse(value[k], seen)
  }
  return value
}

可以看到代码比较简单,watch 由用户手动的指定了回调函数,在 options.scheduler 里面就单纯的执行了一下而已。重点在于依赖的收集。

我们可能传进去的就是一个很单纯的响应式数据对象,而我们可能只改其中的一些字段。因此我们需要将响应式对象里面的所有属性全都注册起来,确保无论修改多深层次的属性都能够被响应。所以这就需要一个递归读取函数 traverse 来实现。

接收getter函数

我们平时在写代码的时候,对于一些非响应式的变量的监听,除了使用 toRefs() 进行包装以外,我们还会使用 getter 函数对其进行包装,然后传给 watch

javascript
watch(
  () => obj.num1,
  () => {
    console.log('变了,变了啊!')
  }
)

为了适配可能传入一个 getter 函数的情况,我们需要稍微修改一下 watch 接受到参数之后的处理:

javascript
/** watch 监听响应式数据 */
function watch(source, callback) {
  let getter // () => void
  if (typeof source === 'function') {
    getter = source
  }
  else {
    getter = () => traverse(source)
  }
  effect(() => getter(), {
    scheduler() {
      callback()
    },
  })
}

我们改用 getter 函数来统一处理传入的 source,如果为函数,那就直接进行赋值;如果是单纯的响应式对象,那么就调用 traverse 进行递归读取。然后,在 effect 函数内部执行 getter 就可以了。

newValue与oldValue

我们都知道,watch 这个 API 最强大并且有特色的功能就是 能够获取被监视对象的新值和旧值。为了实现这一点,我们要借助 effectoptions.lazy 选项。

javascript
/** watch 监听响应式数据 */
function watch(source, callback) {
  let getter // () => void
  if (typeof source === 'function') {
    getter = source
  }
  else {
    getter = () => traverse(source)
  }

  let oldValue, newValue
  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      newValue = effectFn()
      callback(newValue, oldValue)
      oldValue = newValue
    },
  })
  oldValue = effectFn()
}

lazy 本身就是为了能够让用户手动去调用副作用函数而生的一个选项,并且如果副作用函数是单纯的 getter,那么更是能够直接拿到执行后的返回值。因此在这里,每次要监听的数据发生变化后,就会重新执行 options.scheduler,在执行的时候就可以手动调用 effectFn 拿到最新的值,然后使用 callbacknewValueoldValue 都传出去。最后就是更新 oldValue,给下一次做准备。

立即执行的watch与回调执行时机

如果我们想要让 watch 里面的回调函数在响应式数据变化之前按照同步代码的方式立即执行一次,我们通常会往 watch 里面传入第三个参数:{immediate: true}

为了实现这一点,我们要稍微修改一下 watch 函数的参数,使其能够接收 options

javascript
/** watch 监听响应式数据 */
function watch(source, callback, options = {}) {
  let getter // () => void
  if (typeof source === 'function') {
    getter = source
  }
  else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  const job = () => {
    newValue = effectFn()
    callback(newValue, oldValue)
    oldValue = newValue
  }

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler: job,
  })

  if (options.immediate) {
    job()
  }
  else {
    oldValue = effectFn()
  }
}

可以看到,为了能够实现传入 options.immediate 的时候可以手动调用这个 scheduler 的内容,我们需要把原来 scheduler 部分的逻辑单独的拆出来给一个 job 函数,之后根据 options.immediate 的具体值决定是否直接调用。

不过除了 options.immediate 以外,Vue3 中还提供了一个 flush 的参数,能够更加精确地指定回调函数的执行时机,可以选择 'pre' | 'post' | 'sync' 三种。

  • pre:回调在组件更新之前执行,即数据和 DOM 都已经更新完成,回调才会被触发。如果不传 flush 参数, 这是默认值。
  • post:回调在组件更新之后执行,即数据和 DOM 都已经更新完成,回调才会被触发。
  • sync:回调在数据变化的同步任务中立即执行,不等待 Vue 的响应式系统批量处理队列,而是立即执行回调。

我们在这里可以试着写一写传入 'post' 参数时的回调执行逻辑。我们在之前实际上有写过类似的 延迟任务执行 的逻辑,那就是 把同步代码推进微任务队列中。在这里的实现逻辑其实也差不多。

javascript
/** watch 监听响应式数据 */
function watch(source, callback, options = {}) {
  let getter // () => void
  if (typeof source === 'function') {
    getter = source
  }
  else {
    getter = () => traverse(source)
  }

  let oldValue, newValue

  const job = () => {
    newValue = effectFn()
    callback(newValue, oldValue)
    oldValue = newValue
  }

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      if (options.flush === 'post') {
        const p = Promise.resolve()
        p.then(job) // 把 job 放到微任务队列里以实现延迟执行
      }
      else {
        job()
      }
    },
  })

  if (options.immediate) {
    job()
  }
  else {
    oldValue = effectFn()
  }
}

不过肯定大家都看得出来,目前我们实现的响应式系统还没有牵扯上真正的 组件,我们目前的实现方式其实是 'post''sync''pre' 在目前还没办法实现。不过算是稍微明白了 watch 底层是怎么处理的,之后我读到组件那里了我再慢慢填坑吧。

过期的副作用

在上面,我们其实已经实现了一个相对比较完善的 watch API 了,不过还剩下下一个我们经常写但是下意识忽视掉的一个问题:竞态问题

我们平时肯定有写过那些 分页获取数据 的逻辑吧?一般不用那些乱七八糟的缓存请求库的做法是自己写一个 const page = ref(0) 这样的响应式变量,然后通过一些逻辑让它变化,之后通过 watch 来监听它,它发生变化后,能够触发数据的重新获取。

我们来基于上面的实现,自己模拟一下:

javascript
async function fetchData(page, pageSize) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        page,
        pageSize,
        list: Array.from({ length: pageSize }, (_, i) => i + 1),
      })
    }, 1000)
  })
}

let finalData
watch(obj, async () => {
  const res = await fetchData(obj.page, obj.pageSize)
  finalData = res
})

是不是看起来非常非常的正常?我们平时都这么写的。但是我们都忽视了一个问题,如果 fetchData 这个异步函数执行的时间很久,在其返回结果之前我们可能就会修改 obj 的数据,这个时候肯定会发起第二次请求,这个时候我们就不知道到底哪个请求先返回值 ,如果第二次快一点,finalData 的值就是第一次请求的值了。

不过按照我们一般的思维,后发的请求肯定是我们期望更新到的最终数据 。所以我们应当把请求 B 视为最新的,而请求 A 则应该被视为 过期的副作用 从而不再起作用,相当于后面触发的请求 B 把 A 覆盖了。

所以我们需要一个让前一个副作用函数过期的手段。在现版本的 Vue3 中,watch 的回调函数实际上是可以接收第三个参数 onInvalidate。它是一个函数,我们可以用它注册一个回调,使其在当前副作用函数过期的时候执行。

我们可以利用这第三个参数,改写一下 watch 内部的具体逻辑:

javascript
let finalData
watch(obj, async (_, __, onInvalidate) => {
  let expired = false
  onInvalidate(() => {
    expired = true
  })

  const res = await fetchData(obj.page, obj.pageSize)

  if (!expired) {
    finalData = res
    console.log('finalData:', finalData)
  }
})

我们在回调函数内部注册了一个 expired 来标识当前的这个副作用函数是否过期。当当前副作用函数过期时,会自动调用 onInvalidate 内部的回调函数,把这个标识位置为 true。如果过期了,很明显就不执行了,从而只确保执行没有过期的那个副作用函数。

那么这个 onInvalidatewatch 内部到底是怎么实现的呢?同样也是利用 scheduler,在每次被监听的响应式数据发生变化后,在副作用函数执行之前都自己手动调用一下 onInvalidate 内部传的回调,如果用户有传的话:

javascript
/** watch 监听响应式数据 */
function watch(source, callback, options = {}) {
  let getter // () => void
  if (typeof source === 'function') {
    getter = source
  }
  else {
    getter = () => traverse(source)
  }

  let oldValue, newValue
  let cleanup // 保存过期回调函数

  function onInvalidate(fn) {
    cleanup = fn
  }

  const job = () => {
    newValue = effectFn()
    // 注意在回调函数执行之前调用过期回调
    if (cleanup) {
      cleanup()
    }
    callback(newValue, oldValue, onInvalidate)
    oldValue = newValue
  }

  const effectFn = effect(() => getter(), {
    lazy: true,
    scheduler() {
      if (options.flush === 'post') {
        const p = Promise.resolve()
        p.then(job) // 把 job 放到微任务队列里以实现延迟执行
      }
      else {
        job()
      }
    },
  })

  if (options.immediate) {
    job()
  }
  else {
    oldValue = effectFn()
  }
}

这样子我们就能够实现,即便传入 watch 内部的回调是一个需要等待的异步函数并且监听的响应式数据连续不断的发生变化,最后拿到的结果也是最后一次发生变化后触发的回调函数的结果。不过前面的请求,该发的也都会发,只是最后不给 finalData 赋值。 所以后面可能还有优化的空间,不过已经发出去的请求确实收不回来了。

我们可以来测试一下:

javascript
async function fetchData(page, pageSize) {
  return new Promise((resolve) => {
    setTimeout(() => {
      resolve({
        page,
        pageSize,
        list: Array.from({ length: pageSize }, (_, i) => i + 1),
      })
    }, 1000)
  })
}

let finalData
watch(obj, async (_, __, onInvalidate) => {
  let expired = false
  onInvalidate(() => {
    expired = true
  })

  const res = await fetchData(obj.page, obj.pageSize)

  if (!expired) {
    finalData = res
    console.log('finalData:', finalData)
  }
})

obj.page++
setTimeout(() => {
  obj.page++
}, 200)

看一下输出结果:

可以看到,很准确的只输出了最后一次变更后的结果,obj.page 原本是 1。

这样子,对于 watch 的简单版本实现,我们目前算是告一段落了。

评论0