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
}
}
三个关键点,对应三类坑:
track只在get时收集 → 没读过的属性不收集依赖,不会触发更新- 深层代理是惰性的 →
reactive时不会递归,访问到才代理 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 应该是纯函数——只根据依赖计算返回值。一旦有副作用,会出两个问题:
- 副作用可能执行多次:
computed是惰性求值 + 缓存,依赖变化时重算,但什么时候重算是 Vue 控制的 - 可能导致无限循环:在
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 写」的操作,都会让响应式失效。记住这一点,不用背规则也能推出结果。
正在加载评论…