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

Vue3 watch加immediate后怎么避免首次执行踩坑?还有哪些immediate的实用细节你没注意?

terry 54分钟前 阅读数 7 #Vue

Vue3的组合式API里,watch算是用得特别多的一个工具了,用来监听数据变化执行逻辑,但默认情况下watch只在数据变化时才触发——比如你要监听路由参数取详情页数据,页面刚加载时参数已经有了,但watch没反应,还得手动再调一遍接口?这时候大家第一个想到的肯定是加immediate: true,可真用起来,很多人会踩各种奇奇怪怪的坑:比如明明只想处理变化后的逻辑,结果首次执行把初始化不该改的东西给改了;或者用了immediate和deep一起出问题;甚至有时候和computed结合起来数据会乱飘,今天就把关于immediate的所有东西掰碎了讲,从基本作用、核心原理到避坑指南、高阶用法,看完下次用肯定不会出错。

immediate到底是个什么开关?别光知道“首次执行”

很多人对immediate的理解就停留在“加上这个属性,watch会在创建完的瞬间先执行一次handler函数”,这确实是最直观的表现,但背后藏着的和Vue3响应式系统结合的逻辑,才是理解避坑的关键。

先回忆下不带immediate的watch是怎么工作的:Vue3的组合式API里,watch本质上是调用了响应式系统的effect函数,不过给这个effect加了个“懒执行”的标记(lazy: true),还额外包装了一层对比逻辑,懒执行意味着什么?就是effect创建的时候不会立刻跑,只有当你监听的响应式数据发生变化时,才会触发对比——如果是引用类型(比如对象、数组)开启了deep,就会递归对比所有属性;如果是基本类型或者没有开启deep的引用类型,就对比引用地址或者顶层属性——有变化才会执行你写的handler。

那加了immediate之后呢?懒执行的标记会被临时覆盖一次:watch创建完之后,先不管数据有没有变化,直接调用一次带对比逻辑的包装函数?不对,首次执行的时候,对比逻辑里的“旧值”是Vue3内部默认给的undefined(或者说init value占位符),然后直接跳过了对比(或者对比结果默认是“有变化”),先执行一遍handler,等这次执行完,懒执行的标记又会生效,后面就恢复成只有数据变化才触发的状态。

举个不带坑的基础例子感受下:比如你做了个搜索框,监听搜索关键词query,关键词变了就去请求搜索接口,但你希望页面刚加载时如果query有默认值(比如从localStorage里读的上次搜索词),也直接请求,这时候用immediate就刚好:

import { ref, watch } from 'vue'
const query = ref(localStorage.getItem('lastSearch') || '')
const searchResults = ref([])
const fetchSearch = async (newVal) => {
  if (!newVal) return // 空关键词不请求
  // 这里写你的接口请求逻辑
  const res = await mockSearchApi(newVal)
  searchResults.value = res.data
}
// 监听query,加immediate首次执行
watch(query, (newVal, oldVal) => {
  // 首次执行的时候oldVal是undefined哦!这点很重要,后面避坑会反复提
  console.log('搜索词变化了:', newVal, oldVal)
  fetchSearch(newVal)
}, { immediate: true })

这个例子很顺,但为什么很多人用的时候就踩坑?问题就出在首次执行时的旧值是undefined,还有handler里的逻辑没有区分“首次初始化”和“后续数据变化”,甚至有时候和Vue3的生命周期、其他响应式API混在一起没理清顺序。

新手最容易踩的3个immediate大坑,你中过几个?

坑1:首次执行时修改了不该在初始化阶段动的数据

举个真实场景的例子:比如你做了个表单编辑页,从父组件传进来一个editData(props.editData),然后用这个数据初始化本地的formData,接下来你想监听formData的变化,一旦变化就给父组件emit一个“表单已修改”的事件,同时做表单校验。

很多人可能会这么写:

// 错误示例
import { ref, watch, toRefs } from 'vue'
const props = defineProps(['editData'])
const emit = defineEmits(['formDirty'])
// 初始化本地表单
const formData = ref({
  name: '',
  age: 0,
  // ...其他字段
})
// 把props.editData赋值给formData
const initForm = () => {
  formData.value = { ...props.editData }
}
initForm()
// 监听formData变化,emit事件+校验
watch(formData, (newVal, oldVal) => {
  console.log('表单变了', newVal, oldVal)
  emit('formDirty', true)
  validateForm() // 假设这是个校验函数
}, { deep: true, immediate: true })

看起来没问题?页面加载时先初始化formData,然后watch加了immediate首次执行,刚好emit一下?不对!这里有两个大问题: watch的创建是在initForm之后吗?不一定,组合式API的执行顺序是从上到下的,但有时候initForm可能放在异步函数里(比如props.editData是从接口异步拿的,通过父组件v-if控制子组件显示,或者用了onMounted之后才调initForm),这时候watch先创建,immediate先执行,formData还是初始的空值,旧值是undefined,直接就emit了formDirty=true,父组件可能就会显示“您有未保存的修改”,但其实用户还没碰表单呢! 就算initForm是同步的,放在watch之前,immediate首次执行时的“旧值”并不是initForm执行前的初始空值,而是undefined!这时候对比逻辑其实没起作用,直接就执行了emit和校验,同样会导致父组件误触“未保存”提示。

怎么解决这个坑?核心思路是区分首次执行和后续变化执行,不能让首次执行的逻辑和后续的混在一起,有几种常用的方法: 方法一:加一个“是否已初始化”的标记变量,只有在标记为true的时候才执行后续的业务逻辑:

// 正确示例1:加初始化标记
import { ref, watch, toRefs } from 'vue'
const props = defineProps(['editData'])
const emit = defineEmits(['formDirty'])
const formData = ref({
  name: '',
  age: 0,
})
const isFormInited = ref(false) // 初始化标记
const initForm = () => {
  formData.value = { ...props.editData }
  // 等表单完全初始化好之后,再把标记设为true
  isFormInited.value = true
}
initForm()
watch(formData, (newVal, oldVal) => {
  // 只有标记为true,且不是首次初始化的那次immediate执行(或者用oldVal判断)才执行业务
  // 这里推荐两个条件一起用,更保险:旧值不是undefined,且标记已初始化
  if (oldVal !== undefined && isFormInited.value) {
    console.log('表单真的被用户改了', newVal, oldVal)
    emit('formDirty', true)
    validateForm()
  }
}, { deep: true, immediate: true })

为什么要两个条件一起用?比如有时候你异步初始化isFormInited,但watch的immediate已经跑过了,旧值是undefined,刚好过滤;如果同步初始化,但oldVal可能因为某种原因不是undefined(比如用了其他方式提前改了formData),这时候标记能兜底。

不用immediate,而是在初始化函数的最后,手动调用一次handler,但只调用初始化需要的部分?不对,刚才的场景是不想首次调用emit,那刚好可以把初始化需要的逻辑和后续的分开:比如如果是刚才的搜索框场景,想手动调一次fetchSearch,完全可以不用immediate,在initForm(或者localStorage读出来之后)直接调fetchSearch(query.value),这样更灵活,也不会有旧值undefined的问题,不过刚才的表单场景,如果后续还要监听变化,用方法一的标记更合适。

如果你的逻辑必须要区分旧值,比如要对比“变化前和变化后的差值”,那immediate首次执行的时候旧值是undefined,肯定对比不了,这时候要么用方法一的标记跳过首次对比,要么在首次执行的时候给旧值手动赋一个初始的对比值——比如用watchEffect?不对,watchEffect没有旧值,还是得用watch加标记。

坑2:immediate和deep一起用,监听对象时首次执行会遍历所有属性,影响性能?

很多人可能觉得这个是个小坑,但如果监听的是特别大的对象(比如整个后台管理系统的全局状态里的某个大模块),而且频繁创建销毁带这种watch的组件,性能损耗其实是看得见的。

为什么会影响性能?刚才讲原理的时候提了,不带immediate的deep watch,创建时是懒执行的,只是给对象的所有深层属性都加上了响应式追踪的“钩子”,不会立刻递归遍历所有属性做对比,但加了immediate之后呢?虽然首次执行的对比逻辑是直接跳过的(或者旧值是undefined默认有变化),但为了之后能追踪深层变化,Vue3的响应式系统还是会在immediate首次执行前后,对整个对象做一次深层的递归访问——这样才能确保每个深层属性都被track到,下次变化时能触发effect。

那有没有办法避免这个性能问题?得看你的场景: 场景一:你只需要监听对象的顶层属性,不需要深层,那别加deep,直接传顶层属性的数组或者单个ref给watch就行:

// 只监听顶层name和age,不用deep,加immediate也不会有深层遍历
const user = ref({
  name: '张三',
  age: 25,
  address: {
    city: '北京',
    district: '朝阳区'
  }
})
watch([() => user.value.name, () => user.value.age], (newVals, oldVals) => {
  // ...业务逻辑
}, { immediate: true })

这样的话,watch只会追踪name和age这两个顶层属性,不管address怎么变都不会触发,首次执行也只会访问这两个属性,性能损耗几乎为0。

你确实需要监听某个深层的具体属性,比如user.value.address.city,那也别加deep,直接把这个深层属性用函数的形式传进去(或者用toRef取出来成ref再传):

// 只监听深层的city属性
watch(() => user.value.address.city, (newVal, oldVal) => {
  // ...业务逻辑
}, { immediate: true })
// 或者用toRef
import { toRef } from 'vue'
const cityRef = toRef(user.value.address, 'city')
watch(cityRef, (newVal, oldVal) => {
  // ...
}, { immediate: true })

这种方法也不会触发深层遍历,只会追踪city这一个属性,性能最好。

你真的需要监听整个对象的所有深层属性变化,比如用户随便改对象里的任何一个字段都要保存草稿,这时候deep必须加,但immediate的性能损耗能不能避免?其实如果你的初始化逻辑不需要对比旧值,只是想首次执行一下保存草稿(或者检查草稿),可以先不加immediate,等初始化完成之后,手动调用一次handler,这样响应式系统只会在第一次数据变化时才做深层访问?不对,等下,不管加不加immediate,只要你传的是对象且加了deep,watch创建时都会给整个对象加深层的响应式钩子吗?其实不是的——Vue3的响应式系统是“按需track”的,只有当你访问某个属性的时候,才会给它加钩子,那刚才的deep watch不带immediate的时候,是怎么给所有深层属性加钩子的?哦,对了,watch内部有个专门处理deep的函数,叫traverse,它会在effect第一次执行的时候(也就是数据第一次变化的时候),递归遍历整个对象的所有属性并访问,这样才能全部track到,那如果我们不加immediate,手动调用一次handler,但handler里不要访问整个对象的深层属性,是不是就不会触发traverse?不对,watch的deep选项是独立于handler的,只要你开了deep,不管handler里写没写,watch内部都会在effect第一次执行(不管是immediate触发还是数据变化触发)的时候调用traverse,所以如果场景三必须加deep,那immediate的性能损耗是 unavoidable(不可避免的),但可以通过其他方式优化:比如不要频繁创建销毁带这种watch的组件,用v-show代替v-if;或者把大对象拆分成几个小的响应式对象,分别监听小对象,这样每次traverse的范围就小了。

坑3:immediate和computed结合,或者和onMounted/onBeforeMount混在一起,执行顺序乱了?

这个坑稍微进阶一点,但遇到的人也不少,先理清楚Vue3组合式API和生命周期的执行顺序: 组合式API的代码是从上到下同步执行的(除了异步函数),然后是onBeforeMount,然后是组件挂载,然后是onMounted

那watch加了immediate之后,是在什么时候执行的?是在组合式API同步执行到watch这一行的时候,立刻执行一次handler,比onBeforeMount还要早!这点非常重要,很多人搞反了。

举个例子:比如你做了个图表组件,父组件传进来一个chartData(props.chartData),你用这个chartData生成了一个computed的chartOptions,然后在onMounted里初始化echarts实例,接下来你想监听chartOptions的变化,变化了就重新渲染图表,同时希望页面刚加载时(echarts初始化好之后)立刻渲染一次,很多人可能会这么写:

// 错误示例2:执行顺序混乱
import { ref, computed, watch, onMounted } from 'vue'
import * as echarts from 'echarts'
const props = defineProps(['chartData'])
const chartRef = ref(null)
let chartInstance = null
// 生成图表配置的computed
const chartOptions = computed(() => {
  if (!props.chartData) return {}
  return {
    // ...根据props.chartData生成的配置
    xAxis: { data: props.chartData.dates },
    series: [{ data: props.chartData.values }]
  }
})
// 监听chartOptions变化,加immediate首次执行
watch(chartOptions, (newOptions) => {
  if (!chartInstance) return // 没有实例就不渲染
  chartInstance.setOption(newOptions)
}, { deep: true, immediate: true })
// onMounted里初始化echarts
onMounted(() => {
  chartInstance = echarts.init(chartRef.value)
})

看起来很合理?但运行的时候你会发现,页面刚加载时图表是空的!为什么?因为执行顺序是:

  1. 组合式API从上到下执行,先定义了chartRef、chartInstance、chartOptions;
  2. 然后执行watch,加了immediate,立刻执行handler——这时候chartInstance还是null(因为onMounted还没跑),所以直接return了,没有渲染;
  3. 然后才跑onBeforeMount,然后组件挂载,然后onMounted初始化chartInstance,但这时候chartOptions已经没有变化了(除非props.chartData刚好在onMounted之后变了),所以watch不会再触发,图表就空了。

怎么解决这个执行顺序的问题?核心思路是确保handler里的依赖(比如chartInstance、DOM元素)在immediate首次执行之前已经准备好了,或者不用immediate,而是在依赖准备好之后手动触发一次渲染

常用的解决方法有两种: 方法一:把watch放在onMounted里面,这样immediate首次执行的时候,chartInstance已经初始化好了:

// 正确示例2:把watch放在onMounted里
import { ref, computed, watch, onMounted } from 'vue'
import * as echarts from 'echarts'
const props = defineProps(['chartData'])
const chartRef = ref(null)
let chartInstance = null
const chartOptions = computed(() => {
  if (!props.chartData) return {}
  return {
    xAxis: { data: props.chartData.dates },
    series: [{ data: props.chartData.values }]
  }
})
onMounted(() => {
  chartInstance = echarts.init(chartRef.value)
  // 把watch放在这里,immediate首次执行时实例已经有了
  watch(chartOptions, (newOptions) => {
    chartInstance.setOption(newOptions)
  }, { deep: true, immediate: true })
})

这个方法最简单,但要注意:如果你的组件是用keep-alive缓存的,那onMounted只会在第一次挂载时执行,第二次激活时(activated钩子)不会执行watch,这时候你需要在activated里再调一次setOption,或者把watch放在组合式API顶层,但加一个“实例是否已初始化”的标记,同时在activated里手动调一次setOption。

不用immediate,而是在onMounted初始化完实例之后,手动调用一次setOption:

// 正确示例3:不用immediate,手动触发首次渲染
import { ref, computed, watch, onMounted, onActivated, onDeactivated } from 'vue'
import * as echarts from 'echarts'
const props = defineProps(['chartData'])
const chartRef = ref(null)
let chartInstance = null
const chartOptions = computed(() => {
  if (!props.chartData) return {}
  return {
    xAxis: { data: props.chartData.dates },
    series: [{ data: props.chartData.values }]
  }
})
// 顶层的watch,只监听变化,不加immediate
watch(chartOptions, (newOptions) => {
  if (!chartInstance) return
  chartInstance.setOption(newOptions)
}, { deep: true })
const renderChart = () => {
  if (!chartInstance || !chartOptions.value) return
  chartInstance.setOption(chartOptions.value)
}
onMounted(() => {
  chartInstance = echarts.init(chartRef.value)
  renderChart() // 手动触发首次渲染
})
// 处理keep-alive的情况
onActivated(() => {
  chartInstance?.resize()
  renderChart()
})
onDeactivated(() => {
  chartInstance?.dispose()
  chartInstance = null
})

这个方法更灵活,尤其是处理keep-alive的时候,不用担心watch的创建销毁问题,而且顶层的watch可以在组件的整个生命周期里都生效,只要chartInstance存在就会渲染。

immediate除了解决“首次不触发”,还有这3个实用的高阶用法

刚才讲的都是避坑,其实immediate还有很多好用的地方,不止是监听路由取详情页数据这么简单。

用法1:同步响应式数据到非响应式的第三方库

很多第三方库(比如echarts、three.js、高德地图)的实例或者配置不是Vue的响应式数据,这时候你可以用watch加immediate,把Vue的响应式数据同步过去:比如刚才的图表例子,其实就是这个用法的简化版——把computed的chartOptions(响应式)同步到echarts的实例.setOption(非响应式)。

再举个高德地图的例子:比如你有一个响应式的location(经纬度),希望location变化时地图的中心点也跟着变,同时页面刚加载时地图就定位到初始的location:

import { ref, watch, onMounted } from 'vue'
const location = ref({ lng: 116.397428, lat: 39.90923 }) // 北京天安门
let mapInstance = null
const initMap = () => {
  mapInstance = new AMap.Map('map-container', {
    zoom: 16
  })
}
// 监听location,加immediate,同步到地图中心点
watch(location, (newLoc) => {
  if (!mapInstance) return
  mapInstance.setCenter([newLoc.lng, newLoc.lat])
}, { deep: true, immediate: true })
onMounted(() => {
  // 这里注意执行顺序,和图表例子一样,要么把watch放onMounted里,要么加标记
  // 为了演示immediate同步的用法,我们加个标记
  initMap()
  // 假设这里有个isMapInited标记,刚才图表例子里的方法一,这里就不重复写了
})

用法2:初始化响应式数据时,同时做一些依赖该数据的计算或者判断,但不想用computed

computed是用来做缓存的响应式计算的,但有时候你的计算不需要缓存,或者只是想做一些一次性的判断(比如检查用户的登录状态是否过期,同时监听登录状态的变化),这时候用watch加immediate比computed更合适。

举个例子:比如你有一个响应式的token(从localStorage里读的),希望页面刚加载时检查token是否过期,如果过期就跳转到登录页,同时之后token变化时也要检查:

import { ref, watch } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const getTokenFromStorage = () => {
  const token = localStorage.getItem('token')
  const expireTime = localStorage.getItem('tokenExpireTime')
  if (!token || !expireTime) return null
  // 检查是否过期
  if (Date.now() > parseInt(expireTime)) {
    localStorage.removeItem('token')
    localStorage.removeItem('tokenExpireTime')
    return null
  }
  return token
}
const token = ref(getTokenFromStorage())
// 监听token,加immediate,检查过期并跳转
watch(token, (newToken) => {
  if (!newToken) {
    router.push('/login')
  }
}, { immediate: true })

这个例子里,检查过期的逻辑不需要缓存,用watch加immediate刚好:页面刚加载时先检查一次,之后token变化(比如登录成功设置新token,或者退出登录清空token)时再检查一次。

用法3:和watchEffect结合?不,其实可以替代某些简单的watchEffect

很多人分不清watch和watchEffect的区别:watch需要明确指定监听的数据源,有旧值和新值,懒执行(除非加immediate);watchEffect不需要明确指定数据源,自动追踪内部用到的所有响应式数据,没有旧值,默认立即执行(相当于watch加了immediate,但没有明确数据源)。

那什么时候用watch加immediate替代watchEffect?当你只需要追踪少数几个明确的数据源,且不需要自动追踪其他可能用到的响应式数据时——这样可以避免watchEffect“过度追踪”导致的不必要触发。

举个例子:比如你有两个响应式数据:count(数字)和user.name(字符串),你想在count变化时,或者user.name变化时,把这两个数据打印出来,同时页面刚加载时也打印一次,如果用watchEffect的话:

import { ref, reactive, watchEffect } from 'vue'
const count = ref(0)
const user = reactive({ name: '张三' })
const otherData = ref('其他数据') // 这个数据你不想追踪
watchEffect(() => {
  // 这里自动追踪count和user.name,还有otherData(如果你不小心写进去的话)
  console.log('count:', count.value, 'user.name:', user.name)
  // 假设你不小心在这里访问了otherData.value
  console.log('otherData:', otherData.value)
})

这时候如果你修改了otherData,watchEffect也会触发,打印count和user.name,但其实你不想这样,这时候用watch加immediate,明确指定监听count和user.name,就不会过度追踪:

import { ref, reactive, watch } from 'vue'
const count = ref(0)
const user = reactive({ name: '张三' })
const otherData = ref('其他数据')
// 明确指定监听count和user.name
watch([count, () => user.name], (newVals, oldVals) => {
  console.log('count:', newVals[0], 'user.name:', newVals[1])
  console.log('旧值:', oldVals)
}, { immediate: true })

这时候修改otherData,watch不会触发,而且你还能拿到旧值,做一些对比,比watchEffect更灵活。

什么时候该加immediate?什么时候不该加?

最后我们来做个总结,帮你快速判断要不要加immediate:

该加immediate的场景:

  1. 页面/组件刚加载时,需要用监听的初始数据执行业务逻辑:比如监听路由参数取详情页数据,监听搜索关键词的默认值取搜索结果,监听用户token的初始值检查登录状态;
  2. 需要同步响应式数据到非响应式的第三方库,且初始时就要同步:比如同步到echarts、高德地图、three.js的配置;
  3. 需要替代过度追踪的watchEffect,且明确知道要监听的数据源

不该加immediate的场景:

  1. 业务逻辑只需要在数据变化时执行,不需要初始化时执行:比如监听表单变化只emit“未保存”事件(刚才的表单例子),监听滚动条位置只在滚动时改变导航栏样式;
  2. handler里的依赖(比如DOM元素、第三方库实例、其他响应式数据)在组合式API同步执行时还没准备好:比如刚才的图表例子,DOM元素和echarts实例要在onMounted之后才准备好;
  3. 担心首次执行的旧值是undefined导致逻辑出错,且没有合适的方法区分首次和后续执行:这时候宁愿不用immediate,手动调用一次handler。

immediate是个很实用的开关,但要用对场景,注意避坑,区分首次和后续执行的逻辑,理清和其他API、生命周期的执行顺序,才能发挥它的最大作用,希望这篇文章能帮你彻底搞懂Vue3 watch的immediate属性,下次用的时候不再踩坑!

版权声明

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

热门