ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3 watch监听props全攻略:从原理到实战避坑

Vue3 watch监听props全攻略:从原理到实战避坑 在 Vue3 的项目里用watch监听props里的数据听起来是再基础不过的操作可实际开发里十个组件有八个在这上面吃过亏要么监听不到要么疯狂触发要么直接把父组件搞成死循环。尤其是一开始从 Vue2 转过来的同学习惯直接把this.props.xxx当普通数据用到了 Vue3 组合式 API 这里一个const props defineProps(...)加一个watch写法没选对坑就排着队来了。这篇文章不打算把官方文档念一遍而是从一个实际项目需求出发父组件传了一份筛选条件给子组件子组件需要在这个条件变化时重新请求数据、同步表单、处理异步竞态。整个过程我会把watch监听props的写法、参数区别、常见坑、封装思路一次说清楚。适合正在学 Vue3 的前端初学者也适合从 Vue2 迁移过来的老手当然如果你在准备面试后面关于computed和watch区别的对比也能直接拿去用。1. 为什么非要在子组件里监听 props单向数据流下的副作用出口1.1 props 的本质是一个“会被追踪的只读快照”先捋清楚一个底层问题props到底是什么。在 Vue3 里子组件通过const props defineProps(...)拿到父组件传来的数据这个props本身是一个响应式对象但它和reactive()直接创建的对象不太一样它是只读的子组件里不能直接props.xxx value否则控制台会警告数据流也会变得混乱它是浅层响应式的父组件重新渲染时props里已声明属性的值会同步变化它承载的是父组件的状态本身不应该成为子组件“私有可变状态”的载体。理解这一点很重要因为watch监听props这个需求本质上是在单向数据流的限制下找一个“当外部状态变化时子组件要做点什么”的出口。父组件把数据传下来子组件不能改它但如果这个数据变化后子组件需要重新请求接口、重置表单、触发动画或者操作 DOM这些就是典型的副作用操作需要借助watch来完成。举个例子后台管理系统里最常见的场景父组件页面上有一组筛选条件点击“查询”后把条件对象传给子表格组件表格组件监听到条件变化就去重新拉列表。这时候你用computed是憋不出副作用请求的因为computed必须同步返回一个值而且它依赖的数据没变化时还会被缓存住根本不适合发请求。watch才是干这个活的。1.2 computed 是“算出来”watch 是“变了以后做事”这里插一个面试和日常开发都绕不开的点computed和watch到底有什么区别。computed的价值在于派生状态。比如const total computed(() props.list.reduce(...))它只关心“根据现有数据算出什么结果”而且这个结果有缓存依赖没变不会重新计算。如果某个状态只是用来给模板展示或者用来传给另一个方法优先用computed。watch的价值在于副作用执行。它不是用来“计算返回结果”的而是当某个数据发生变化后去执行一段可能带异步逻辑的代码。比如监听props.id变化然后调用接口比如监听props.visible变化后重置表单比如监听props.tabs变化后切换高亮样式。所以你看watch监听props并不是为了“把 props 变成另一个值”而是为了在 props 变化这个时间点上触发组件里的某些行为。理解了这一点后面很多写法上的细节就顺理成章了。2. watch 监听 props 的几种写法选对才不踩坑2.1 基本写法永远先写成 getter 函数监听props里的基本类型比如父组件传了一个userId过来子组件要在这个 ID 变化时重新请求用户详情。我的写法第一版一定是这样watch( () props.userId, (newVal, oldVal) { console.log(userId 变了, oldVal, -, newVal); fetchUserDetail(newVal); } );注意这里我写的是() props.userId不是props.userId。这个差异极其关键也是新手最常踩的第一个坑。watch的第一个参数是“数据源”它可以是ref、reactive对象、函数返回的值也可以是一个由这些组成的数组。如果你直接写watch(props.userId, callback)这里props.userId在watch初始化时就会被执行一次它的结果是一个普通字符串或数字Vue 拿到这个“已经求值后的静态值”后并不会建立对该props属性的依赖追踪后面props.userId再怎么变回调都不会触发。这就像你把门牌号抄下来贴在手表上而不是把 GPS 定位器绑在人身上人走了你贴的门牌号当然不会通知你。用 getter 函数() props.userId就不一样。Vue 在每次依赖变化时会重新执行这个函数去读取props.userId读取动作发生在响应式系统的依赖收集阶段因此能准确感知到值的变化并把新旧值传给回调。2.2 监听对象类型要不要 deep要看父组件怎么改基本类型好说但现实项目里props更常传的是对象。比如父组件传了一个formData里面有name、age、address几个字段。这种情况下监听方式要分场景讨论。先看最简单的写法watch( () props.formData, (newVal, oldVal) { // 只有 formData 整个引用被替换时才会触发 } );这种写法只能感知到formData这个引用被重新赋值。也就是说父组件如果这样更新formData.value { ...formData.value, name: 新的名字 };因为对象整体被替换成新引用子组件的watch会触发。但如果父组件直接修改原对象formData.value.name 新的名字;由于formData的引用没有变化上面的watch就监听不到了。这时候有两条路第一条路监听具体字段watch( () props.formData.name, (newVal) { console.log(name 变了, newVal); } );第二条路给对象开深度监听watch( () props.formData, (newVal, oldVal) { console.log(formData 内部发生了变化); }, { deep: true } );这里我的建议是能用字段监听就优先字段不到万不得已不要开deep: true。倒不是不能用而是深度监听会对整个对象进行递归遍历对象层级越深、字段越多性能消耗越大。更麻烦的是deep会把对象里任意一层子字段的变化都当作触发源很多时候你其实只关心其中两个字段结果一个无关字段变了回调也跑了导致不必要的接口请求或状态更新。2.3 immediate、deep、flush 这三个参数务必要吃透watch的第三个参数是一个配置对象项目里用到最多的就是immediate、deep和flush。immediate: true表示初始化时立即执行一次回调。这个在请求接口的场景里太常用了。比如父组件首次传入一个selectId子组件需要在挂载后立刻加载对应的数据这时候不给immediatewatch默认是惰性的初始化不会执行你还要额外写一个onMounted里去拉一次代码就冗余了。有了immediate一行就解决watch( () props.selectId, (newVal) { fetchList(newVal); }, { immediate: true } );需要注意immediate: true首次执行时回调里的oldVal是undefined这是正常行为你别在业务里去读oldVal的属性容易直接报错。deep前面讲过了它只对“通过 getter 返回的响应式对象”有意义。如果 getter 返回的是基本类型deep: true写不写都一样因为基本类型没有内部嵌套结构。如果 getter 返回的是props里的对象deep: true会递归监听该对象内部的所有响应式字段。flush参数控制回调的执行时机默认是pre也就是在组件更新前执行。如果你在watch回调里需要操作刚刚因为 props 变化而更新过的 DOM比如获取某个元素的宽度、聚焦输入框执行太早会拿到旧 DOM。这时候有两种处理方式watch( () props.visible, async (newVal) { if (newVal) { await nextTick(); inputRef.value?.focus(); } }, { flush: post } );直接使用flush: post可以让回调在 DOM 更新之后再执行省去手动nextTick。如果你写的是watchEffect同样支持这个配置项。3. 实战把 props 同步到内部 data并联动请求接口3.1 一个绕不开的需求props 赋值给 data很多组件并不是直接展示props而是需要把props的初始值复制到自己内部的响应式 state 里然后允许用户修改。比如一个编辑弹窗父组件传了一份detail数据过来子组件要把这份数据铺到表单里用户改了之后提交再通知父组件。很多人第一反应是这么写const props defineProps({ detail: { type: Object, default: () ({}) } }); const form reactive({ ...props.detail });这个写法有坑。reactive({ ...props.detail })只在setup执行时复制了一次。如果detail是父组件异步请求回来后才赋值的子组件初始化时拿到的是空对象等父组件detail有值了form里的字段还是空的。你要做的就是用watch把props的新值同步进内部 statewatch( () props.detail, (newVal) { // 注意这里不能直接 form newVal会丢响应式 Object.assign(form, newVal); }, { immediate: true } );这里有两个细节。第一form必须是创建在setup中的响应式对象Object.assign可以在保持引用不变的前提下把新对象的字段合并进去这样模板和后续操作引用的还是同一个响应式对象。第二immediate: true很重要否则首次赋值还是可能漏掉异步到达的数据。当父组件传的detail本身就经常被整体替换时这个模式基本是通用的。但要注意潜在死循环如果子组件编辑表单后emit(update:detail, form)父组件再把新对象传回来watch又会触发又把表单覆盖回父组件数据。避免方法是明确区分“外部数据变化”和“用户主动编辑”。常见做法是编辑时记录一个isUserEditing标记或者只在父组件传入的引用和当前form里的“数据版本号”不一致时才Object.assign而不是无脑同步。这个问题我见过不少团队踩没有银弹核心是看你的数据流向怎么设计。3.2 请求接口时怎么解决竞态监听props后拉接口是高频场景但只写一个简单的回调远远不够。举个实际例子watch( () props.status, async (newStatus) { loading.value true; const res await fetchList({ status: newStatus }); list.value res.data; loading.value false; }, { immediate: true } );这段代码能跑但有一个隐患异步竞态。假设用户快速切换了status第一次请求还没返回第二次请求已经开始。如果第一次请求网络更慢它后返回就会用旧数据覆盖新请求的结果页面上显示的内容就和当前status对不上了。解决竞态的思路有很多最简单可控的是加一个请求序号let requestId 0; watch( () props.status, async (newStatus) { const current requestId; loading.value true; const res await fetchList({ status: newStatus }); if (current ! requestId) return; // 说明已经有更晚的请求发出去了 list.value res.data; loading.value false; }, { immediate: true } );每次触发回调时先把requestId自增并记录到局部变量异步结果回来后确认自己是不是最新一次请求。如果不是直接丢弃结果。这比“用取消令牌 AbortController 取消旧请求”更通用因为有些接口库并不支持取消而且某些场景下旧请求发出去了不太好中途掐断。也可以用watchEffect配合onScopeDispose做清理不过请求序号的思路简单明了我个人在业务代码里用得更顺手。3.3 监听多个 props数组写法如果子组件一次要响应多个 props 变化比如筛选条件里的关键字和分页页码可以写成数组watch( [() props.keyword, () props.page], ([newKeyword, newPage], [oldKeyword, oldPage]) { if (newKeyword ! oldKeyword) { // 关键字变了可能需要重置分页 page.value 1; } fetchList({ keyword: newKeyword, page: newPage }); } );数组写法里新旧值都是数组分别对应每个数据源。这样能在同一个回调里同时拿到多个 props 的当前值和旧值避免写多个watch导致代码分散。不过也不要把所有 props 都塞进一个watch数组里职责不清晰不如拆开。比如“搜索条件变化”和“弹窗开关变化”就是两件事拆成两个watch更好维护。4. watch 监听 props 的常见坑与排查实录4.1 死活监听不到先查这四个原因这些年帮人排查过不少“watch 不触发”的问题总结起来就这四类第一watch的第一个参数写成了props.xxx没有写 getter。前面已经详细解释过重复无数次一定要() props.xxx。第二在setup顶层解构了propsconst { id } props; watch(id, callback); // 错 watch(() props.id, callback); // 对解构出来的id只是一个静态值和props.id之间已经没有响应式联系了监听自然失效。Vue 3.5 之后虽然提供了实验性的usePropsDestructure来解决解构响应性问题但生产项目里最稳的依然是通过 getter 去读一个箭头函数就能解决没必要依赖额外 API。第三监听对象时只监听引用但父组件是原地修改对象属性。比如父组件form.value.name 新名字子组件监听的是() props.form没有deep。这种场景下要么改 getter 监听具体字段要么开deep。第四props的默认值本身就是一个空对象父组件数据后到。比如const props defineProps({ data: { type: Object, default: () ({}) } });父组件初始渲染时data是空对象子组件watch已经执行了等父组件从接口拿回数据后如果父组件是patch方式原地修改data对象里的字段子组件监听() props.data就不会触发因为引用没变。遇到这种场景要么父组件整体替换对象要么子组件开deep。排查顺序也很简单先看是不是 getter再看是否解构了 props再看数据源变化是不是发生在“引用”层面最后看有没有必要加deep。按这个顺序走大部分问题都能定位。4.2 频繁触发、重复请求、死循环性能与逻辑双排查监听不到的反面是监听太灵敏。最常见的场景是deep: true监听一个表单对象用户在输入框里每敲一个字符watch就触发一次如果回调里还发了请求那基本就是一次请求风暴。这种情况没有太多高深技巧就是拆分监听粒度。把“整个对象”改成“对象里的某个字段”watch( () props.filter.keyword, debounce((newVal) { fetchList(newVal); }, 300) );如果实在没办法必须监听一个大对象也可以在回调里加防抖。防抖函数最简单的手写就是setTimeout包一层但要注意组件卸载后定时器可能还会执行最好在onScopeDispose里清掉。项目里已经有lodash的直接_.debounce也行。另一个更严重的问题是死循环。有人会在watch回调里修改props或者修改父组件的数据比如watch(() props.visible, (val) { emit(update:visible, !val); });如果父组件监听了visible变化又同步回子组件子组件又 emit就会形成无限循环。解决这类问题的核心原则是watch回调里永远不要主动修改它正在监听的 props 数据。如果确实要回传务必判断新旧值是否相同或者用“只在用户主动操作时 emit”的方式断开循环。4.3 watch、watchEffect、computed 怎么选一张表说清楚把三者的区别整理成表面试也好、写代码也好直接对照类型核心定位是否缓存是否立即执行能拿到旧值吗适合场景computed派生状态计算新值是惰性首次访问时计算不需要把 props 转换成模板用的展示数据watch监听数据源执行副作用否默认惰性可选 immediate可以数据变化后调接口、操作 DOM、重置状态watchEffect自动收集依赖的副作用否是拿不到只需要新值不需要旧值且依赖不明确的副作用实际项目里我一般按这个路径决策如果只是“根据 props 算出一个新值给模板用”比如格式化金额、拼接完整名称、筛选列表子集用computed。如果需要在数据变化后“做点事”比如发请求、更新本地 state、操作 DOM用watch。如果这个副作用依赖的响应式数据很多而且你又不在乎每个依赖分别是什么可以考虑用watchEffect它会自动追踪回调里读到的所有响应式数据。但watchEffect也有坑最典型的是依赖追踪的误判。比如回调里有一个if分支某些依赖只在某个条件下才会被读取那这个依赖变化时watchEffect并不一定会在它变化时重新执行因为依赖收集阶段它根本没被访问到。此外watchEffect拿不到旧值很多业务需要对比新旧状态所以它并不能完全替代watch。我自己的习惯是默认用watchwatchEffect只在明确知道“这个副作用需要自动收集多个依赖”时使用。5. 一个可复用的 useWatchProps 组合式函数5.1 为什么值得封装项目里多个组件都在监听 props 拉接口如果每次都是复制粘贴那一套“let requestId 0watchloading”的代码不仅冗长还容易漏掉immediate或竞态处理。更好的做法是把这套逻辑抽成一个组合式函数既复习了 Vue3 的 composition API也让业务代码干净不少。要注意一个前提组合式函数只是对逻辑的封装它内部依然要调用watch和getCurrentInstance等 Vue API所以必须放在组件的setup顶层调用不能用if包裹也不能在普通函数内部按条件调用。只要遵守这条规则并保证传入的props是组件当前的 props 对象这套封装就能正常工作。5.2 实现带竞态处理的 useWatchProps我这里给一个比较实用的实现版本。它接收四个参数props 对象、一个 getter 函数、业务回调、以及配置项比如 immediate、debounce。内部统一处理请求序号避免并发竞态。import { watch, onScopeDispose, unref } from vue; export function useWatchProps(props, getter, callback, options {}) { const { immediate false, debounce 0 } options; let requestId 0; let timer null; const run (val, oldVal) { if (debounce 0 timer) { clearTimeout(timer); } const id requestId; timer setTimeout(() { if (id ! requestId) return; callback(val, oldVal); }, debounce); }; const stop watch( () (typeof getter function ? getter() : getter), (val, oldVal) { run(val, oldVal); }, { immediate } ); onScopeDispose(() { stop(); if (timer) clearTimeout(timer); requestId; }); return stop; }这里有一个需要提醒的点debounce在内部实际上会延迟callback的执行也就意味着如果你依赖immediate: true在挂载时立即拉数据加了debounce之后不会“立即”执行而是会延迟。所以加不加防抖一定要结合业务场景判断不要为了封装而封装。对于绝大多数请求场景我更推荐直接在业务层用一个更轻的封装只处理竞态不加防抖因为防抖本身会改变用户体验和请求时机放到组件里由开发者自行控制反而更透明。5.3 在组件里怎么用假设父组件传了一个params对象子组件要在params.id或params.sort变化时重新拉列表const props defineProps({ params: { type: Object, required: true } }); useWatchProps( props, () [props.params.id, props.params.sort], ([id, sort]) { fetchList({ id, sort }); }, { immediate: true } );这样组件内部不再散落watch和requestId这些细节主流程一眼就能看懂监听这两个字段变化后拉数据初始化也拉一次。如果某天需要在变化后重置页码直接在业务回调里加逻辑就行。不过也要提醒一句useWatchProps这类封装适合“一个 props 变化必然伴随某个固定副作用”的组件。如果你的组件里有十几个不同的监听关系全部塞进一个组合式函数反而更混乱。封装是好东西但不要为了炫技而过度抽象最终目标是让业务逻辑更容易读而不是更难懂。4.5 写在最后的几个实用小习惯分享几个我在实际项目里养成的习惯。第一watch监听props的 getter 里尽量精确到具体字段不要动不动() props.xxx然后内部再解构出一堆字段。字段写得越细触发范围越小代码的可读性也越好。第二需要初始化执行的回调优先用immediate: true不要额外写onMounted去补一次那样看着特别扭也容易出现重复执行。第三如果发现watch回调里要处理的事情特别多比如要更新三个本地 state、要调两个接口、还要操作 DOM那说明这个组件可能承担了太多职责不如把副作用拆到子组件里让每个watch只干一件明确的事。这几个习惯不算什么高深技巧但写多之后你会发现老出问题的代码基本都是把简单事情复杂化了。
返回列表