先问一句:你在Vue3项目里写过watch(() => props.xxx)吗?如果你点进来是想确认自己写得对不对,或者刚好被“props变了但watch没反应”这种问题卡住,那这篇文章应该能帮你把这块彻底捋清楚。
先说结论:Vue3里用watch监听props数据本身不复杂,但真正的坑往往不在语法,而在“你的监听目标到底是个啥”。是基础类型、引用类型,还是嵌套对象里的某个字段?immediate要不要开?deep什么时候必须开?props 能不能直接赋值给 data?这些问题如果不搞清楚,写十次能踩八次坑。
这篇文章我按实际开发中踩过的坑来写,从基本语法到复杂场景,再到和 computed 的对比、常见报错排查,全程贴代码,确保你复制过去就能跑。
1. 什么场景下需要监听props数据
1.1 子组件必须响应父组件数据变化的典型场景
绝大多数人第一次接触watch监听 props,是在做“父组件传值、子组件联动”的时候。父组件一个下拉框选值,子组件要根据这个值重新拉数据、重新算表单、重置分页、或者切换展示状态,这些都属于“子组件必须感知外部状态变化”的场景。
我举一个最实际的项目例子:你写了一个订单列表页,父组件里有三个筛选条件——订单状态、支付方式、时间范围,三个条件都传给子组件OrderTable。用户每次切换筛选条件,子组件内部就得重新请求接口。这时候props在子组件里就是“只读的外部输入”,你不能直接改它,也不能靠子组件内部状态去同步,必须用watch把 props 的变化“翻译”成子组件自己的行为。
这和使用computed最大的区别在于:computed适合“根据 props 派生出一个值”,比如filteredList、totalAmount,它是同步计算的,没有副作用。但如果你要根据变化去发请求、写日志、触发动画、重置内部状态,这些是“副作用”,必须放进watch里。搞清楚这个边界,你就理解了一大半。
1.2 监听 props 和监听 data / ref 有什么不同
很多刚从 Vue2 转 Vue3 的同学会有一个惯性:在watch里直接写props.xxx。比如:
watch(props.goodsList, () => { ... })这在 Vue3 的 Composition API 里是不行的——watch的第一个参数必须是响应式引用、响应式对象、getter 函数,或者由它们组成的数组。props.goodsList只是一个普通的值,你传进去的瞬间它就只是一个值,监听不到后续变化。
正确的写法是包一层 getter:
watch(() => props.goodsList, () => { ... })这一点是 Vue3 和 Vue2 最大的区别之一。Vue2 的 Options API 里watch: { goodsList() { ... } }这种写法很自然,Vue3 里如果你继续写 options 风格,那watch: { 'props.goodsList'() { ... } }也是可以的,但一旦进入 Composition API 就必须遵循 getter 规则。
1.3 基础类型与引用类型的监听差异
这是新手最容易碰到“玄学问题”的地方。
如果 props 传的是一个基础类型(字符串、数字、布尔),那watch(() => props.status)没问题,只要父组件传的值变了,回调必然触发。
如果 props 传的是一个引用类型(数组、对象),那你监听的是“引用地址变化”。父组件做这类操作时才会触发你的监听:
- 父组件整体替换了这个数组/对象
- 父组件用
reactive包裹并通过赋值操作改变引用 - 父组件传的是 computed 返回值,计算属性重新计算生成了新对象
但如果父组件只是往对象里加了一个字段、修改了数组的某一项(arr[0] = xxx)或者push了个新元素,而你监听的又是这个数组本身,回调是怎么都不会触发的。这时候你就需要deep: true或者监听具体字段。
2. watch监听props的完整写法与核心参数
2.1 基础写法:getter + 回调 + 配置项
先上一个最标准的写法模板,建议直接收藏:
import { watch } from 'vue' watch( () => props.goodsList, (newVal, oldVal) => { console.log('goodsList 变化了', newVal, oldVal) // 在这里做拉取数据、重置分页、刷新状态等操作 }, { immediate: true, deep: false, flush: 'pre' } )这里解释一下用到的参数:
- 第一个参数
() => props.goodsList是 getter,Vue 内部会追踪这个 getter 依赖的响应式数据,当依赖变化时重新执行 getter 并对比值。 - 第二个参数是回调函数,参数分别是新值、旧值。
- 第三个参数是
WatchOptions,核心字段是immediate、deep、flush。
immediate的作用是:在组件初始化时就立刻执行一次回调。这个功能特别好用。很多场景你是希望子组件首次挂载时和后续 props 变化时执行同一套逻辑的。如果不写immediate: true,你就得在onMounted里再调用一次同一个方法,代码冗余不说,还容易出现“初始化走了 A 路径,更新走了 B 路径”这种不一致问题。
2.2 immediate 的典型场景:初始化和更新共用同一个逻辑
我自己的习惯是:只要涉及“props 变化后去拉接口”的场景,一律开启immediate: true。
举个例子,你在子组件里这样写:
watch( () => props.userId, async (newId) => { if (!newId) return loading.value = true try { const res = await getUserDetail(newId) userInfo.value = res.data } finally { loading.value = false } }, { immediate: true } )这样组件一挂载就会带着初始 props 去请求,父组件之后每次切换 userId 也会重新请求,不需要在onMounted里再写一遍。
但有一点你得注意:immediate: true触发时,由于是初始执行,回调里的oldVal是undefined。如果你在回调里写了“新旧值对比”的逻辑,第一次执行会拿到undefined的旧值,所以必须先判空。
2.3 deep: true 什么时候必须开启
deep的机制很好理解——默认情况下 Vue 的 watch 只监听 getter 返回值本身的变化,如果 getter 返回的是对象,Vue 不会递归去监听对象内部字段的变化。开启deep: true后,Vue 会递归遍历对象的每一个属性,任何子属性变化都会触发回调。
那么问题来了:props传过来的对象很少是“整个替换”的,更多时候是父组件直接修改了对象上的某个字段:
// 父组件里 const filters = reactive({ status: 'pending', page: 1, keyword: '' }) // 用户操作时只改了其中一个字段 filters.status = 'completed'这种情况下,子组件里:
watch( () => props.filters, (newVal) => { ... } )只会在父组件把整个filters对象赋值给 props 时触发。但上面的代码是修改filters.status属性——对象的引用没变,所以子组件的 watch 不触发。
两个解决方案:
- 用
deep: true
watch( () => props.filters, (newVal) => { ... }, { deep: true } )- 监听具体的字段
watch( () => props.filters.status, (newVal) => { ... } )方案二的性能更好,只在status变化时触发;方案一省事,但对象很大时会有性能损耗。两者不是互斥的,关键是你要清楚 props 里哪些字段是会变的。
还有一个特别容易忽略的点:在父组件里如果给 props 传递的是一个普通对象字面量,比如:
<Child :config="{ theme: 'dark' }" />那么每次父组件重新渲染时,都会创建一个全新的对象字面量,子组件拿到的 props 引用每次都变。这时候即便不加deep,监听也会触发。这也是很多人困惑“我 props 内容没变为什么 watch 触发了”的原因之一。要避免这个问题,父组件应该把配置对象定义成ref或reactive,保证引用稳定。
2.4 监听 props 中的嵌套字段与数组
实际项目中,props 深入一层甚至两三层的情况太常见了。比如:
props: { formData: { type: Object, default: () => ({}) } }你要监听formData.userInfo.name,可以有四种写法:
写法一:getter 返回嵌套值
watch( () => props.formData.userInfo?.name, (newVal) => { console.log('name 变化了', newVal) } )写法二:getter 返回整个对象 + deep
watch( () => props.formData, (newVal) => { console.log('formData 变化了', newVal) }, { deep: true } )写法三:监听多个来源,数组形式
watch( [() => props.formData.userInfo?.name, () => props.formData.status], ([newName, newStatus], [oldName, oldStatus]) => { console.log('name 或 status 发生了变化', newName, newStatus) } )写法四:watchEffect 自动追踪
watchEffect(() => { console.log('name 是', props.formData.userInfo?.name) })如果你只是想在字段变化时打印一下或者做轻量同步,watchEffect是最省事的,它会自动追踪代码里用到的响应式数据,哪个变了都会重新执行。但它不能拿到旧值,也没有“懒执行”的概念——一开始就会执行。
关于嵌套监听,我有一条非常实际的建议:能监听到多细就监听到多细。不要每次都() => props.formData加deep: true。因为你监听得越宽泛,回调触发的次数就越多,排查问题的时候越难定位是哪个字段引起的,性能也越差。比如一个表单对象有二十个字段,你在 watch 里只关心status,结果二十个字段任何一个变了回调都执行,这看着不优雅,维护起来也痛苦。
3. props赋值给data的坑:为什么会失效
3.1 直接赋值的隐患与正确做法
很多从 Vue2 转过来的同学有个旧习惯:把 props 同步到 data 里。比如:
const props = defineProps({ userInfo: { type: Object, default: () => ({}) } }) const localUserInfo = ref(props.userInfo) // 这行是坑看起来好像是把父组件传来的数据“复制”了一份到子组件内部,方便子组件修改。但问题在于:这行代码只执行了一次。在组件初始化时,props.userInfo是什么值,localUserInfo就是什么值,之后父组件哪怕把userInfo整个换掉,localUserInfo依然是旧值。
为什么?因为ref(props.userInfo)只是把你的旧值包了一层响应式外壳,它不会自动跟随props.userInfo变化。
正确做法是在 watch 里同步:
watch( () => props.userInfo, (val) => { localUserInfo.value = val }, { immediate: true, deep: true } )这样初始化时就会把初始 props 同步进去,之后 props 变化了也会跟着同步。
3.2 其实很多场景用 computed 更合适
这里我得说句实话:如果你只是想“在子组件里拿 props 的值做点派生操作”,那大概率不需要往 data 里搬,直接用 computed 就好:
const displayName = computed(() => props.userInfo?.name ?? '未命名')computed 的好处是:它本身就是响应式的,props 变了它跟着变,不需要你写 watch、不需要定义额外变量、不需要管 immediate。而且它天然是只读的,不会出现“子组件改了本地值,父组件不知道”的同步混乱。
什么时候才需要用 watch + 赋值 data?只有一种情况:你的子组件内部真的需要对这份数据做“本地修改”,并且你希望在 props 变化时重置本地修改。打个比方,弹窗里的表单,用户改了一半,父组件换了行数据,要把表单重置为新数据。这时候 watch 赋值就非常合理。
3.3 用watch同步多个props到一个data时的注意点
有时候你需要把多个 props 合并成一个子组件内部状态。比如:
watch( [() => props.page, () => props.pageSize], () => { queryParams.value = { page: props.page, pageSize: props.pageSize } }, { immediate: true } )这种写法我比较推荐用watchEffect替代:
watchEffect(() => { queryParams.value = { page: props.page, pageSize: props.pageSize } })watchEffect 会自动追踪props.page和props.pageSize,哪个变了都会重新执行。代码更少,意图更清晰。而且它默认就是立即执行,省掉了immediate: true。
但注意 watchEffect 拿不到旧值,如果你需要判断“到底是哪个字段变了”,那还是用数组形式的 watch 更合适。
4. watch、computed 和 watchEffect 到底怎么选
4.1 三者的定位差异
这是我每次面试前端都要考的问题,也是很多项目里写出一堆没必要代码的根源。先给一个简单的判断逻辑:
- 你有 props 或 state,想基于它们计算出一个值来渲染模板或传给子组件 → 用
computed。 - 你有 props 或 state,想在变化时执行一段逻辑(请求数据、打印日志、修改另一个状态) → 用
watch。 - 你不关心具体是哪个依赖变了,只想在任何相关响应式数据变化后执行一段逻辑 → 用
watchEffect。
拿一个具体场景来说:子组件根据父组件传来的type展示不同的文案。
- 用 computed:
const typeText = computed(() => { ... }) - 用 watch:
watch(() => props.type, () => { typeText.value = ... })
computed 版本不仅代码少,而且保证 typeText 和 props.type 永远同步,不会出现“watch 忘了开 immediate 导致首次没赋值”的 bug。能 computed 就不要 watch,这条规则能帮你省掉大量代码。
但反过来,如果 props 变化后你需要调用一个外部方法、发一个接口请求,或者修改多个不相干的状态,computed 就不合适了,因为 computed 的 getter 里不应该有副作用。这是 Vue 的“纯度约定”——计算属性只负责派生值,不负责干别的。如果你非要在 computed 里发请求,代码虽然能跑,但调试的时候会非常酸爽,因为 computed 会依赖其他响应式状态被意外重新计算。
4.2 watchEffect 的适用边界
watchEffect 适合处理“依赖哪个字段都无所谓,反正我都重新执行”的场景。比如统计筛选条件、同步缓存、记录日志。
但 watchEffect 有个坑:它的依赖收集是自动的,你在 effect 函数里读到的所有响应式数据,都会变成依赖。如果你写的逻辑里不小心读了一个频率变化极高的 ref(比如鼠标坐标、当前时间),那 watchEffect 会被频繁触发,甚至产生性能问题。
举个例子:
watchEffect(() => { // 这里读了 props.tableData,也读了 currentTime saveToLocal(props.tableData, currentTime.value) })只要currentTime一变化,即使tableData没有变,saveToLocal也会执行。这不是 bug,是行为符合预期但不符合你的预期。排查这种问题的方法是把 effect 里读取的变量逐个梳理,精简依赖。
而 watch 的优势恰恰在于“精确指定依赖”——你不希望被无关变量干扰,就明确指定要监听的对象。所以在性能敏感、副作用逻辑重的场景,watch 比 watchEffect 可控得多。
4.3 产品面试官最爱问:computed 和 watch 的区别
这里顺便把热搜里那个高频面试题“vue的computed和watch的区别”也答了。一条一条说:
- computed 适合派生值,watch 适合副作用。
- computed 有缓存,只有依赖变化时才重新计算;watch 没有缓存概念,只要你监听的源变了就执行回调。
- computed 默认懒执行,只有被模板或其他地方读取时才求值;watch 默认懒执行,但可以配置
immediate: true让它在初始时执行。 - computed 不能写异步逻辑;watch 可以自由写异步逻辑。
- computed 是声明式的,适合“根据状态推导 UI”;watch 是命令式的,适合“状态变化后触发行为”。
如果你在面试,能把这个差异讲成一段业务案例而不是背定义,会加分很多。比如这样说:“表单筛选组件里,筛选条件变化后要重新请求接口,如果我用 computed 就只能在 getter 里产生副作用,既不可控也容易重复执行;用 watch 指定监听条件字段,变化一次请求一次,配合 immediate 保证初始化也拉一次,这才是符合预期的实现。”
4.4 实际开发中两者配合的模式
实际项目里 computed 和 watch 经常是配合使用的。我给你一个我自己项目里的真实模式:
父组件传一个完整对象props.formConfig,里面可能有几百个字段,子组件只需要其中一部分用于显示,一部分用于计算,一部分用于触发接口请求。
我的处理流程是这样的:
- 先用 computed 把“用于显示的字段”提取出来,做成展示视图模型。
- 再用 computed 把“参与校验的字段”组合成校验模型。
- 最后只对“触发接口请求的字段”用 watch 监听。
这样拆的好处是:显示逻辑放在 computed 里好维护、走缓存;接口请求放在 watch 里精确控制触发时机。两边各司其职,不会出现一个字段变化引发一堆无关逻辑执行的问题。
5. 常见“不触发/疯狂触发”问题与排查技巧
5.1 watch 不触发?先检查这五个位置
我整理了一个排查清单,遇到“watch 没反应”的问题从上往下查:
- 监听源是不是 getter 函数?
watch(props.xxx, ...)是错的,watch(() => props.xxx, ...)才对。 - props 字段名是否拼写正确?用
defineProps声明的字段名,和模板里:xxx绑定的名字必须一致。比如父组件写:user-info,子组件用defineProps({ userInfo: ... })的时候,Vue 会自动转驼峰,但 watch 里必须写props.userInfo,不是props.userInfo的连字符版本。 - 深层对象变化是否开了 deep?如果对象内部字段被修改而引用没变,不加
deep: true不触发。 - 父组件是真正在改数据,还是在改一个非响应式对象?比如父组件里
const obj = { name: 'a' }然后把它绑到 props 上,之后obj.name = 'b'——这个对象根本不是响应式的,Vue 完全追踪不到。正确做法是用ref或reactive包裹。 - watch 回调里是否有条件判断提前 return?有时候回调写了
if (!newVal) return,而父组件传的新值恰好是个 falsy 值(比如0、''、false),那看着就像没触发,其实是触发了但提前 return 了。
5.2 对象内部属性变化监听失效的两种解法
我见过一个很典型的 bug:父组件用reactive定义了一个筛选对象,然后整体传给子组件。子组件的写法:
watch( () => props.filterForm, (val) => { loadList(val) }, { deep: true } )结果用户在选择筛选条件时,明明值变了,接口却没重新请求。排查了很久发现,父组件里其实是在一个普通函数中直接修改了filterForm.status。看起来是响应式对象上的属性修改,理论上是能追踪的。问题出在函数执行上下文:因为父组件用reactive包裹的对象,在给属性赋值时确实能触发依赖更新。最后定位到父组件里把对象传给了另一个组件,那个组件内部用JSON.parse(JSON.stringify(...))深拷贝了一份,然后修改的是拷贝后的对象,根本没动原对象。
这种问题定位起来特别耗时。我给的建议是:如果父传子的 props 是一个对象,且你日常使用中经常出现“整体替换”和“局部修改”混合的情况,优先使用以下方案:
方案一:子组件监听具体基本类型字段,不监听整个对象。
watch( () => props.filterForm.status, (val) => { loadList(val) } )这种写法的好处是:不管父组件是整体替换还是局部修改status字段,只要filterForm.status这个值最终变了,watch 就会触发。因为 Vue 的 getter 会追踪到filterForm.status这个属性变更。
方案二:必须监听整个对象时,给父组件加规范,要求一切修改都通过整体赋值完成,比如:
// 父组件 filterForm.value = { ...filterForm.value, status: 'completed' }这样每次修改都会改变对象引用,子组件不加deep也能触发。
两条路你用一条就行,千万别混着来。混着来就是“为什么我这里不触发”的万恶之源。
5.3 watch 回调里修改同一个 props 关联状态导致死循环
这个问题比较高级,但碰到了真的很抓狂。
假设你写了这么一段代码:
watch( () => props.count, (val) => { localCount.value = val emit('update:count', localCount.value + 1) } )父组件监听了@update:count,又把count传回来,子组件再看props.count变了就再执行 watch……循环就产生了。
这是典型的“watch 回调解发 props 变化,props 变化再触发 watch”的循环依赖。Vue 在异步批处理机制下,不会立刻无限循环,但如果每次都生效,页面就会卡死或者疯狂打请求。
正确的设计原则是:watch 回调里不要直接修改父组件的 props 来源数据。如果需要联动修改,用emit让父组件决定,而且父组件的处理里要保证“值不会变回原样”。还有一种做法是把 emit 逻辑放到nextTick里,打破同步循环。
5.4 同一事件多次触发 watch 的批处理问题
Vue3 的 watch 默认是异步批量执行的。也就是说,你在一个事件循环里改了 10 次props.value,watch 回调只会在微任务队列中执行一次,而且拿到的旧值是最初的值,新值是最终的值。这是 Vue 设计的“特性”,不是 bug。
但有些业务希望“每次变化都通知”,那你可以把flush设置为'sync':
watch( () => props.value, (val) => { ... }, { flush: 'sync' } )这个操作会让 watch 立即同步执行,不经过队列。代价是性能下降,并且如果你在回调里又改了同一个数据,可能真的会死循环。日常项目里我基本不用'sync',默认的'pre'和显式的'post'已经覆盖绝大多数场景。
关于flush: 'post'的场景,一般是 watch 回调里要操作 DOM,并且需要等组件更新完之后再操作。比如通过 ref 拿到某个元素的宽度、高度来重新计算布局,这时候'post'会安全很多。
5.5 defineProps 的类型与默认值对监听的影响
顺便把热搜里那条“const props = defineProps({ 里面的类型”也一起讲掉。defineProps里必须声明好类型,尤其对于对象和数组,必须提供默认值为函数的工厂形式:
const props = defineProps({ userInfo: { type: Object, default: () => ({}) }, tags: { type: Array, default: () => [] }, active: { type: Boolean, default: false } })如果你声明了 props 类型但没写default,当父组件没传时,props.userInfo会是undefined。这时候 watch 里写() => props.userInfo.name就会直接报错:“Cannot read properties of undefined”。所以要么父组件保证传完整,要么子组件里做兜底:
watch( () => props.userInfo?.name, (val) => { if (val !== undefined) { ... } } )6. 三个能直接抄的实战模板
6.1 父组件传参、子组件拉数据
这个场景是我写得最多的。父组件负责筛选、搜索、翻页,子组件负责渲染表格或列表,props 变化时重新拉数据。完整代码可以这样写:
父组件:
<template> <div> <select v-model="status"> <option value="pending">待处理</option> <option value="completed">已完成</option> </select> <OrderList :status="status" :page="page" /> </div> </template> <script setup> import { ref } from 'vue' import OrderList from './OrderList.vue' const status = ref('pending') const page = ref(1) </script>子组件:
<template> <div> <div v-if="loading">加载中...</div> <div v-else> <div v-for="item in list" :key="item.id">{{ item.name }}</div> </div> </div> </template> <script setup> import { ref, watch } from 'vue' const props = defineProps({ status: { type: String, default: 'pending' }, page: { type: Number, default: 1 } }) const list = ref([]) const loading = ref(false) async function fetchData() { loading.value = true try { const { data } = await api.getOrderList({ status: props.status, page: props.page }) list.value = data } finally { loading.value = false } } watch( () => props.status, () => { // 切换筛选条件时,重置页码并重新拉数据 page.value = 1 // 注意:这里需要配合 emit 或者直接调父组件来改,不能直接改 props fetchData() }, { immediate: true } ) watch( () => props.page, () => { fetchData() } ) </script>等等,有同学可能已经发现了:page.value = 1这行是错的——你在子组件里改不了page,因为它是 props 属性。
这里其实想说明一个关键点:如果有“切筛选条件就重置页码”的需求,最佳实践是把这个逻辑放到父组件里做,而不是在子组件里硬改。父组件监听status变化时同时重置page,这样数据源始终是清晰的。
正确写法是父组件这样:
watch(status, () => { page.value = 1 })如果一定要在子组件里触发父组件的状态变更,那就通过 emit 事件让父组件自己改。
6.2 父组件异步加载数据后传递给子组件
这是另一个高频坑:父组件从接口拿数据,拿到后传给子组件。子组件的 watch 里虽然写了immediate: true,但 props 初始值是空数组,接口返回后需要 watch 重新触发。
<template> <Chart :data="chartData" /> </template> <script setup> import { ref, onMounted } from 'vue' import Chart from './Chart.vue' const chartData = ref([]) onMounted(async () => { const res = await fetch('/api/chart-data') chartData.value = await res.json() }) </script>子组件里:
const props = defineProps({ data: { type: Array, default: () => [] } }) watch( () => props.data, (val) => { if (val.length) { renderChart(val) } }, { immediate: true, deep: true } )这里有几个细节值得注意:
immediate: true保证首次渲染时,如果父组件已经有数据了也能正常渲染。deep: true是保险选项,因为接口返回的数组本身可能被整体替换,不加也触发,但加上更稳,防止父组件在数组上做了一些“原地操作”。- 回调里判断
if (val.length)是防止空数组时 render 方法报错或渲染空白。
6.3 多个 props 同时变化时统一联动
如果子组件要同时关注多个相关 props,而且希望它们“任意一个变化”都触发同一条逻辑,直接用数组形式的 watch:
watch( [() => props.keyword, () => props.type, () => props.dateRange], ([newKeyword, newType, newDateRange], [oldKeyword, oldType, oldDateRange]) => { // 三个字段任一变化都进来 // 可以通过对比 old/new 精确判断哪个变了 if (newKeyword !== oldKeyword) { // keyword 变了 } search() }, { immediate: true } )注意两点:
第一,newVal和oldVal会是数组形式,解构出来后是一一对应的。
第二,如果你的业务逻辑是“这三个任意一个变了都重新搜索”,那这个写法非常合适。但如果只有 keyword 变了才需要清空 type,那更细的逻辑可以在回调里自己判断。
另外,如果三个字段本身就属于一个对象,比如props.queryForm = { keyword, type, dateRange },那我更建议用watchEffect:
watchEffect(() => { search(props.queryForm) })这样完全不用关心是哪个字段变的,效果一样,代码量还少一半。唯一的缺点是拿不到旧值,但绝大多数“重拉数据”的场景根本不需要旧值。
7. 我的实操心得与避坑总结
7.1 四条最重要的实操经验
把这些年写 Vue3 + props + watch 的经验浓缩成四条,每一条都是踩过坑换来的:
第一条:能监听多深就监听多深,能监听多窄就监听多窄。除非真的需要关心整个对象的所有变化,否则不要直接() => props.xxx+deep: true。这个组合监听范围大、性能差、触发次数多,出了问题特别难排查。优先监听具体字段,比如() => props.form.status、() => props.form.user?.name。
第二条:immediate: true是好朋友,但不是所有场景都需要。如果初始化时的行为和 props 变化时的行为完全一致,那就开;如果初始化时你有单独的初始化逻辑,那就不开,或者开了之后在回调里根据oldVal === undefined来区分初始化。
第三条:watch 回调里尽量不要修改父组件的状态。如果你确实需要“父组件数据变化 → 子组件处理后 → 父组件再变化”这样的联动,优先通过emit让父组件自己改,而不是在子组件里直接改 props。这个原则能避免 90% 的循环触发问题。
第四条:watch 和 computed 的边界要清楚。能派生值就 computed,需要执行副作用才 watch。如果你发现自己在一个 watch 回调里写了 30 行“计算逻辑”,大概率这段逻辑应该抽成 computed 或者普通函数。
7.2 这几个配置项组合起来就是完全体
最后给一个配置项速查表,你可以贴在项目文档里:
| 配置项 | 作用 | 什么时候用 |
|---|---|---|
immediate: true | 初始化时立即执行一次回调 | 初始化和更新共用同一套逻辑时 |
deep: true | 深层监听对象内部字段变化 | 父组件直接修改对象属性而不是整体替换时 |
flush: 'post' | 等 DOM 更新完再执行回调 | 回调里需要操作 DOM、测量尺寸时 |
flush: 'sync' | 同步立即执行回调 | 极少用,特殊场景才用,默认不要开 |
| 数组形式的 sources | 同时监听多个源 | 多个 props 变化触发同一条逻辑时 |
7.3 最后分享一个调试技巧
如果你实在搞不清 watch 为什么没触发,或者触发得太频繁,最简单有效的办法就是临时加一行console.log,但要打在 getter 里而不是回调里:
watch( () => { console.log('getter 执行了,当前 props.filterForm 是', props.filterForm) return props.filterForm }, (val) => { console.log('回调执行了', val) }, { deep: true } )getter 每次执行说明 Vue 确实追踪到了这个依赖的变化,但回调没执行可能是因为新旧值判断后相等。这两个日志一对比,问题范围直接缩小一半。调试完记得删掉这些日志,不然上线后控制台会比较热闹。
7.4 关于 Vue3 监听 props 的最终建议
监听 props 这件事,核心并不是记 API,而是搞明白 Vue3 的响应式追踪机制:watch的 getter 函数负责追踪依赖,返回值负责判等触发回调。这两个环节只要有一个理解不到位,就会出现“明明数据变了但 watch 不触发”或者“数据没变却疯狂触发”的问题。
我个人在实际开发中的体会是:把 props 当成“外部输入信号”,watch 当成“信号接收器”,你只需要关心两件事——接收哪个信号、收到信号后做啥。至于是整体替换还是局部修改,那是信号源的问题,不要混在一起想。把这个思路理顺了,Vue3 里关于 watch 监听 props 的所有坑,基本都能一眼看穿。