外观
从 1200ms 到 16ms:Vue3 watch 深侦听卡顿避坑实录
问题背景:一次 deep 侦听引发的输入卡顿
在一个复杂表单页面中,左侧是可拖拽的 JSON Schema 配置器,右侧是实时预览。为了在配置变化后生成预览,最初很自然地写下了下面的代码:
vue
<script setup>
import { ref, watch } from 'vue'\n
// 一个包含上千个节点、嵌套很深的表单模型
const formModel = ref({
fields: [],
rules: {},
metadata: {}
})
// 看似一劳永逸的深侦听
watch(
formModel,
async (next) => {
// 序列化 + 生成预览,属于较重的计算
previewState.value = await buildPreview(next)
},
{ deep: true }
)
</script>数据量小的时候没有任何问题;但当 fields 增长到 1000 项、每项再嵌套 5 层校验规则后,每次在输入框敲一个字符都会明显掉帧。Performance 面板里最长的一次侦听触发链路达到 1200ms,其中绝大部分时间并不是 buildPreview,而是 Vue 在遍历整个响应式对象、收集深层依赖并比对新旧值。
这就是 Vue3 项目中非常典型的「deep 深侦听卡顿」。本文会从原理、方案对比到可运行代码,讲清 watch 与 watchEffect 到底该怎么选。
为什么 deep:true 会变慢
watch 默认是懒执行的,它只侦听你明确传入的数据源。但一旦开启 deep: true,为了能感知对象内部任意属性的变化,Vue 必须在执行 getter 时深度遍历整个值,对沿途遇到的对象、数组逐个建立依赖。
关键点有三个:
- 遍历成本与对象规模成正比:1000 个表单项、每项几十个字段,意味着每次都会访问数万个属性。
- 无法感知“真正关心的路径”:你可能只需要在
fields的label变化时更新预览,但 deep 会把rules、临时 UI 状态、滚动位置等全部纳入侦听。 - 回调里的旧值与新值指向同一对象:深层修改时,
oldValue和newValue引用相同,深比较或手动 diff 很容易再次遍历整棵树,进一步放大开销。
watch 与 watchEffect 的核心差异
很多卡顿并不是 API 本身慢,而是选型用反了。
| 对比项 | watch | watchEffect |
|---|---|---|
| 执行时机 | 默认懒执行,依赖变化才触发 | 组件初始化时立即执行一次 |
| 依赖来源 | 显式指定数据源 | 执行过程中自动收集 |
| 旧值获取 | 可以拿到 newValue、oldValue | 不能直接获取旧值 |
| 适合场景 | 精准响应某个状态、需要前后值对比 | 聚合多个依赖、自动管理订阅 |
| deep 风险 | 容易对大对象开启 deep | 访问到大对象的深层属性时会隐式收集大量依赖 |
需要特别注意:watchEffect 并不等于“自动高性能”。如果回调里写了 console.log(formModel.value.fields[0].rules[0].message),它同样会沿路径收集依赖;如果在循环中遍历整个大对象,依赖收集范围甚至比显式 watch 更难排查。
下面这张图概括了两者从状态变化到副作用执行的链路:
方案一:把 deep 侦听改成精准路径侦听
绝大多数情况下,业务并不需要监听整个模型,而只是关心某些会影响预览的字段。此时可以给 watch 传一个 getter,只返回必要数据。
vue
<script setup>
import { ref, watch } from 'vue'
const formModel = ref({
fields: [
{ id: 1, label: '用户名', type: 'input' }
],
rules: {},
ui: { activeId: 1, scrollTop: 0 }
})
const previewState = ref(null)
// 只侦听影响预览的字段,Vue 只会访问这里出现的属性
watch(
() => formModel.value.fields.map((field) => ({
id: field.id,
label: field.label,
type: field.type
})),
async (visibleFields) => {
previewState.value = await buildPreview({
fields: visibleFields
})
},
{ deep: false }
)
function buildPreview(model) {
return Promise.resolve(JSON.stringify(model))
}
</script>
<template>
<input v-model="formModel.fields[0].label" />
<pre>{{ previewState }}</pre>
</template>这里的 getter 每次都会返回一个新数组,因此 watch 会在 getter 结果变化后触发回调。但它只遍历 fields,且只读取三个字段,不再触碰庞大的 rules 和 UI 临时状态。
如果数组非常大,连 map 都想避免,可以使用计算属性配合稳定的序列化键:
typescript
import { computed, watch } from 'vue'\n
const previewKey = computed(() => {
// 只拼接真正影响结果的字段,避免 JSON.stringify 整个模型
return formModel.value.fields
.map((item) => `${item.id}:${item.label}:${item.type}`)
.join('|')
})
watch(previewKey, async (key, oldKey) => {
if (key === oldKey) return
previewState.value = await buildPreview(formModel.value)
})方案二:大对象使用 shallowRef,手动控制更新粒度
对于 Schema 树、可视化画布节点、表格数据这类“整体替换、局部少改”的结构,更推荐使用 shallowRef。它不会对对象内部做深层代理,只有 .value 被整体替换时才触发响应。
typescript
import { shallowRef, watch, triggerRef } from 'vue'\n
interface FieldNode {
id: number
label: string
type: string
}
// shallowRef 内部不会被深度代理
const fieldList = shallowRef<FieldNode[]>([
{ id: 1, label: '用户名', type: 'input' }
])
// 普通 watch 即可,不需要 deep
watch(fieldList, (next, prev) => {
console.log('列表引用发生变化', next.length, prev?.length)
})
function updateLabel(id: number, label: string) {
// 先基于旧数据生成一份新数据,再整体替换引用
fieldList.value = fieldList.value.map((item) =>
item.id === id ? { ...item, label } : item
)
}
// 某些极端场景下,如果你确实原地修改了对象,
// 再显式通知依赖;不要把它当默认写法。
function forceEmit() {
triggerRef(fieldList)
}这种写法的收益非常直接:没有深层代理、没有深层遍历、更新路径也符合不可变数据思想。配合列表虚拟滚动后,上千节点的编辑场景通常可以把侦听开销压到个位数毫秒。
方案三:watchEffect 用于聚合依赖,而不是替代 deep
watchEffect 适合“我这个副作用依赖哪些状态,让代码自己说明”的场景,比如根据多个筛选条件请求列表:
vue
<script setup>
import { ref, watchEffect } from 'vue'
const keyword = ref('')
const status = ref('all')
const page = ref(1)
const loading = ref(false)
const rows = ref([])
watchEffect(async (onCleanup) => {
// 自动收集 keyword/status/page,初始化时也会执行
const controller = new AbortController()
// 组件卸载或下一次执行前取消上一次请求
onCleanup(() => controller.abort())
loading.value = true
try {
const res = await fetch(
`/api/list?keyword=${keyword.value}&status=${status.value}&page=${page.value}`,
{ signal: controller.signal }
)
rows.value = await res.json()
} catch (err) {
if ((err as Error).name !== 'AbortError') throw err
} finally {
if (!controller.signal.aborted) loading.value = false
}
})
</script>但不要在 watchEffect 中遍历整个大对象,否则它会把遍历到的所有属性都收集为依赖。下面就是常见的反例:
typescript
// 反例:为了“自动监听”,在 effect 中深度访问整个模型
watchEffect(() => {
// 这会访问 fields 下所有对象的所有枚举属性,
// 任意字段变化都会触发,和 deep:true 的问题本质相同
saveDraft(JSON.stringify(formModel.value))
})如果确实要自动保存草稿,更合理的方式是监听明确的版本号,或者对输入做防抖:
typescript
import { watch, ref } from 'vue'
import { debounce } from 'lodash-es'
const draftVersion = ref(0)
const persistDraft = debounce(() => {
localStorage.setItem('form-draft', JSON.stringify(formModel.value))
}, 500)
// 业务代码在明确发生有效编辑后递增版本号
watch(draftVersion, () => persistDraft())flush 与 immediate 的额外开销
除了 deep,两个选项也常被误用:
immediate: true会让watch立即执行一次。如果回调里有请求、DOM 测量或重计算,首屏就会付出成本;能用惰性计算或watchEffect明确表达初始化逻辑时,不必强行加 immediate。flush: 'sync'会在依赖变更时同步触发。输入框连续更新时,它可能在一次渲染周期内执行多次,容易造成布局抖动。大多数业务使用默认的pre,涉及 DOM 尺寸测量再考虑post。
typescript
watch(
() => panelRef.value?.offsetHeight,
(height) => {
// 需要在 DOM 更新后读取布局时,再使用 post
layoutState.value.panelHeight = height ?? 0
},
{ flush: 'post' }
)避坑总结:三个高频错误与修正方法
1. 对接口返回的大数组直接 deep: true
错误现象:表格勾选、搜索输入后明显卡顿,Performance 中出现大量 traverse 和响应式 getter 调用。
修正方法:纯展示数据使用 shallowRef 或 markRaw;需要监听变化时监听分页、筛选条件、版本号,而不是监听整份数据。
typescript
import { shallowRef, markRaw } from 'vue'
const tableData = shallowRef([])
async function loadData() {
const data = await fetchList()
// 复杂组件实例、第三方对象不需要响应式时使用 markRaw
tableData.value = markRaw(data)
}2. 在 watch 回调中修改被监听对象造成循环触发
错误现象:控制台出现 Maximum recursive updates exceeded,或者回调反复执行。
修正方法:不要在同一个深层对象上“读旧对象、改旧对象、再触发自己”。使用不可变更新生成新引用,或拆出独立的派生状态;确需回写时增加条件判断。
typescript
watch(visibleFieldKeys, async (keys) => {
const next = await normalizeFields(keys)
// 只在结果确实不同时回写,避免无限循环
if (JSON.stringify(next) !== JSON.stringify(previewState.value)) {
previewState.value = next
}
})3. 误以为 watchEffect 会自动做性能优化
错误现象:只是改了一个无关字段,请求、保存、重算却全部执行;排查时很难看清依赖来源。
修正方法:watchEffect 中只访问真正需要的响应式属性,避免 JSON.stringify、深拷贝、全量遍历。需要旧值、精确控制触发源或调试依赖时,优先使用显式 watch。
4. 高频输入不防抖,导致重任务挤满主线程
错误现象:连续输入时预览区重复渲染,输入框掉帧,网络请求出现竞态,旧响应覆盖新响应。
修正方法:对搜索、草稿保存、预览生成增加防抖或 AbortController;把重计算移到 computed 缓存或 Web Worker,侦听器只负责调度。
最终选型建议
一句话概括:能用精准 getter,就不要 deep;能整体替换引用,就不要原地改大对象;需要自动聚合依赖再用 watchEffect。
在这次表单优化中,我们把 watch(formModel, ..., { deep: true }) 拆成了三条精准侦听:预览监听 visibleFieldKeys,草稿监听防抖后的 draftVersion,布局监听 DOM 尺寸。最大侦听耗时从约 1200ms 降到 16ms 以内,输入恢复流畅。
Vue3 的响应式系统非常强大,但“能监听到”不等于“应该全量监听”。明确依赖边界,才是组合式 API 下最重要的性能优化习惯。
免责声明
本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。