Vue3 watch加immediate后怎么避免首次执行踩坑?还有哪些immediate的实用细节你没注意?
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)
})
看起来很合理?但运行的时候你会发现,页面刚加载时图表是空的!为什么?因为执行顺序是:
- 组合式API从上到下执行,先定义了chartRef、chartInstance、chartOptions;
- 然后执行watch,加了immediate,立刻执行handler——这时候chartInstance还是null(因为onMounted还没跑),所以直接return了,没有渲染;
- 然后才跑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的场景:
- 页面/组件刚加载时,需要用监听的初始数据执行业务逻辑:比如监听路由参数取详情页数据,监听搜索关键词的默认值取搜索结果,监听用户token的初始值检查登录状态;
- 需要同步响应式数据到非响应式的第三方库,且初始时就要同步:比如同步到echarts、高德地图、three.js的配置;
- 需要替代过度追踪的watchEffect,且明确知道要监听的数据源。
不该加immediate的场景:
- 业务逻辑只需要在数据变化时执行,不需要初始化时执行:比如监听表单变化只emit“未保存”事件(刚才的表单例子),监听滚动条位置只在滚动时改变导航栏样式;
- handler里的依赖(比如DOM元素、第三方库实例、其他响应式数据)在组合式API同步执行时还没准备好:比如刚才的图表例子,DOM元素和echarts实例要在onMounted之后才准备好;
- 担心首次执行的旧值是undefined导致逻辑出错,且没有合适的方法区分首次和后续执行:这时候宁愿不用immediate,手动调用一次handler。
immediate是个很实用的开关,但要用对场景,注意避坑,区分首次和后续执行的逻辑,理清和其他API、生命周期的执行顺序,才能发挥它的最大作用,希望这篇文章能帮你彻底搞懂Vue3 watch的immediate属性,下次用的时候不再踩坑!
版权声明
本文仅代表作者观点,不代表Code前端网立场。
本文系作者Code前端网发表,如需转载,请注明页面地址。
code前端网


