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警告:
也就是说数据更新后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获取清理函数列表,并将我们自己传入的cleanupFn
push到清理函数队列中。
let cleanups = cleanupMap.get(owner)
if (!cleanups) cleanupMap.set(owner, (cleanups = []))
cleanups.push(cleanupFn)
那cleanups队列中的函数什么时候执行?大胆猜测,应该是每次执行watch回调前被执行。例如以下代码,当newId更新后,我们看到的打印顺序:onWatcherCleanup
、watch
。
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内部选择合适的时机触发监听器。
代码中的job
为函数类型,负责调用入参的cb,Vue会从性能方面考量cb的触发时机,也可通过pre
、post
、sync
设置触发时机,在合适的时机触发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。
回想上文提到的问题:如何解决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的值,日志打印顺序为onWatcherCleanup
、callback
,如下图所示:
结果符合预期,但其实想一想,以上的方法不能说完全相同,简直和上文提到的解决async
方法一模一样, onCleanup只是onWatcherCleanup
的套壳而已。
总结
未解之谜:既然有onCleanup了为什么还提出onWatcherCleanup
?
不管是watch的回调cb,还是watchEffect的回调,都支持onCleanup参数。由于onCleanup是回调形式,不管是同步、还是异步都可支持,而onWatcherCleanup还不支持异步,所以对于Vue初学者来说,个人认为onCleanup
更加稳妥。
畅想:Vue3.5为ReactiveEffect、EffectScope、WatchHandle提供了pause、resume等函数,Vue的单元测试都是基于EffectScope提供上下文,开发者是不是也可以基于这些API提供更底层的能力封装?
我是
前端下饭菜
,原创不易,各位看官动动手,帮忙关注、点赞、收藏、评轮!