Vue 3 响应式常见陷阱:身份、解构与副作用边界

Vue 3 的响应式建立在 Proxy、依赖收集和触发更新之上。多数问题不是“Vue 没监听到”,而是代码已经离开代理对象、替换了错误的引用,或者把副作用放进了不合适的计算过程。

解构会丢失属性访问的响应式连接

const state = reactive({count: 0})
const {count} = state

state.count++
console.log(count) // 仍是解构时的普通数字

需要保持引用时使用 toRefs

const state = reactive({count: 0})
const {count} = toRefs(state)

对于只读取一次的配置,普通解构没有问题;不要机械地给所有属性套 toRefs

不要随意替换 reactive 根对象

let form = reactive({name: '', email: ''})
form = reactive(serverData) // 使用旧 form 的地方不会跟上

可以用 Object.assign(form, serverData) 保留代理身份,或者一开始就用 ref 并通过 form.value = serverData 明确替换。

Proxy 与原对象不是同一身份

reactive(raw) !== raw。如果把原对象和代理对象混合作为 Map Key、集合成员或第三方库实例,可能出现查找失败。业务 ID 比对象身份更稳定;不需要代理的复杂类实例可以使用 markRaw,大型不可变结果可用 shallowRef

computed 必须保持纯粹

Computed 用来从状态派生值,不应发请求、写状态或操作 DOM:

const fullName = computed(() => `${user.firstName} ${user.lastName}`)

副作用放在 watchwatchEffect,并处理竞态:

watch(query, async (value, _, onCleanup) => {
  const controller = new AbortController()
  onCleanup(() => controller.abort())
  result.value = await search(value, controller.signal)
})

当输入快速变化时,旧请求会被取消,避免后返回的旧结果覆盖新结果。

Watch 的选择原则

  • computed:纯派生,有缓存。
  • watch:明确监听源,需要新旧值或精确控制时机。
  • watchEffect:依赖较多且由执行过程自动收集。
  • 事件处理:由一次用户动作直接触发的写操作,不必绕到 Watch。

深度 Watch 大对象代价高,而且很难看出真正依赖。优先监听具体字段或稳定的派生值。需要在 DOM 更新后读取元素时使用 flush: 'post',不要用随意的 setTimeout 猜更新时间。

表单和集合的实用技巧

  • 清空响应式数组用 items.splice(0) 或替换 ref.value
  • Map、Set 的增删可以被追踪,但取出的普通对象是否响应式取决于其来源。
  • Props 是只读输入,不要在子组件直接修改;用 Emit 或本地副本表达编辑流程。
  • 模板中 Ref 会自动解包,但数组和集合里的 Ref 行为需要明确测试。

排查顺序

  1. 当前访问的是原对象、Proxy、Ref 还是解构后的普通值?
  2. 依赖是否真的在 computed/watch 执行期间被读取?
  3. 是否替换了 reactive 变量而不是更新代理?
  4. 是否有异步竞态或未清理的副作用?
  5. 是否用深度 Watch 掩盖了状态设计问题?

参考资料