先说一个我几乎每周都能在 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 为什么官方建议直接用 computed
Vue 官方文档在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改成computed:
const form = computed(() => ({ name: props.user.name, age: props.user.age }))如果这个表单还需要支持编辑,并且把编辑后的数据同步回父级,可以用可写computed:
const 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 的定位对比速查表
| 比较维度 | computed | watch |
|---|---|---|
| 核心职责 | 声明式派生 | 触发副作用 |
| 返回值 | 有,且带缓存 | 不负责回传结果 |
| 依赖追踪 | 自动精确到字段 | 需自行指定 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',或者在回调里显式调用nextTick:
watch( () => 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 重构后的代码
我先把两个状态改成 computed:
const 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 找出来,一个个删掉,你会感觉到响应式代码终于回到了它该有的样子。