Vue 3 响应式的六个坑:解构、ref、watch 与性能

从 Proxy 的 get/set 拦截原理出发,推导出解构丢响应性、ref 少写 .value、watch 浅监听等六个高频问题的成因与修复方式。

Vue 3 的响应式系统用 Proxy 重写了,性能和能力都比 Vue 2 的 Object.defineProperty 强很多。但「更强大」也意味着「更多的隐式行为」——很多在 Vue 2 里不会出现的问题,在 Vue 3 里会以很奇怪的方式表现出来:界面不更新、组件重复渲染、内存越用越多。

这篇文章梳理六个最常踩的响应式坑,每个都有可复现的最小案例和修复方式。

一、原理:Proxy 到底做了什么

先看清楚底层,后面所有坑都能自己推出来。

const state = reactive({ count: 0, user: { name: 'a' } })

reactive 返回的是一个 Proxy,它的 handler 大致是这样的:

{
  get(target, key, receiver) {
    track(target, key)                    // 1. 收集依赖
    const res = Reflect.get(target, key, receiver)
    if (typeof res === 'object' && res !== null) {
      return reactive(res)                // 2. 深层对象惰性代理
    }
    return res
  },
  set(target, key, value, receiver) {
    const old = target[key]
    const ok = Reflect.set(target, key, value, receiver)
    if (ok && !Object.is(old, value)) {
      trigger(target, key)                // 3. 触发更新
    }
    return ok
  }
}

三个关键点,对应三类坑:

  1. track 只在 get 时收集 → 没读过的属性不收集依赖,不会触发更新
  2. 深层代理是惰性的 → reactive 时不会递归,访问到才代理
  3. trigger 用 Object.is 判断变化 → 相同值不触发

二、坑 1:解构丢失响应性

这是最高频的问题,没有之一。

<script setup>
const state = reactive({ count: 0, name: 'vue' })

// 错误:解构后 count 就是一个普通数字
const { count } = state

function inc() {
  count++        // 改的是局部变量,state.count 没变,视图不更新
}
</script>

根因:reactive 的响应性在 Proxy 上。解构取值时触发了一次 get,返回的是原始值快照,Proxy 的拦截能力就丢了。

三种修复

方式 A:不解构,始终通过 state.xxx 访问

最直接,也最容易在重构时被破坏。

方式 B:用 toRefs(或 toRef)

import { reactive, toRefs } from 'vue'

const state = reactive({ count: 0, name: 'vue' })
const { count, name } = toRefs(state)     // 每个都是 ref

function inc() {
  count.value++                            // 同步到 state.count
}

toRefs 把每个属性包装成 { get value() { return state[key] }, set value(v) { state[key] = v } },get/set 都指向原对象,所以响应性保留。

方式 C:storeToRefs(Pinia 场景)

import { storeToRefs } from 'pinia'
const store = useUserStore()

// 直接解构会丢响应性
const { userInfo } = store            // ✗

// 用 storeToRefs
const { userInfo } = storeToRefs(store)   // ✓

// 方法不需要 toRefs
const { logout } = store                  // ✓ 方法本身不是响应式数据

注意 props 的解构也是同一个问题:

const props = defineProps({ title: String })
const { title } = props        // ✗ 丢失响应性

// Vue 3.5+ 支持响应式解构
const { title } = defineProps({ title: String })   // ✓

Vue 3.5 之前要用 toRef(props, 'title')。

三、坑 2:ref 和 reactive 混用导致 undefined

const state = ref({ count: 0 })
console.log(state.count)      // undefined(应该是 state.value.count)

ref 无论包什么,访问都要 .value。这个错误在 JS 里会静默返回 undefined,在模板里却自动解包,所以很容易「模板里正常、JS 里报错」。

更隐蔽的一种:

const user = ref({ name: 'a' })
user.value = { name: 'b' }        // ✓ 整体替换,响应式正常
user.name = 'b'                   // ✗ 静默失败,user 是 ref 对象

判断口诀:

声明 JS 中访问 模板中访问
ref(x) x.value x
reactive(x) x.prop x.prop
toRefs(x) y.value y

为什么 reactive 不能整体替换?

let state = reactive({ a: 1 })
state = reactive({ a: 2 })     // ✗ 断开了原来的 Proxy,视图不更新

state 变量指向了新的 Proxy,但组件里保存的依赖还是旧 Proxy 的。这就是为什么官方建议:需要整体替换的对象用 ref,不需要替换的用 reactive。

四、坑 3:给 reactive 对象新增属性

Vue 2 里 this.$set 是必备知识,Vue 3 用 Proxy 后新增属性是可以的:

const state = reactive({ a: 1 })
state.b = 2          // ✓ Vue 3 支持

但有一个前提:模板里必须先「读过」这个属性。

const state = reactive({ list: [] })

// 模板:{{ state.extra }}
// 初始时 state.extra 是 undefined,get 被触发,依赖被收集
state.extra = 'x'    // ✓ 会更新

// 如果模板里没用到 state.extra
state.extra = 'x'    // 不会触发任何更新(也没必要)

真正会出问题的是这种:

const state = reactive({})

// 模板里写 {{ state.user?.name }}
state.user = { name: 'a' }    // ✓ 触发更新(因为读过了 state.user)

以及这种边界情况——用 delete 删属性:

delete state.a        // ✓ Vue 3 支持 deleteProperty 拦截

Vue 3 确实比 Vue 2 自由很多。但新增属性仍然建议在初始化时就声明,理由是 TypeScript 类型推导和代码可读性。

五、坑 4:watch 监听不到对象内部变化

const state = reactive({ user: { name: 'a' } })

// 默认是浅监听,只在 state.user 引用变化时触发
watch(() => state.user, (v) => console.log(v))
state.user.name = 'b'      // ✗ 不触发

// 修复 1:deep
watch(() => state.user, cb, { deep: true })

// 修复 2:直接监听 reactive 对象(自动 deep)
watch(state.user, cb)       // ⚠️ reactive 对象会自动 deep

// 修复 3:精确监听某个字段
watch(() => state.user.name, (v) => console.log(v))   // ✓ 推荐

重要差异:watch(reactiveObj, cb) 会自动开启 deep,但 watch(() => reactiveObj, cb) 不会。

const state = reactive({ a: 1 })

watch(state, cb)               // 自动 deep ✓
watch(() => state, cb)         // 浅监听 ✗ —— 因为返回的是同一个对象引用

这个差异非常容易搞错,因为两种写法看起来几乎一样。

性能提示:deep: true 会递归遍历整个对象来建立依赖。 监听一个 10000 条数据的列表,遍历开销很可观。能用精确路径就用精确路径。

// 差:大对象 deep
watch(state.bigList, cb, { deep: true })

// 好:监听长度
watch(() => state.bigList.length, cb)

// 好:监听特定 id
watch(() => state.bigList.map(i => i.id).join(','), cb)

六、坑 5:computed 里的副作用

const total = computed(() => {
  fetch('/api/log')          // ✗ 副作用
  return items.value.reduce((s, i) => s + i.price, 0)
})

computed 应该是纯函数——只根据依赖计算返回值。一旦有副作用,会出两个问题:

  1. 副作用可能执行多次:computed 是惰性求值 + 缓存,依赖变化时重算,但什么时候重算是 Vue 控制的
  2. 可能导致无限循环:在 computed 里修改它自己的依赖

更隐蔽的是异步 computed:

// ✗ 错误:返回 Promise,模板里拿到的是 Promise 对象
const user = computed(async () => {
  const res = await fetch('/api/user')
  return res.json()
})

正确做法是用 watch + ref:

const user = ref(null)
watch(userId, async (id) => {
  user.value = await fetchUser(id)
}, { immediate: true })

或者用官方推荐的 vue-query / swrv 这类库。

七、坑 6:ref 在数组 / 集合里的自动解包

ref 在 reactive 对象里会自动解包,但在数组和 Map/Set 里不会:

const r = ref(1)

const obj = reactive({ r })
console.log(obj.r)             // 1,自动解包

const arr = reactive([r])
console.log(arr[0])            // RefImpl { value: 1 },不解包
console.log(arr[0].value)      // 1

const map = reactive(new Map())
map.set('k', r)
console.log(map.get('k'))      // RefImpl,不解包

为什么数组不解包? 因为数组可以有 [0] 之外的索引访问、length、迭代器,自动解包会破坏 identity 一致性(arr[0] === r 应该成立)。

实际影响:

const list = reactive([ref('a'), ref('b')])
// 模板里 {{ list[0] }} 显示 "RefImpl" 而不是 "a"

修复方式是不要在数组里放 ref,直接放值:

const list = ref(['a', 'b'])       // ✓ 用 ref 包整个数组
list.value[0] = 'c'

八、性能:v-for 的 key 与响应式开销

响应式本身有成本。一个 5000 行的表格,每行都用 reactive 包,内存和 CPU 都会明显上升。

<!-- 差:每次渲染都创建新对象 -->
<Row v-for="item in list" :key="item.id" :data="{ ...item }" />

<!-- 好:直接传引用 -->
<Row v-for="item in list" :key="item.id" :data="item" />

key 用索引的代价:

<!-- 差 -->
<li v-for="(item, i) in list" :key="i">

<!-- 好 -->
<li v-for="item in list" :key="item.id">

用索引做 key,删除第一项时后续所有项都「变成」了不同的数据但 key 没变,Vue 会尝试复用 DOM 并只 patch 内容。如果列表项里有输入框、有内部状态(比如展开/收起),状态会串位。

v-if 与 v-for 不要放在同一元素上:

<!-- 差:v-if 优先级低于 v-for(Vue 3),会为每一项都求值一次 -->
<li v-for="item in list" v-if="item.active" :key="item.id">

<!-- 好:先过滤再循环 -->
<li v-for="item in activeList" :key="item.id">
<script setup>
const activeList = computed(() => list.filter(i => i.active))
</script>

Vue 3 里 v-if 的优先级低于 v-for,所以 v-if 拿不到 item 变量——这会直接报错 item is not defined。Vue 2 是相反的。这是迁移时的一个坑。

九、shallowRef / markRaw:什么时候该主动放弃响应式

有些数据天生不需要响应式:大型字典、图表实例、第三方库对象。

// 1. shallowRef:只有 .value 整体替换才触发更新
const bigData = shallowRef(loadHugeList())
bigData.value = newList          // ✓ 触发
bigData.value[0].name = 'x'      // ✗ 不触发(也不需要)

// 2. markRaw:让对象永远不被代理
const chart = markRaw(echarts.init(el))
const state = reactive({ chart })    // chart 不会被 Proxy 包装

// 3. shallowReactive:只有第一层属性响应式
const state = shallowReactive({ a: 1, nested: { b: 2 } })
state.a = 2              // ✓
state.nested.b = 3       // ✗

ECharts / Mapbox / 大型表单设计器这类场景,用 markRaw 能显著降低开销。ECharts 实例被 Proxy 包装后,内部大量的 self-reference 会导致性能骤降甚至栈溢出。

十、调试响应式问题的方法

视图不更新时,按这个顺序排查:

1. 数据是不是 reactive / ref?
   console.log(isReactive(obj), isRef(obj))

2. 改的是 Proxy 还是原对象?
   const raw = toRaw(state)
   state.a = 1  vs  raw.a = 1     ← 改 raw 不会触发更新

3. 依赖有没有被收集?
   // 在组件 render 里打日志,看是否会重跑
   onUpdated(() => console.log('updated'))

4. 是不是对象被整体替换了?
   // reactive 对象被重新赋值 = 断开 Proxy

5. 是不是在异步回调之外修改?
   // 某些第三方回调不在 Vue 的调度队列里
   nextTick(() => { ... })

最实用的一个技巧是 toRaw:

import { toRaw, isReactive } from 'vue'

const raw = toRaw(state)
console.log(raw === originalObject)    // true
// 如果代码里不小心用了 raw 去改数据,响应式就失效了

数据从第三方库(比如 IndexedDB 读出来的、axios 返回的)进来时,会被自动 reactive 包装。有时你传给第三方库的对象被 Proxy 包装后行为异常——这时用 toRaw 剥掉。

小结

六个坑对应六个判断:

现象 根因 修复
解构后不更新 Proxy 拦截丢失 toRefs / storeToRefs
JS 里拿到 undefined ref 少了 .value 加 .value
reactive 整体替换不更新 引用被换掉 用 ref
watch 不触发 浅监听 deep: true 或精确路径
computed 结果异常 有副作用 改纯函数 + watch
数组里 ref 显示 RefImpl 数组不解包 用 ref 包整个数组

最后一条经验:响应式的本质是「读时收集依赖、写时触发更新」。任何破坏「通过 Proxy 读」或「通过 Proxy 写」的操作,都会让响应式失效。记住这一点,不用背规则也能推出结果。

查看「前端与性能」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。