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。

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

相关推荐
L耀早睡1 分钟前
mapreduce打包运行
大数据·前端·spark·mapreduce
咖啡の猫3 分钟前
JavaScript基础-创建对象的三种方式
开发语言·javascript·ecmascript
MaCa .BaKa13 分钟前
38-日语学习小程序
java·vue.js·spring boot·学习·mysql·小程序·maven
HouGISer15 分钟前
副业小程序YUERGS,从开发到变现
前端·小程序
outstanding木槿21 分钟前
react中安装依赖时的问题 【集合】
前端·javascript·react.js·node.js
小吕学编程1 小时前
Jackson使用详解
java·javascript·数据库·json
霸王蟹1 小时前
React中useState中更新是同步的还是异步的?
前端·javascript·笔记·学习·react.js·前端框架
霸王蟹1 小时前
React Hooks 必须在组件最顶层调用的原因解析
前端·javascript·笔记·学习·react.js
专注VB编程开发20年1 小时前
asp.net IHttpHandler 对分块传输编码的支持,IIs web服务器后端技术
服务器·前端·asp.net
爱分享的程序员2 小时前
全栈项目搭建指南:Nuxt.js + Node.js + MongoDB
前端