Vue3 响应式避坑:ref 与 reactive 的边界

发布于2026-10-12 12:24 阅读16次 数据返回了界面却不更新?本文从一个真实 bug 出发,讲清 Vue 3 中 ref 与 reactive 的适用边界、解构丢失响应性的原因、watch 的三个常见误用,以及 props 解构的陷阱,最后给出一条快速判断口诀与排查思路。
## 一个诡异的 bug
后台列表页,明明数据已经返回,但表格有一行显示的还是旧值。点进详情再返回,又正常了。最后定位到原因:用 `reactive` 包了一个数组,往里面 `push` 新对象后,模板没更新。
问题出在我把 `reactive` 的返回值解构了出来。这篇文章把 Vue 3 响应式里最容易踩的边界一次讲清。
## ref 与 reactive 的选择
**`ref`** 适合基本类型,也适合"整体替换"的对象;**`reactive`** 适合需要深度监听的大对象。但 `reactive` 有三个限制,记住这三条就能避开九成问题:
1. **解构即丢失**。`const state = reactive({ a: 1 }); const { a } = state;` 之后 `a` 就是个普通数字,改了不触发更新。
2. **整体赋值即丢失**。`state = { a: 2 }` 这种写法不会生效——`reactive` 返回的引用不能整体替换。
3. **不能包 Map/Set 之外的非普通对象**。Date、class 实例放进 `reactive`,代理行为不可预期。
实践经验:**默认用 `ref`,只有明确需要深度代理时才用 `reactive`**。`ref` 在 `.value` 层面的限制更少,心智负担也小。
## 为什么 push 之后没更新
我当时的代码大概是这样的:
```ts
const list = reactive<Item[]>([]);
const load = async () => {
const { data } = await useFetch('/api/list');
// 错误写法:整体替换
Object.assign(list, data.value); // 这个其实是对的
};
```
真正出问题的是另一处:在 `setup` 外定义了一个普通数组 `rawList`,组件里 `reactive(rawList)` 之后,`rawList` 本身仍然是非响应式的,我在工具函数里直接改 `rawList.push(...)`,界面当然不动。
**核心原则:响应式只在代理对象上生效。谁被代理,就改谁。**
## watch 的三个坑
**坑一:watch 源是 `reactive` 时默认深度监听,`ref` 不是。**
```ts
const obj = ref({ a: 1 });
watch(obj, (v) => { /* 只在 .value 整体替换时触发 */ });
// 需要监听内部属性时:
watch(obj, (v) => {}, { deep: true });
watch(() => obj.value.a, (v) => {}); // 更推荐,精准且便宜
```
**坑二:watchEffect 里的异步副作用要自己清理。** 组件卸载后 Promise 才 resolve,再去改状态会报 warning。用 `onCleanup` 或者改成 `watch` 的显式写法更稳。
**坑三:immediate 与 deep 别乱加。** 很多人给每个 watch 都加 `deep: true`,大对象递归遍历一遍,性能直接崩。只对真正需要深度监听的对象开启。
## props 解构的陷阱
Vue 3.5 之前,`defineProps` 解构出来的 props 会**丢失响应性**:
```vue
<script setup>
const { id } = defineProps(['id']); // 旧版里 id 变化模板不更新
</script>
```
稳妥做法是不解构,或者用 `toRef`:
```ts
const props = defineProps<{ id: string }>();
const id = toRef(props, 'id');
```
## 一条判断口诀
> 想知道某个值改了会不会更新,就问自己:**它是不是从 setup 里"流"出去的响应式源?**
如果是解构出来的、赋值给普通变量的、在工具函数里新建的,都不会更新。把这条记住,排查响应式 bug 的时间能少一半。
## 最后
Vue 3 的响应式设计整体比 Vue 2 清晰,但 `ref` / `reactive` / `toRef` 三兄弟的边界需要花点时间适应。遇到"改了不更新",先打印一下这个值是不是 Proxy、是不是已经脱离了响应式链路,比盲目加 `deep: true` 有效得多。