注册
web

Vue3中watch好用,但watchEffect、watchSyncEffect、watchPostEffect简洁

比较好奇vue项目中使用watch还是watchEffect居多,查看了element-plus、ant-design-vue两个UI库, 整体上看,watch使用居多,而watchEffect不怎么受待见,那这两者之间有什么关系?


APIwatchwatchEffectwatchSyncEffectwatchPostEffect
element-plus1982800
ant-design-vue26316800

watchEffect是watch的衍生


为什么说watchEffect是watch的衍生?



  • 首先,两者提供功能是有重叠。大部分监听场景,两者都能满足。

const list = ref([]);
const count = ref(0);

watch(
list,
(newValue) => {
count.value = newValue.length;
}
)

watchEffect(() => {
count.value = list.value.length;
})
[]>


  • 其次,源码上两者也都是同一出处。以下是两者的函数定义:

export function watch(
source: T | WatchSource
,
cb: any,
options?: WatchOptions
,
): WatchStopHandle {
return doWatch(source as any, cb, options)
}

export function watchEffect(
effect: WatchEffect,
options?: WatchOptionsBase,
): WatchStopHandle {
return doWatch(effect, null, options)
}

两者内部都调用doWatch函数,并且返回都是WatchStopHandle类型。唯独入参上有比较大的区别,watch的source参数就像大杂烩,支持PlainObject、Ref、ComputedRef以及函数类型;而watchEffect的effect参数仅仅是一个函数类型。


watch早于watchEffect诞生,watch源代码有这样一句提示:


if (__DEV__ && !isFunction(cb)) {
warn(
`\`watch(fn, options?)\` signature has been moved to a separate API. ` +
`Use \`watchEffect(fn, options?)\` instead. \`watch\` now only ` +
`supports \`watch(source, cb, options?) signature.`,
)
}

也就是说历史的某一个版本,watch也是支持watch(fn, options?)用法,但为了降低API复杂度,将这部分功能迁移至watchEffect函数。一个优秀框架的发展历程也不过如此,都是在不断的重构升级。


话又说回来,到目前,为什么大部分Vue开发者更偏向于使用watch,而不是watchEffect?,带着这个问题,庖丁解牛式层层分析。


watch、watchEffect底层逻辑


当我们把watch、watchEffect底层逻辑看透,剩下的watchSyncEffect、watchPostEffect也就自然了解。


先回顾下watch、watchEffect内部调用doWatch的参数:


// watch
doWatch(source as any, cb, options)
// demo
watch(
list,
(newValue) => {
count.value = newValue.length;
}
)

// watchEffect
doWatch(effect, null, options)
// demo
watchEffect(() => {
count.value = list.value.length;
})

入参的区别,如下表所示:


APIarg1arg2arg3
watchT | WatchSourcecbWatchOptions
watchEffectWatchEffectnullWatchOptionsBase

根据参数对比,先抛出两个问题:


1. doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?


2. 第三个参数WatchOptions、WatchOptionBase有什么区别?


watchOptions、WatchOptionBase的定义如下:


export interface WatchOptions<Immediate = boolean> extends WatchOptionsBase {
immediate?: Immediate
deep?: boolean
once?: boolean
}

export interface WatchOptionsBase extends DebuggerOptions {
flush?: 'pre' | 'post' | 'sync'
}


WatchOptionsBase仅提供了flush,因此watchEffect函数的第三个参数也只有flush一个选项。
flush包含prepostsync三个值,缺省为pre。它明确了监听器的触发时机,pre和post比较明确,对应渲染前、渲染后。


sync官方定义为:在某些特殊情况下 (例如要使缓存失效),可能有必要在响应式依赖发生改变时立即触发侦听器。简而言之,依赖的多个变量,只要其中一个有更新,监听器就会触发一次。


const list = ref([]);
const page = ref(1);
const message = ref('');

watchEffect(() => {
message.value = `总量${list.value.length}, 当前页:${page.value}`
console.log(message.value);
}, { flush: 'sync' })
[]>

例如上述的list、page任意一个有更新,则会输出一次console。sync模式得慎重使用,例如监听的是数组,其中一项有更新都会触发监听器,可能带来不可预知的性能问题。


post也有明确的应用场景,例如:当页面侧边栏显示或隐藏后,需要容器渲染完成后再更新内部的图表等元素。不使用flush选项的解法,一般是监听visible变化并使用setTimeout延迟更新。有了post,一个属性即可搞定。


watch(visible, (value) => {
setTimeout(() => {
// 更新容器内图表
}, 1000);
})

watch(visible, (value) => {
// 更新容器内图表
}, { flush: 'post' })

完成了第二个问题的解答, 要回答第一个问题,需要深入doWatch函数, 在上一篇《写Vue大篇幅的ref、computed,而reactive为何少见?》也有对doWatch做局部介绍,可以作为辅助参考。


doWatch源码


先从doWatch函数签名上,对其有概括性的认识:


function doWatch(
source: WatchSource | WatchSource[] | WatchEffect | object,
cb: WatchCallback | null,
{
immediate,
deep,
flush,
once,
onTrack,
onTrigger,
}: WatchOptions = EMPTY_OBJ,
): WatchStopHandle

由于我们主要目的是回答问题:doWatch为什么能自动监听WatchEffect函数内的数据变更,并且能重新执行?


因此仅分析source为WatchEffect的情况,此时,cb为null, 第三个参数仅有flush选项。


WatchEffect类型定义如下:


export type WatchEffect = (onCleanup: OnCleanup) => void

onCleanup参数的作用是,在下一次监听器执行前被触发,通常用于状态清理。


doWatch函数实现,最核心的片段是ReactiveEffect的生成:


const effect = new ReactiveEffect(getter, NOOP, scheduler)

为什么ReactiveEffect是其核心?因为它起到了"中介"的作用,在监听器函数内,每一个可监听的变量都对应有依赖项集合deps,当调用这些变量的getter时,ReactiveEffect会把自身注入到依赖集合deps中,这样每当执行变量的setter时,deps集合中的副作用都会触发,而每个副作用effect内部会调用scheduler, scheduler可理解为调度器,负责处理视图更新时机,scheduler内部选择合适的时机触发监听器。


image.png


接下来着重看getter、scheduler定义,当source为WatchEffect类型时,getter定义片段如下:


 // no cb -> simple effect
getter = () => {
if (cleanup) {
cleanup()
}
return callWithAsyncErrorHandling(
source,
instance,
ErrorCodes.WATCH_CALLBACK,
[onCleanup],
)
}

首先执行cleanup,也就是说如果参数传入有onCleanup回调,那么每次在获取新值前都会触发onCleanup。其次是return语句,调用callWithAsyncErrorHandling函数,从函数可探察之,一方面支持异步,另一方面处理异常错误。


支持异步:也就是我们传入的监听器可以是一个异步函数,那么我们可以在其中执行远程请求的调用,例如官方给的示例, 当id.value值变化,从远端请求数据await response,并赋值给data.value。


watchEffect(async (onCleanup) => {
const { response, cancel } = doAsyncWork(id.value)
// `cancel` 会在 `id` 更改时调用
// 以便取消之前未完成的请求
onCleanup(cancel)
data.value = await response
})

上述示例中,如果id.value频繁更新,则会导致触发多次远端请求,要解决该问题,可调用onCleanup(cancel),将cancel传入到doWatch内部,并且每次执行cleanup时被调用。onCleanup定义如下:


let cleanup: (() => void) | undefined
let onCleanup: OnCleanup = (fn: () => void) => {
cleanup = effect.onStop = () => {
callWithErrorHandling(fn, instance, ErrorCodes.WATCH_CLEANUP)
cleanup = effect.onStop = undefined
}
}

其中,fn即为上述示例中的cancel,这样就建立了cancel和cleanup的关联,因此每次更新前,先调用cancel中断上一次请求。


callWithAsyncErrorHandling函数定义如下:


export function callWithAsyncErrorHandling(fn,instance,type,args?): any {
...
const res = callWithErrorHandling(fn, instance, type, args)
if (res && isPromise(res)) {
res.catch(err => {
handleError(err, instance, type)
})
}
return res
...
}

res为fn函数执行结果,由于支持同步、异步。如果fn为异步函数,那么res为Promise类型,并且对异常做了兜底处理。


当fn函数执行后,内部所有可监听变量的deps都会添加上当前effect,所以只要变量有更新,effect的scheduler就被触发。


watchEffect官方定义有:立即运行一个函数,同时响应式地追踪其依赖,并在依赖更改时重新执行。“立即运行一个函数”如何体现?


doWatch函数的最后几行代码如下:


if (flush === 'post') {
queuePostRenderEffect(
effect.run.bind(effect),
instance && instance.suspense,
)
} else {
effect.run()
}

如果flush不为post,那么立即执行effect.run(), 而run函数会调用getter,因此会立即运行监听器函数一次;如果flush为post,那么effect将会在vue下一次渲染前第一次执行effect.run()


至此,我们就分析完watchEffect的底层逻辑,总结其特点:立即执行,支持异步,并且会自动监听变量更新。


为什么不能两者取一,而必须共存


再次回顾watch的定义:


export function watch(
source: T | WatchSource
,
cb: any,
options?: WatchOptions
,
)
: WatchStopHandle {

return doWatch(source as any, cb, options)
}

其中WatchOptions包含的选项有:immediate、deep、once、flush。如果是watchEffect,选项仅有flush,并且immediate相当于true,剩下的deep、once不支持配置。


先说watchEffect的缺点



  • 不支持immediate为false,必须是立即执行。例如下面的代码,由于autoplay默认false,初始化时不需要立即执行。如果是watchEffect,则pauseTimer初始化会执行一次,完全没必要。

watch(
() => props.autoplay,
(autoplay) => {
autoplay ? startTimer() : pauseTimer()
}
)


  • 不支持deep为true的场景,只能见监听当前使用的属性。但如果是调用watch(source, cb, { deep: true }), 则会通过traverse(source)将source所有深度属性读取一次,和effect建立关联,达到自动监听所有属性的目的。
  • 异步使用有坑,watchEffect 仅会在其同步执行期间,才追踪依赖。在使用异步回调时,只有在第一个 await 正常工作前访问到的属性才会被追踪。

再说watchEffect优点


优点也是非常明显,写法非常简洁,无需显式声明监听哪些变量,一个回调函数搞定,并且默认为立即执行,我认为能满足开发中80%的应用场景。另一方面,由于只监听回调中使用的属性,相比于deep为true的一锅端方式,watchEffect则更加直观明了。


总结


watchSyncEffect、watchPostEffect和watchEffect唯一的区别是:flush分别固定为syncpost。所以,watchEffect为watch的衍生,而watchSyncEffect、watchPostEffect为watchEffect的衍生。


对于开发使用上:



  • watchPostEffect、watchSyncEffect仅在极少数的特殊场景下才使用,完全可以用watchEffect(fn, { flush: 'sync' | 'post' })代替,多了反而对入门开发者来说是徒增干扰。
  • 个人认为应优先使用watchEffect函数,毕竟代码写法上更加简洁,属性依赖上也更加明确。满足不了的场景,再考虑使用watch。

作者:前端下饭菜
来源:juejin.cn/post/7401415643981185078

0 个评论

要回复文章请先登录注册