尤大,这样解决Vue3.5 watch和onCleanup异步Bug,可好?

1,072 阅读7分钟

Vue3.5为watch、watchEffect量身打造数据清理函数onWatcherCleanup,如果watch回调函数为同步执行,确实好用。但只要涉及到async异步,使用不当极可能出现不可预知Bug。虽然官方有说明onWatcherCleanup不能使用在await调用之后,但不能假设使用者都阅读过官方文档。

watch(userId, (newId) => {
  const controller = new AbortController()

  fetch(`/api/${newId}`, { signal: controller.signal }).then(async (res) => {
    detail.value =  await res.json()
  })

  onWatcherCleanup(() => {
    controller.abort()
  })
})

上述代码watch回调函数使用同步方式,在fetch的then函数添加数据更新逻辑,这样在执行onWatcherCleanup时,保持了同步流程。假如userId更新,则传入给onWatcherCleanup的回调函数先被触发,可中断上一次请求。

"不能使用在await调用之后",是什么意思? 如下代码所示,在watch回调函数最后调用onWatcherCleanup,那么当执行userId.value = 200之后,日志会被打印吗?

const userId = ref(100)
watch(userId, async (newId) => {
    const list = await getUserData(newId)
    userList.value = list

    onWatcherCleanup(() => {
        console.log('onWatcherCleanup', Date.now())
    })
}, { immediate: true })

userId.value = 200

如果我们在dev环境运行代码,可打开浏览器cosnole面板查看,不仅没看到日志打印,而且还有vue警告:

image.png

也就是说数据更新后onWatcherCleanup没执行成功,那接下来就根据打印的警告日志,分析分析为什么没执行,以及如何解决aysnc问题。

onWatcherCleanup完整签名如下,入参中:failSilently确认是否需要静默警告信息,仅在Dev环境适用;owner和Watch是一对一关系,类型为ReactiveEffect,缺省等于activeWatcher

export function onWatcherCleanup(
  cleanupFn: () => void,
  failSilently = false,
  owner: ReactiveEffect | undefined = activeWatcher,
): void

先提出两个个问题:

  • owner缺省的activeWatcher从哪来,为什么可能为空?
  • 什么场景可以显式指定owner?

要回答清楚这两个问题,需要在watch函数中找答案,我在《Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁》中也有介绍watch底层原理。在分析watch之前,我们先假设已经了解了activeWatcher的来源,先把onWatcherCleanup代码介绍完。

onWatcherCleanup本身就十来行代码,什么情况会提示警告信息? owner为空 && DEV运行环境 && !静默,三个条件同时满足则会打印上文提示。

if (owner) {
    ...
  } else if (__DEV__ && !failSilently) {
    warn(
      `onWatcherCleanup() was called when there was no active watcher` +
        ` to associate with.`,
    )
  }

如果owner有值,则以owner为key,从cleanupMap获取清理函数列表,并将我们自己传入的cleanupFnpush到清理函数队列中。

let cleanups = cleanupMap.get(owner)
if (!cleanups) cleanupMap.set(owner, (cleanups = []))
cleanups.push(cleanupFn)

那cleanups队列中的函数什么时候执行?大胆猜测,应该是每次执行watch回调前被执行。例如以下代码,当newId更新后,我们看到的打印顺序:onWatcherCleanupwatch

watch(userId, (newId) => {
    console.log('watch')

    onWatcherCleanup(() => {
        console.log('onWatcherCleanup')
    })
})

owner缺省的activeWatcher从哪来,为什么可能为空?

export function watch(source, cb?,options): WatchHandle {
  ...
  effect = new ReactiveEffect(getter)
  effect.scheduler = scheduler
    ? () => scheduler(job, false)
    : (job as EffectScheduler)
  ...
}

每一个watch都会实例化一个ReactiveEffect,并设置scheduler属性,ReactiveEffect的作用是什么?

watch完整流程,可阅读这部分

source下每一个可监听的属性都对应有依赖项集合deps,当调用这些变量的getter时,ReactiveEffect会把自身注入到依赖集合deps中,这样每当执行变量的setter时,deps集合中的副作用都会触发,而每个副作用effect内部会调用scheduler, scheduler可理解为调度器,负责处理视更新时机,scheduler内部选择合适的时机触发监听器。 image.png

代码中的job为函数类型,负责调用入参的cb,Vue会从性能方面考量cb的触发时机,也可通过prepostsync设置触发时机,在合适的时机触发cb的逻辑交由scheduler全权负责。

现在有了effect对象,但它就是activeWatcher所需要的吗?什么时候赋给activeWatcher?

由于onWatcherCleanup在cb中执行,而cb包含在job函数中,接下来我们分析job函数逻辑,看从中是否能够找到答案。

  const job = (immediateFirstRun?: boolean) => {
    if (cb) {
      // watch(source, cb)
      const newValue = effect.run()
      if (hasChanged(newValue, oldValue)) {
        // cleanup before running cb again
        if (cleanup) {
          cleanup()
        }
        const currentWatcher = activeWatcher
        activeWatcher = effect
        try {
          const args = [
            newValue,
            oldValue,
            boundCleanup,
          ]
          cb!(...args)
          oldValue = newValue
        } finally {
          activeWatcher = currentWatcher
        }
      }
    } 
  }

通过effect.run()获取最新的source值,然后判断值有没有更新,仅当有更新才会触发cb回调。

首先会执行cleanup函数,我们在前文介绍onWatcherCleanup函数时有提到cleanups列表,没错,cleanups列表将在cleanup函数中被调用,如下代码所示,从cleanupMap获取当前effect对应的cleanups,遍历并执行每一项清理回调函数, 如前文中的() => { console.log('onWatcherCleanup')}函数,执行完回调列表后,还得从cleanupMap中清理effect。

  cleanup = effect.onStop = () => {
    const cleanups = cleanupMap.get(effect)
    if (cleanups) {
      for (const cleanup of cleanups) cleanup()
      cleanupMap.delete(effect)
    }
  }

回到job函数,终于看到我们关注的activeWatcher,先通过currentWatcher缓存activeWatcher,然后将实例化的effect付给activeWatcher,其目的是当执行cb时就能拿到当前effect上下文了。

const currentWatcher = activeWatcher 
activeWatcher = effect

获取到activeWatcher后,就可以安心执行cb函数了,cb的函数签名为cb(newValue, oldValue, onCleanup):void,记住onCleanup,后面要用到。

const args = [ newValue, oldValue, boundCleanup, ] 
cb!(...args)

认真看这两行代码,因为它已经回答了问题onWatcherCleanup源码中activeWatcher为什么可能为空

cb是同步调用,即使cb回调函数内部有异步代码,job函数依然立即往后执行,最后执行activeWatcher = currentWatcher重置activeWatcher。因此,如果将onWatcherCleanup(() => {})放置在await代码之后,当真执行它的时候activeWatcher已经被置空, 所以console面板会提示"no active watcher"信息。

如何解决async问题?

还记得前文提到cb的第三个参数onCleanup吗?是的,它能解决async问题,onCleanup方法使用如下,代码能保证每次userId更新后,先执行传递给onCleanup的回调函数。

watch(userId, async (newId, oldId, onCleanup) => {
    const list = await getUserData(newId)
    userList.value = list

    onCleanup(() => {
        console.log('onWatcherCleanup', Date.now())
    })
}, { immediate: true })

onWatcherCleanup什么场景可以显式指定owner?

查看Vue源码,也仅watch函数内部一处地方有使用第三个owner参数:

  effect = new ReactiveEffect(getter)
  ...
  boundCleanup = fn => onWatcherCleanup(fn, false, effect)

在生成boundCleanup时,将effect作为owner显式传入给onWatcherCleanup,还记得上文中哪个地方有使用到boundCleanup吗?上文提到的job函数包含如下代码:

const args = [
    newValue,
    oldValue,
    boundCleanup,
]
cb!(...args)

也就是说watch的cb签名cb(newVal, oldVal, onCleanup)中的onCleanup等价于(fn) => onWactcherCleanup(fn, false, effect),等价于如下代码:

watch(userId, (newId, oldId) => {
    onWatcherCleanup(() => {}, false, effect)
})

因为watch内部有实例化effect,所以可以显式传给onWatcherCleanup思考:那在外部我们可以拿到effect吗?

查看Vue3.5发布的CHANGELONG, 关键的提示:getCurrentWatcher, 没错它可以拿到当前的activeWatcher,即当前的effect。

image.png

回想上文提到的问题:如何解决async问题?

在异步场景,如果在await之后使用onWatcheCleanup,由于丢失了activeWatcher,其中的清理回调函数不会被执行。但现在知道可以通过getCurrentWatcher在异步执行前获取activeWatcher,那是不是就可以解决问题了,试一试:

const userId = ref(100)
watch(userId, async (newId) => {
    console.log('callback', newId)
    const effect = getCurrentWatcher() // 显式获取effect
    const list = await getUserData(newId) // mock,使用setTimeout
    userList.value = list

    onWatcherCleanup(() => {
        console.log('onWatcherCleanup', newId)
    }, false, effect)
}, { immediate: true })

setInterval(() => {
    userId.value = 200
}, 300)

在setInterval中更新userId的值,日志打印顺序为onWatcherCleanupcallback,如下图所示:

image.png

结果符合预期,但其实想一想,以上的方法不能说完全相同,简直和上文提到的解决async方法一模一样, onCleanup只是onWatcherCleanup的套壳而已。

总结

未解之谜:既然有onCleanup了为什么还提出onWatcherCleanup?

不管是watch的回调cb,还是watchEffect的回调,都支持onCleanup参数。由于onCleanup是回调形式,不管是同步、还是异步都可支持,而onWatcherCleanup还不支持异步,所以对于Vue初学者来说,个人认为onCleanup更加稳妥。

畅想:Vue3.5为ReactiveEffect、EffectScope、WatchHandle提供了pause、resume等函数,Vue的单元测试都是基于EffectScope提供上下文,开发者是不是也可以基于这些API提供更底层的能力封装

我是前端下饭菜,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!