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

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

API watch watchEffect watchSyncEffect watchPostEffect
element-plus 198 28 0 0
ant-design-vue 263 168 0 0

watchEffect是watch的衍生

为什么说watchEffect是watch的衍生?

  • 首先,两者提供功能是有重叠。大部分监听场景,两者都能满足。
ini 复制代码
const list = ref<User[]>([]);
const count = ref<number>(0);

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

watchEffect(() => {
    count.value = list.value.length;
})
  • 其次,源码上两者也都是同一出处。以下是两者的函数定义:
javascript 复制代码
export function watch(
  source: T | WatchSource<T>,
  cb: any,
  options?: WatchOptions<Immediate>,
): 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源代码有这样一句提示:

less 复制代码
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的参数:

scss 复制代码
// 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;
})

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

API arg1 arg2 arg3
watch T | WatchSource<T> cb WatchOptions<Immediate>
watchEffect WatchEffect null WatchOptionsBase

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

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

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

watchOptions<Immediate>、WatchOptionBase的定义如下:

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

ini 复制代码
const list = ref<User[]>([]);
const page = ref<number>(1);
const message = ref<string>('');

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

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

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

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

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

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

doWatch源码

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

javascript 复制代码
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类型定义如下:

typescript 复制代码
export type WatchEffect = (onCleanup: OnCleanup) => void

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

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

arduino 复制代码
const effect = new ReactiveEffect(getter, NOOP, scheduler)

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

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

scss 复制代码
 // 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。

scss 复制代码
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定义如下:

javascript 复制代码
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函数定义如下:

typescript 复制代码
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函数的最后几行代码如下:

arduino 复制代码
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的定义:

arduino 复制代码
export function watch(
  source: T | WatchSource<T>,
  cb: any,
  options?: WatchOptions<Immediate>,
): 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初始化会执行一次,完全没必要。
scss 复制代码
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。

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

相关推荐
2301_796982149 分钟前
网页打开时,下载的文件text/html/重定向类型有什么作用?
前端·html
重生之我在20年代敲代码10 分钟前
HTML讲解(二)head部分
前端·笔记·html·web app
天下无贼!17 分钟前
2024年最新版TypeScript学习笔记——泛型、接口、枚举、自定义类型等知识点
前端·javascript·vue.js·笔记·学习·typescript·html
计算机学姐1 小时前
基于SpringBoot+Vue的篮球馆会员信息管理系统
java·vue.js·spring boot·后端·mysql·spring·mybatis
小白小白从不日白1 小时前
react 高阶组件
前端·javascript·react.js
程序员大金1 小时前
基于SpringBoot+Vue+MySQL的智能物流管理系统
java·javascript·vue.js·spring boot·后端·mysql·mybatis
Mingyueyixi1 小时前
Flutter Spacer引发的The ParentDataWidget Expanded(flex: 1) 惨案
前端·flutter
徐同保2 小时前
vue 在线预览word和excel
vue.js·word·excel
Rverdoser2 小时前
unocss 一直热更新打印[vite] hot updated: /__uno.css
前端·css