跳到主内容

从 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 时深度遍历整个值,对沿途遇到的对象、数组逐个建立依赖。

关键点有三个:

  1. 遍历成本与对象规模成正比:1000 个表单项、每项几十个字段,意味着每次都会访问数万个属性。
  2. 无法感知“真正关心的路径”:你可能只需要在 fields 的 label 变化时更新预览,但 deep 会把 rules、临时 UI 状态、滚动位置等全部纳入侦听。
  3. 回调里的旧值与新值指向同一对象:深层修改时,oldValue 和 newValue 引用相同,深比较或手动 diff 很容易再次遍历整棵树,进一步放大开销。

watch 与 watchEffect 的核心差异 ​

很多卡顿并不是 API 本身慢,而是选型用反了。

对比项watchwatchEffect
执行时机默认懒执行,依赖变化才触发组件初始化时立即执行一次
依赖来源显式指定数据源执行过程中自动收集
旧值获取可以拿到 newValue、oldValue不能直接获取旧值
适合场景精准响应某个状态、需要前后值对比聚合多个依赖、自动管理订阅
deep 风险容易对大对象开启 deep访问到大对象的深层属性时会隐式收集大量依赖

需要特别注意:watchEffect 并不等于“自动高性能”。如果回调里写了 console.log(formModel.value.fields[0].rules[0].message),它同样会沿路径收集依赖;如果在循环中遍历整个大对象,依赖收集范围甚至比显式 watch 更难排查。

下面这张图概括了两者从状态变化到副作用执行的链路:

响应式状态变化ref / reactivewatch 显式数据源支持 oldValuewatchEffect 自动收集立即执行一次调度副作用flush / 异步任务精准侦听优先避免整棵对象树遍历

方案一:把 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 下最重要的性能优化习惯。

免责声明

本工具与文章内容仅用于学习测试场景,优化建议仅供参考,请勿直接用于生产环境,由此产生的风险自行承担。