ARTICLE DETAIL

资讯详情

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

Vue 中 watch 与 computed 的正确用法:何时该删掉 watch?

Vue 中 watch 与 computed 的正确用法:何时该删掉 watch? 先说一个我几乎每周都能在 code review 里看到的场景组件里一个ref本质上是从另一个 prop 或状态“派生”出来的但实现却用了watch手动同步。每次看到这种写法我都会在评审意见里直接写一句“这个 watch 写法我劝你早点删掉。”这不是要为难写代码的人而是这种写法踩过的坑足够深。从最早把一个变量同步到另一个变量到后来搜索竞态、对象深层监听、父子数据流打架我见过太多项目最后整个组件里长满了watch。今天决定把这个问题展开说清楚这种写法到底错在哪、应该换成什么、什么情况下watch又必须保留。无论你刚接触 Vue 组合式 API还是已经被各种watch同步逻辑折磨过这篇文章都值得你花十分钟过一遍。1. 被我盯上的这段 watch手动同步状态的反面教材1.1 一段典型到几乎每周都能看到的代码假设你有一个编辑表单组件父级把user传进来子组件内部维护一个form用来展示和编辑script setup import { ref, watch } from vue const props defineProps({ user: { type: Object, default: () ({ name: , age: 0 }) } }) const form ref({ name: , age: 0 }) // 这段 watch 写法我劝你早点删掉 watch( () props.user, (user) { form.value { name: user.name, age: user.age } }, { immediate: true, deep: true } ) /script表面上看这段代码非常“完美”父级user一变表单跟着同步加了immediate: true首屏不会出现空表单加了deep: true连user.name这种深层字段变化都能捕获。实际跑起来需求不复杂时确实一切正常。但问题恰恰藏在“一切正常”这四个字里。代码不只是写给今天的需求更要经受后续三个月的维护、新同事的接手以及各种极端交互的考验。手动同步状态的写法在这些层面全都会暴露问题。1.2 为什么“即时同步”看着顺眼其实是在给数据流添乱我们逐行想一下这段 watch 的语义props.user变化后把我手里另一个状态完整的覆盖一遍。这是典型的“复制粘贴式同步”。它带来的第一个问题是无法区分“外部更新”和“用户正在编辑”。表单本身是一个可编辑的交互状态用户正在输入name的时候如果父级user恰好更新form.value立刻被覆盖输入框里的字瞬间消失。这种问题在真实业务里非常难复现因为需要恰好卡在异步返回的节点上。第二个问题是它破坏了“单一数据源”的心智模型。form本身应该是当前组件真正持有、并且允许修改的状态可它偏偏又会在某个不可预期的时间点被props.user整体替换。你在 Vue DevTools 里看到form变了根本看不出是用户操作改的还是 watch 同步改的。第三个问题是字段越来越多以后 watch 会迅速膨胀。今天只需要同步name和age明天要多同步address、phone后年还要在同步前做一层脱敏处理。每加一个字段这段 watch 回调就重一分最后变成一个没人敢动的巨型函数。1.3 如果继续加需求它会演变成什么样子最糟糕的形态是我在不少项目里真实见过的“watch 套 watch 标志位”const isEditing ref(false) const form ref({ name: , age: 0 }) watch( () props.user, (user) { if (isEditing.value) return form.value { name: user.name, age: user.age } }, { immediate: true, deep: true } ) watch(isEditing, (editing) { if (editing) return form.value { name: props.user.name, age: props.user.age } })这段代码看起来更严谨了但你已经需要两个watch加上一个额外标志位来维护一个本来很简单的关系。再往后你会开始纠结isEditing什么时候重置、用户保存后要不要同步回props.user、表单取消后要不要从 props 再拉一次……那一整套逻辑本质上就是在用命令式的方式手写一套“派生状态系统”。而 Vue 的computed天生就是干这个的。2. computed 才是派生状态的默认答案响应式系统的底层逻辑2.1 为什么官方建议直接用 computedVue 官方文档在computed部分专门有一句话不要为了让一个响应式状态跟随另一个状态变化而写 watch正确做法是使用computed。原因很简单两个响应式状态之间存在“派生关系”时本质上你需要的是一份能自动跟随依赖变化的计算结果而不是一份需要手动维护的副本。举个最经典的例子const firstName ref() const lastName ref() // 你的第一反应可能是这段 watch watch( [firstName, lastName], ([f, l]) { fullName.value ${f} ${l} } )正确的写法是const fullName computed(() firstName.value lastName.value)两段代码输出结果几乎一样。但computed版本只声明了一个派生关系fullName永远是firstName lastName的结果。而watch版本则是在源数据变化后命令式地把目标状态手工赋值一遍你仍然需要同时维护firstName、lastName、fullName三个状态的一致性。2.2 computed 的缓存与依赖收集靠什么实现computed在 Vue 响应式系统内部本质上是一个自带依赖跟踪和结果缓存的“惰性求值器”。你读取它的时候它会执行 getter同时收集 getter 里访问到的响应式字段一旦这些依赖发生变化computed只会把自身标记为“可能需要重算”。真正的重算要等到下次有人读取它时才发生。这意味着两件事如果某个依赖变了但重算后的结果和之前一样组件不会白渲染一遍。如果组件里根本没人读取这个computed它就算依赖全变了也不会做任何计算。watch恰恰相反。watch不管有没有人读取结果只要 source 变了回调就一定会执行。所以拿watch管理派生状态等于让整个组件在“不必要重算”和“不该重算”这两种情况里反复横跳。打个生活化的比方computed就像 Excel 里的公式C2*D2会自己跟随 C2、D2 的变化重算而且只在你需要看结果时才触发。watch就像你写了一段宏每次 C2 一改就手动把 C2*D2 的结果复制到 E2。公式和宏都能让 E2 显示正确结果但公式的因果关系清晰宏则随时可能因为复制时机不对而出错。2.3 把第一段的 watch 换成 computed 之后长什么样回到 1.1 的例子把form改成computedconst form computed(() ({ name: props.user.name, age: props.user.age }))如果这个表单还需要支持编辑并且把编辑后的数据同步回父级可以用可写computedconst form computed({ get: () ({ name: props.user.name, age: props.user.age }), set: (val) emit(update:user, { ...props.user, ...val }) })这里emit需要你在script setup里提前定义const emit defineEmits([update:user])。这个版本没有watch没有deep: true没有immediate: true也没有isEditing标志位。form与props.user的关系是声明式写死的不可能不同步也不可能覆盖用户正在编辑的状态——因为编辑本身就是通过 setter 往父级反写。2.4 watch 与 computed 的定位对比速查表比较维度computedwatch核心职责声明式派生触发副作用返回值有且带缓存不负责回传结果依赖追踪自动精确到字段需自行指定 source必要时还要 deep惰性求值是否适合场景模板渲染、状态派生、数据加工异步请求、DOM 操作、外部系统同步典型误用把副作用写进 getter用它手动同步另一个 ref这张表是我每次做 code review 时脑子里自动浮现的基准线。3. watch 在真实项目里最容易爆的三种变体如果说上一节是“不该用 watch 的地方用了 watch”那下面这三种就是“该用 watch 却没用好”的典型。它们各自产生的坑比单纯的数据同步更隐蔽也更容易在评审里被放过。3.1 变体一在 watch 回调里继续改自己监听的数据这种写法是我见过最多、也最容易引起线上事故的watch( () searchValue.value, (val) { searchValue.value val.trim() } )你可能觉得这不就是个“数据清洗”吗为什么不行关键在于 Vue 的watch回调默认在一个批处理队列里执行。如果你只在同步代码里改Vue 会把同一次触发合并在一个 tick不会立刻再次触发这个 watch。但只要出现异步路径比如这个过滤器被封装到 debounce 之后执行或者你在回调里调用了别的函数间接修改数据就很可能会再次命中 watch然后第二次回调又改数据再次触发……更严重的是跨 watch 互相触发watch(paramA, (v) { paramB.value transform(v) }) watch(paramB, (v) { paramA.value inverseTransform(v) })两个 watch 互为对方的“因”又互为对方的“果”直接形成报警风暴页面卡死、CPU 飙高但你在业务代码里又看不出哪一行有问题。正确的做法是调整数据入口输入值先 trim 再写进响应式状态或者用computed去返回加工后的值而不是在 watch 回调里“修正”它正在监听的数据。watch 只负责响应变化不负责篡改来源。3.2 变体二带异步请求的 watch搜索结果被旧响应覆盖watch 另一个高频使用场景是“状态变化后发请求”。这个方向本身是对的但很多人写成了这样watch( keyword, async (kw) { list.value await fetchSearch(kw) }, { immediate: true } )看着没毛病但实际联调时你会发现用户先输入了“vue”又马上输入“react”。第一次请求发出去了还没返回第二次请求紧接着发出。如果网络环境不稳定第一次请求可能 300ms 后才回来而第二次请求 100ms 就回来了。结果第二次先渲染正确结果第一次再把旧结果覆盖到页面上展示的却是“vue”的搜索列表。这就是典型的响应式竞态。处理方案里我建议至少加一层“请求失效”机制。方式一用自增 id 判断当前响应是否最新。let queryId 0 watch( keyword, async (kw) { const current queryId const res await fetchSearch(kw) if (current queryId) { list.value res } }, { immediate: true } )方式二用浏览器原生 AbortController旧请求直接丢弃。let controller null watch( keyword, async (kw) { controller?.abort() controller new AbortController() try { const res await fetchSearch(kw, { signal: controller.signal }) list.value res } catch (e) { if (e.name AbortError) return throw e } }, { immediate: true } )如果你的项目里这种“带参数的请求”很多我更建议抽一个useRequest组合式函数把防抖、取消、loading 状态全部收进去。组合式 API 本身就是用来收编这类复用的不需要在每个组件里手写一遍。3.3 变体三deep watch 监听对象越监听越慢第三种变体是把deep: true当成“精确监听”的替代品。说到底层deep: true会让 Vue 在对象任意层级字段变化时都触发回调。为了做到这一点它需要递归遍历整个对象并且维护整棵对象树的依赖关系。当一个对象里嵌套了数组、数组里又嵌套对象时每一步变化都会引发大量比较逻辑。更痛的问题是误触发你只关心props.user.name和props.user.age但只要user对象里任何一个字段变化回调就会执行一遍哪怕那个字段你根本用不到。比较好的做法是“getter 收敛”把监听源收敛到具体关心的字段。// 不建议 watch( () props.user, handler, { deep: true, immediate: true } ) // 建议 watch( () [props.user.id, props.user.name], handler, { immediate: true } )第二次写法里Vue 会通过数组里的基础类型值做比较只有id或name真正变化才会触发回调。这样既不需要 deep 递归又避免了无关字段的干扰。如果确实要监听一个对象里的许多字段我一般是先问自己这个回调究竟要响应什么如果答案是“对象里任意内容变了都要重新渲染某个图表”那deep: true还有其存在价值。但只要存在一两个关键字段就永远优先让 getter 返回那两三个值。4. 哪些场景下 watch 应该保留而且必须保留我前面劝删的都是“拿 watch 同步状态”的错误用法。但如果因为怕坑就把所有 watch 都删掉那就矫枉过正了。watch 的核心职责是当某个状态变化之后去执行一个有副作用的动作。这个定位是 computed 替代不了的。4.1 副作用代理请求、埋点、外部库接管至少有这三类场景watch 是正确的选择异步请求当筛选条件变化后拉取新列表。埋点统计用户切换分组时上报行为数据。外部系统接管把响应式数据同步给 ECharts、Monaco、Leaflet 这类第三方实例。以 ECharts 为例项目里常有这样的组合const chartOption computed(() buildOption(props.data)) watch( chartOption, (newOption) { chart.value?.setOption(newOption, true) } )这里用 computed 负责把 props 加工成图表配置watch 负责把配置交给外部实例。这个分层非常干净数据层完成派生副作用层完成同步。watch 不参与“结果怎么算”只负责“结果变了要执行什么动作”。4.2 利用 watch 返回值做资源清理组合式 API 里的 watch 会返回一个 stop 函数用于手动停止监听。export function useLogger(source, eventName) { const stop watch(source, (val) { logger.send(eventName, val) }) onScopeDispose(stop) return stop }这是 watch 很强的能力它可以在组件卸载、或者组合式函数作用域失效时自动释放资源。如果你用了原生事件监听、定时器、或者第三方库订阅watch 加onScopeDispose能保证不会产生内存泄漏。另一个容易被忽略的参数是flush。默认情况下 watch 回调在 DOM 更新前执行如果你想在回调里拿到更新后的 DOM需要改成flush: post或者在回调里显式调用nextTickwatch( () props.activeId, async (id) { await nextTick() element.value?.scrollIntoView() } )这种“依赖状态变化后再操作 DOM”的场景就是真正需要 watch 的场景换成 computed 反而没法实现。4.3 让 watch 停在“副作用层”而不是“数据层”我把这种分层方法总结成一句话派生状态交给 computed外部影响交给 watch。只要每个响应式状态都能回答“我是从哪里算出来的”这个状态就应该用 computed。而如果一个变化需要产生请求、打印、通知外部系统等外部影响就必须用 watch 或 watchEffect。watchEffect 和 watch 的差别在于是否主动声明依赖。需要主动收集依赖、且希望首次立即执行的场景watchEffect 更顺手需要明确指定监听源、或者需要控制 immediate 和 flush 的场景watch 更合适。按这个原则整理代码后watch 的数量会自然下降留下来的每个 watch职责都会非常清楚。这也是我在评审时判断“这个 watch 该不该删”的核心标准。5. 实战重构记录把项目里的坏 watch 一个接一个清掉聊完原理我拿一个真实项目里常见的列表筛选页做一次完整重构给你看看清理坏 watch 后的变化。5.1 一个列表筛选页面的原始版本原始代码长这样script setup import { ref, watch, computed } from vue const keyword ref() const page ref(1) const pageSize ref(20) const list ref([]) const loading ref(false) const total ref(0) // watch 1条件变化后拉列表 watch( [keyword, page, pageSize], async () { loading.value true const res await fetchList({ keyword: keyword.value, page: page.value, pageSize: pageSize.value }) list.value res.data total.value res.total loading.value false }, { immediate: true } ) // watch 2列表长度变化时同步一个展示文案 watch( () list.value.length, () { totalText.value 共 ${list.value.length} 条 } ) /script把这段代码的问题列出来total是res.total的副本watch 里赋值存在时序不一致风险。totalText是从list.value.length派生出来的属于“用一个 ref 同步另一个 ref”的典型误用。请求没有任何防抖和竞态保护快速输入时结果会被旧响应覆盖。loading.value分散在异步回调不同分支里后期加错误处理时很容易漏掉重置。5.2 重构后的代码我先把两个状态改成 computedconst params computed(() ({ keyword: keyword.value, page: page.value, pageSize: pageSize.value })) const totalText computed(() 共 ${list.value.length} 条)再把请求封装成一个useRequest组合式函数内部处理防抖和竞态import { ref, watch, readonly } from vue export function useRequest(requestFn, options {}) { const data ref(options.initialData ?? null) const loading ref(false) const error ref(null) let queryId 0 let timer null watch( options.source, async () { clearTimeout(timer) timer setTimeout(async () { const id queryId loading.value true error.value null try { const result await requestFn() if (id queryId) { data.value result } } catch (e) { if (id queryId) error.value e } finally { if (id queryId) loading.value false } }, options.debounce ?? 0) }, { immediate: true } ) return { data, loading, error, retry: () {} } }然后在组件里只需要声明 source 来源watch 逻辑就被完全收进工具函数里了const { data, loading } useRequest( () fetchList(params.value), { source: params, debounce: 300 } )到这里页面里原来的两个 watch 全部消失。派生关系用 computed 表达请求副作用被组合式函数封装组件主体只剩业务状态和模板看代码的人一眼就能理解数据是从哪来的。5.3 删掉无用 watch 后我实测观察到的收益这个重构做完最明显的感受不是“代码行数变少了”而是排错成本变低了。以前遇到“列表没按最新条件刷新”这类 bug我得顺着 watch 链路一步步查watch 有没有触发immediate 配置对不对async 回调里 loading 和 data 有没有按正确顺序赋值现在数据来源变成了params组件直接接受computed派生结果派生错了查 computed请求错了查 useRequest边界非常清晰。性能上也有改善。deep watch 消掉后一次操作引发的依赖变化从整棵对象树收敛到几个标量值多余渲染自然减少。尤其是列表页数据量一大这种差异在低端设备上滚动会明显感觉到更流畅。还有一个隐性收益是 code review 效率。现在看到 watch只会有这两种情况它是合法的副作用或者它是应该删除的反模式。评审意见也从“让我想半天这段代码为什么这么写”变成“把派生状态换成 computed把请求收进 useRequest”。5.4 每个 watch 都值得做一次三分钟体检我把自己平时评审 watch 时的检查顺序整理成一个清单你在删之前挨个过一遍watch 回调里是不是在做“从一个响应式状态往另一个响应式状态赋值”如果是改成 computed。watch 监听的对象是不是很大而你其实只关心其中几个字段用 getter 收敛只返回必要的字段。watch 回调里有异步请求但没有任何竞态保护补上请求 id 失效或 AbortController并考虑防抖。watch 回调里是否直接修改了它正在监听的数据本身如果是把清洗逻辑前移到数据入口别让 watch 变“凶手”。四条都过一遍之后还觉得这个 watch 有必要那基本就是合理的。我甚至会建议在这种 watch 上留一行注释写清楚“这是在同步外部副作用请不要改成 computed”帮助后来的维护者理解意图。最后再分享一个我自己的评审习惯看到 watch我第一反应不是“删掉”而是先问“这个 watch 到底在同步状态还是在同步副作用”。如果是前者我用 computed 或组合式函数替掉如果是后者我接着检查它的防抖、取消、flush。watch 不是坏东西盲目的 watch 才是。把你项目里那些同步状态的 watch 找出来一个个删掉你会感觉到响应式代码终于回到了它该有的样子。
返回列表