Code前端首页关于Code前端联系我们

Vue3手动触发watch的3+实用场景全解析?有没有比nextTick更顺滑的方法?

terry 3小时前 阅读数 35 #Vue

很多接触Vue3的开发者刚上手watch时,都会遇到一个问题:明明监听的数据是自己设置的,为什么有时候想主动让watch的回调函数执行一遍,却找不到直接的API?或者直接把监听的数据再赋值一次,结果要么没效果(值没变浅比较通过),要么带了一堆副作用(值变了导致其他地方联动)?其实Vue3官方确实留了“后门”,而且不同场景下用不同的方法,体验完全不一样,今天就从最基础的原理讲起,把所有实用场景、对应方法、坑点以及比nextTick更适合的替代方案,都掰碎了说清楚。

手动触发Vue3 watch的本质:绕开watch的“值变化触发”机制

先别着急找API,先搞懂Vue3的watch是怎么工作的——这能帮你理解为什么有些方法有用,有些没用,甚至有些方法会给自己挖大坑。

Vue3的watch底层是用Effect模块实现的,简单说就是“给被监听的数据源绑上一个副作用,当数据源的响应式标记变化时(比如ref的value赋值后,Proxy对象的set/get被触发且触发了依赖追踪链),就执行这个副作用”。

那手动触发的核心是什么?就是不改变数据源的情况下,直接调用这个被绑定的副作用函数,不过Vue3官方没有直接把这个副作用暴露给普通开发者,而是通过一些间接的API或者技巧来实现。

这里得先澄清一个很多人犯的错误:以为直接把数据源再赋一遍同样的值就能触发,比如const count = ref(0); count.value = 0;,结果watch没有任何反应——这是因为Vue3的浅比较(watch默认对ref和reactive的根属性做浅比较)判断值没变,就不会触发依赖更新,更不会调用watch的回调,那如果是reactive的深层属性呢?比如const obj = reactive({a: {b: 0}}); obj.a.b = 0;——哦,这里可能有例外!如果是没有开启deep监听的普通watch,同样不会触发;如果开启了deep,那不管值变没变,只要触发了深层Proxy的set,Vue3都会执行副作用——但这完全是靠“误打误撞”触发了深层依赖更新,副作用里可能会带很多你没预料到的性能损耗,因为deep监听会遍历整个对象的所有属性(递归),哪怕你只改了一个没用的深层属性的重复值。

靠重复赋值触发watch是不可靠且不推荐的,我们还是要用官方留的“合规”方法。

初始化数据时立即执行一次watch回调——官方自带的“immediate”方案

这应该是最常见的手动触发场景了:比如我们页面加载时,要根据某个搜索关键词从后端获取数据,而且搜索关键词变化时也要重新获取——这时候总不能在created/onMounted里写一遍获取逻辑,watch里再写一遍吧?太冗余了,而且修改逻辑的时候容易漏。

这时候官方的immediate选项就是为这个准备的,直接在watch的第三个参数(options对象)里设置immediate: true,watch会在第一次绑定依赖的时候,立即执行一次回调函数——完全不需要手动调用其他东西。

举个简单的例子:

<script setup>
import { ref, watch } from 'vue'
import { getList } from '@/api/list'
const keyword = ref('Vue3')
const list = ref([])
const loading = ref(false)
// 监听keyword变化,同时初始化时立即获取一次数据
watch(
  keyword,
  async (newVal, oldVal) => {
    // 这里可以加个判断:如果newVal和oldVal都是空字符串,就不用请求——不过immediate第一次执行时oldVal是undefined
    loading.value = true
    try {
      const res = await getList({ keyword: newVal })
      list.value = res.data
    } catch (error) {
      console.error('获取列表失败', error)
    } finally {
      loading.value = false
    }
  },
  {
    immediate: true, // 开启立即执行
    // 如果keyword是个空串,第一次执行可能会拿到不需要的数据,这里可以配合beforeUpdate或者逻辑判断处理
  }
)
</script>

这个方法太基础了,可能很多人都知道,但为什么还要单独拿出来说?因为这里有个容易被忽略的坑:immediate第一次执行时,oldVal是undefined,如果你的回调函数里用到了oldVal做比较(只有newVal和oldVal不一样才清空搜索历史”),第一次执行时oldVal是undefined,可能会误触发一些逻辑,这时候可以加个简单的判断:if (oldVal === undefined) return;或者if (oldVal === newVal) return;——不过后者在初始化时也会跳过,所以得看你的具体需求。

还有一个点:immediate是同步执行的,也就是说,如果你的watch绑定在某个组件的setup里,immediate第一次执行时,组件还没有挂载到DOM上——如果你在回调函数里用到了DOM操作(比如获取某个元素的高度),肯定会报错,这时候怎么办?可以把DOM操作放在watch的回调函数里的nextTick包裹,或者用onMounted单独处理初始化时的DOM操作,或者改用watchEffect——等等,watchEffect有没有immediate?其实watchEffect默认就是立即执行的,而且不需要设置immediate选项,这也是watch和watchEffect的核心区别之一。

没有开启immediate,但某个操作后需要强制刷新watch的计算结果——官方留的“flush + ref.value = ref.value + 0”小技巧?

等等,刚才不是说重复赋值不可靠吗?怎么这里又提了?别急,先看清楚前提:是“flush + 特定条件下的重复赋值”,而且这个技巧有特定的适用场景,性能也可控。

我们得了解watch的另一个核心选项:flush,flush有三个值:

  1. 'pre'(默认值):在组件更新之前执行回调函数,这样可以避免回调函数修改数据后导致组件多次更新;
  2. 'post':在组件更新之后执行回调函数,适合需要操作DOM的场景;
  3. 'sync':同步执行回调函数,只要数据一变(不管是不是在同一个tick里),就立即执行——这个性能损耗最大,一般不推荐用,除非你有特殊需求(比如实时同步本地存储的高频操作)。

刚才的场景一我们说了immediate,但如果有这样的情况:你的watch没有开启immediate(比如初始化时不需要获取数据,只有用户点击“重置搜索”按钮时才需要),或者开启了immediate但第一次执行后数据被缓存了,现在需要重新执行watch回调,但又不想改变监听数据的真实值(因为真实值改变会影响其他地方的显示,比如搜索框的内容)——这时候怎么办?

如果你的监听数据是ref类型的基本数据类型(比如number、string、boolean),那可以用一个“伪赋值”技巧:count.value = count.value + 0;或者keyword.value = keyword.value + '';,哦,等一下,刚才说浅比较同样的值不会触发,那为什么这里可以?因为——JavaScript里的+操作符会返回一个新的基本数据类型值吗?不对,不对,比如const a = 1; const b = a + 0; console.log(a === b);——这是true啊!哦,那刚才的说法有问题,ref的基本数据类型浅比较同样的值还是不会触发,那刚才的技巧应该怎么改?对了,如果你的监听数据是ref类型的引用数据类型(比如对象、数组),那伪赋值技巧就有用了:比如const obj = ref({a: 1}); obj.value = { ...obj.value };——这会返回一个新的对象引用,浅比较(===)就会失败,从而触发watch的回调,而且监听的深层属性(比如a:1)没有变,不会影响其他依赖深层属性的地方。

举个例子:

<script setup>
import { ref, watch } from 'vue'
import { getList } from '@/api/list'
const searchParams = ref({
  keyword: 'Vue3',
  page: 1,
  pageSize: 10
})
const list = ref([])
const loading = ref(false)
// 没有开启immediate,因为初始化时不需要自动获取数据,等用户点击搜索按钮
watch(
  searchParams,
  async (newVal) => {
    loading.value = true
    try {
      const res = await getList(newVal)
      list.value = res.data
    } catch (error) {
      console.error('获取列表失败', error)
    } finally {
      loading.value = false
    }
  },
  {
    deep: true, // 因为searchParams是个对象,要监听深层属性变化
    flush: 'pre' // 默认值,在组件更新前执行,避免多次更新
  }
)
// 点击搜索按钮时,直接调用watch的副作用——哦,等等,我们现在还没讲怎么直接获取副作用,所以先用伪赋值技巧
const handleSearch = () => {
  // 这里不需要修改searchParams的任何属性,只需要给它一个新的引用
  searchParams.value = { ...searchParams.value }
}
// 点击重置按钮时,先把searchParams重置为初始值,然后再触发一次watch(不过初始值重置时如果值变了,watch会自动触发,所以可能不需要伪赋值)
const initialParams = {
  keyword: 'Vue3',
  page: 1,
  pageSize: 10
}
const handleReset = () => {
  searchParams.value = { ...initialParams }
  // 如果初始值和当前值完全一样(比如用户重置了好几次),那watch不会自动触发,这时候可以再加一次伪赋值
  searchParams.value = { ...searchParams.value }
}
</script>

这个伪赋值技巧虽然能用,但也有几个明显的缺点:

  1. 只适用于ref类型的引用数据类型,基本数据类型或者reactive类型(reactive类型不能直接给根对象赋值,否则会失去响应式)都用不了;
  2. 如果开启了deep监听,伪赋值会触发整个对象的深层依赖遍历,虽然属性值没变,但还是会有一些性能损耗;
  3. 代码不够直观,其他开发者看到searchParams.value = { ...searchParams.value }可能会一脸懵逼,不知道你在干嘛。

那有没有更通用、更直观、性能更好的方法?当然有——那就是官方留的“隐藏API”:手动获取watch返回的stop函数里的effect.run方法

通用场景下的强制刷新——利用watch返回值里的隐藏effect.run

刚才我们说过,Vue3的watch底层是用Effect模块实现的,watch会返回一个stop函数,用来停止监听,那这个stop函数是怎么来的?其实Effect模块的effect函数会返回一个带stop方法的副作用函数本身——也就是说,const stop = watch(...)里的stop,其实就是effect函数返回的副作用函数的stop方法的引用?不对,等一下,我们可以看一下Vue3的源码(简化版):

// Vue3 Effect模块简化源码
function effect(fn, options = {}) {
  const effectFn = () => {
    // 依赖收集和触发的逻辑
  }
  effectFn.stop = () => {
    // 停止监听的逻辑
  }
  // 如果options.immediate为true,立即执行一次effectFn
  if (options.immediate) {
    effectFn()
  }
  return effectFn
}
// Vue3 watch简化源码
function watch(source, cb, options = {}) {
  let oldVal
  // 把source转换成getter函数
  const getter = () => {
    // 依赖收集的逻辑
  }
  // 创建一个副作用函数
  const effectFn = effect(
    () => {
      const newVal = getter()
      // 如果newVal和oldVal不一样,或者开启了deep/immediate,就执行cb
      if (newVal !== oldVal || options.deep || options.immediate) {
        cb(newVal, oldVal)
        oldVal = newVal
      }
    },
    {
      ...options,
      // watch默认不立即执行effectFn,除非options.immediate为true
      lazy: !options.immediate
    }
  )
  // 第一次执行effectFn获取oldVal(如果lazy为false的话,已经执行过了)
  if (options.lazy) {
    oldVal = effectFn()
  }
  // 返回stop函数,其实就是effectFn.stop
  return () => effectFn.stop()
}

哦,原来如此!Vue3的watch函数里,effect函数返回的带stop方法的副作用函数effectFn,被赋值给了一个局部变量,但watch返回的只是effectFn.stop的引用,并没有把effectFn本身暴露出来,那怎么办?难道我们要改Vue3的源码?不用不用,我们可以用一个小技巧“截胡”这个effectFn:在options的onTrack或者onTrigger钩子函数里,保存effectFn的引用

对!Vue3的watch(还有computed、watchEffect)都有两个可选的钩子函数:

  1. onTrack:当被监听的数据源被依赖收集时触发(比如第一次执行watch的getter函数时,或者其他地方用到了被监听的数据源时);
  2. onTrigger:当被监听的数据源触发依赖更新时触发(比如数据源的值变了,或者伪赋值触发了)。

这两个钩子函数的参数都是一个trackEvent或者triggerEvent对象,这个对象里有一个effect属性,就是我们要找的带stop方法和run方法的副作用函数本身!

那具体怎么操作?我们可以先创建一个变量来保存effect的引用,然后在watch的options里添加onTrack或者onTrigger钩子函数,把effect赋值给这个变量,不过这里有个小问题:onTrack会多次触发(只要有地方依赖收集被监听的数据源),而onTrigger只有在依赖更新时才会触发——如果我们的watch没有开启immediate,也没有触发过依赖更新,那onTrigger就不会执行,effect的引用就保存不下来,所以最好用onTrack钩子函数,因为不管watch有没有开启immediate,有没有触发过依赖更新,第一次执行watch的getter函数时(也就是watch初始化时),都会触发onTrack。

等一下,刚才的简化源码里,watch的lazy默认是true(也就是没有开启immediate时),会先执行一次effectFn()来获取oldVal——这时候会不会触发onTrack?当然会!因为执行effectFn()的时候,会执行getter函数,getter函数会访问被监听的数据源,从而触发依赖收集和onTrack钩子函数。

太好了,那我们就可以用这个方法来保存effect的引用了!保存下来之后,什么时候想手动触发watch的回调,直接调用effect.run()就可以了——完全不需要改变数据源的值,也不会有deep监听的性能损耗,代码也很直观。

举个通用的例子,不管是ref还是reactive,不管是基本数据类型还是引用数据类型,不管有没有开启immediate/deep,都能用:

<script setup>
import { ref, reactive, watch } from 'vue'
import { getList } from '@/api/list'
// 基本数据类型的ref
const count = ref(0)
// 引用数据类型的ref
const searchParams = ref({
  keyword: 'Vue3',
  page: 1
})
// reactive类型
const userInfo = reactive({
  name: '张三',
  age: 25
})
// 用来保存三个watch的effect引用
let countEffect = null
let searchParamsEffect = null
let userInfoEffect = null
// 监听基本数据类型的ref
watch(
  count,
  (newVal, oldVal) => {
    console.log('count变化了', newVal, oldVal)
  }
)
// 监听引用数据类型的ref(开启deep)
watch(
  searchParams,
  (newVal, oldVal) => {
    console.log('searchParams变化了', newVal, oldVal)
  },
  {
    deep: true,
    onTrack: (e) => {
      // 保存effect引用
      searchParamsEffect = e.effect
    }
  }
)
// 监听reactive的多个属性
watch(
  [() => userInfo.name, () => userInfo.age],
  ([newName, newAge], [oldName, oldAge]) => {
    console.log('userInfo变化了', { newName, newAge }, { oldName, oldAge })
  },
  {
    onTrack: (e) => {
      // 保存effect引用
      userInfoEffect = e.effect
    }
  }
)
// 手动触发count的watch——哦,等一下,count的watch没有加onTrack,那我们怎么保存它的effect?没关系,我们可以在创建watch的时候,直接用一个闭包或者函数来截胡
// 重新创建count的watch,加上onTrack
if (countEffect === null) {
  // 不过刚才已经创建过一次了,虽然没有保存stop,但最好还是先停止之前的watch——不过刚才的简化源码里,watch返回的是stop函数,我们之前没保存,所以没办法停止,所以最好在第一次创建watch的时候就加上onTrack
}
// 刚才的失误,现在重新写count的watch,加上onTrack和stop函数的保存
// 先把之前的count清空重写(实际开发中不要这么做,应该一开始就设计好)
// 重新定义count的watch
const stopCountWatch = watch(
  count,
  (newVal, oldVal) => {
    console.log('count变化了', newVal, oldVal)
  },
  {
    onTrack: (e) => {
      countEffect = e.effect
    }
  }
)
// 手动触发三个watch的回调函数
const handleTriggerAll = () => {
  console.log('准备手动触发所有watch')
  // 直接调用effect.run()
  if (countEffect) countEffect.run()
  if (searchParamsEffect) searchParamsEffect.run()
  if (userInfoEffect) userInfoEffect.run()
}
// 测试一下:先点击按钮,然后再改变count的值,看看有什么区别
count.value = 1 // 会自动触发count的watch
</script>
<template>
  <div>
    <p>count: {{ count }}</p>
    <p>searchParams: {{ searchParams }}</p>
    <p>userInfo: {{ userInfo }}</p>
    <button @click="handleTriggerAll">手动触发所有watch</button>
    <button @click="count = count + 1">count + 1</button>
  </div>
</template>

这个方法完美解决了之前伪赋值技巧的所有缺点:

  1. 通用:不管是ref还是reactive,不管是基本数据类型还是引用数据类型,不管有没有开启immediate/deep,都能用;
  2. 直观:直接调用effect.run(),其他开发者一看就知道你在手动触发watch的回调;
  3. 性能好:完全不需要改变数据源的值,也不会有deep监听的性能损耗(除非你手动调用的是开启了deep的watch的effect.run(),但这时候你是主动触发的,有心理准备);
  4. 可控:你可以随时保存和删除effect的引用,也可以随时调用stop函数停止监听。

不过这里也有一个需要注意的坑:手动调用effect.run()时,oldVal的值是上一次watch回调执行时的newVal,而不是undefined或者其他值,比如刚才的例子,当count.value=1自动触发watch后,oldVal变成了1之前的0;然后手动调用countEffect.run(),newVal是1,oldVal是0——和自动触发时的情况一样,这时候如果你在回调函数里用到了oldVal做比较,可能会和你预期的不一样——不过这也不是什么大问题,只要你清楚oldVal的来源就可以了。

还有一个小问题:如果你的watch监听的是多个数据源(比如数组形式的source),那手动调用effect.run()时,会获取所有数据源的最新值,然后一起传给回调函数的newVal数组——这和自动触发时的情况也是一样的,没问题。

有没有比nextTick更顺滑的手动触发DOM相关的watch的方法?

刚才我们说过,watch的flush选项有三个值,其中'post'是在组件更新之后执行回调函数,适合需要操作DOM的场景,那如果我们的watch没有设置flush为'post',但手动触发时需要操作DOM,怎么办?

很多人的第一反应是用nextTick包裹effect.run()——

import { nextTick } from 'vue'
const handleTriggerDOMWatch = () => {
  nextTick(() => {
    if (domWatchEffect) domWatchEffect.run()
  })
}

这个方法当然能用,但有没有更“原生”的方法?其实Vue3的Effect模块的effect函数的options里,有一个scheduler选项,可以用来自定义副作用函数的执行时机——不过watch的options里没有直接暴露scheduler选项,但我们可以通过修改effect的scheduler来实现吗?或者——我们可以直接用watch的flush选项的另一种用法?

哦,对了!刚才我们截胡到的effect对象,除了有stop方法和run方法之外,还有一个scheduler属性——不过这个属性是只读的吗?我们可以试一下(不过实际开发中最好不要修改Vue3内部对象的属性,除非你非常清楚你在做什么):

// 假设我们有一个domWatchEffect,它的默认scheduler是'pre'或者'sync'
// 我们可以临时把它的scheduler改成'post'的实现,然后调用run(),再改回来
const originalScheduler = domWatchEffect.scheduler
domWatchEffect.scheduler = (job) => {
  nextTick(job)
}
domWatchEffect.run()
domWatchEffect.scheduler = originalScheduler

这个方法虽然能用,但太麻烦了,而且修改Vue3内部对象的属性可能会有兼容性问题(比如Vue3的版本更新后,scheduler的实现变了),所以最推荐的方法还是在创建watch的时候,就根据是否需要操作DOM,设置好flush选项——如果有时候需要操作DOM,有时候不需要,那可以创建两个watch:一个设置flush为'pre',用来处理数据逻辑;一个设置flush为'post',用来处理DOM逻辑。

或者——你可以用watchEffect,然后在watchEffect里用nextTick包裹DOM操作,因为watchEffect默认就是立即执行的,而且可以随时停止。

手动触发Vue3 watch的方法怎么选?

现在我们已经讲了四种手动触发Vue3 watch的方法(其实场景四是场景三的延伸),现在来总结一下怎么选:

  1. 初始化数据时立即执行一次:优先用官方自带的immediate: true选项;如果回调函数里需要操作DOM,可以用immediate: true + flush: 'post',或者用onMounted单独处理初始化时的DOM操作,或者用watchEffect + nextTick;
  2. 没有开启immediate,但某个操作后需要强制刷新,且监听数据是ref类型的引用数据类型:可以用伪赋值技巧ref.value = { ...ref.value },但最好还是用场景三的方法;
  3. 通用场景下的强制刷新:优先用onTrack钩子函数截胡effect引用,然后手动调用effect.run()的方法,这个方法最通用、最直观、性能最好;
  4. 手动触发DOM相关的watch:优先在创建watch的时候设置好flush: 'post',然后用场景三的方法手动触发;如果没有设置flush,就用nextTick包裹effect.run()。

再提醒大家一句:手动触发watch虽然方便,但不要滥用——如果你的代码里经常需要手动触发watch,可能说明你的组件设计有问题,比如数据流向不清晰,或者逻辑耦合太严重,这时候可以考虑用computed、pinia/vuex状态管理、或者自定义事件来重构代码,让数据和逻辑更清晰。

版权声明

本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。

热门