响应式系统原理与进阶 API
🎯 引言
学会这篇文章,你能真正理解 Vue3 响应式系统的工作原理,清楚 ref 和 reactive 在底层有什么区别,掌握 toRefs、toRef、toRaw、markRaw、readonly 等进阶 API 的使用场景。更重要的是,你能避开开发中常见的响应式陷阱,比如解构丢失响应式、直接替换响应式对象等问题,让你的 Vue3 代码写得更稳、更顺。
🧱 为什么 Vue3 用 Proxy 替代 Object.defineProperty
Vue2 的响应式系统基于 Object.defineProperty,它会遍历对象的每个属性,把它们“劫持”成 getter 和 setter。当数据变化时,Vue 就能通知视图更新。
但 Object.defineProperty 有几个明显缺点:
- 无法监听对象新增或删除的属性
- 无法直接监听数组索引和 length 变化
- 需要递归遍历整个对象,初始化性能开销大
Vue3 改用 ES6 的 Proxy,它直接代理整个对象,不需要提前遍历每个属性。
const user = { name: '张三', age: 20 };
const proxyUser = new Proxy(user, {
get(target, key) {
console.log('读取了', key);
return target[key];
},
set(target, key, value) {
console.log('修改了', key, '新值是', value);
target[key] = value;
return true;
},
});
proxyUser.name = '李四'; // 修改了 name 新值是 李四
console.log(proxyUser.age); // 读取了 age
Proxy 能监听对象任意属性的读取、修改、新增、删除,是 Vue3 响应式更强大、更灵活的根本原因。Vue3 内部基于 Proxy 做了一层封装,当你用 reactive() 包裹对象时,得到的就是这样一个响应式代理对象。
⚡ ref 与 reactive 的底层区别
ref 和 reactive 都能创建响应式数据,但它们的适用场景和底层实现不同。
reactive 只接收对象类型
import { reactive } from 'vue';
const state = reactive({ count: 0 });
state.count++; // 响应式生效
如果你用 reactive 包裹基本类型,会报错或失效:
const num = reactive(0); // 没有响应式效果
ref 可以包裹任意类型
import { ref } from 'vue';
const count = ref(0); // 基本类型
const user = ref({ name: '张三' }); // 对象类型
ref 的返回值是一个对象,真实的值放在 .value 属性上:
console.log(count.value); // 0
count.value++;
为什么 ref 要包一层对象
因为 JavaScript 中基本类型(数字、字符串、布尔值)是按值传递的。如果直接返回一个数字,调用处拿到的是副本,无法追踪变化。ref 把它包成对象,通过 .value 的 getter 和 setter 就能实现响应式。
ref 包裹对象时,Vue 内部其实也是把它转成 reactive 对象来处理的。所以 ref({ name: '张三' }) 和 reactive({ name: '张三' }) 在响应式能力上是一样的,区别只在于 ref 多包了一层 .value。ref,对象类型推荐用 reactive。但在 script setup 中,因为经常需要解构或返回对象,实际开发里 ref 的使用频率也非常高。🪤 响应式丢失的常见陷阱
Vue3 的响应式系统虽然强大,但有些写法会让响应式悄悄失效。下面是开发中最容易踩的几个坑。
陷阱 1:解构 reactive 对象
import { reactive } from 'vue';
const user = reactive({ name: '张三', age: 20 });
let { name } = user;
name = '李四'; // 视图不会更新,name 变成了普通字符串
因为 name 变成了一个普通字符串,和响应式对象脱离了关系。
正确做法:使用 toRefs
import { reactive, toRefs } from 'vue';
const user = reactive({ name: '张三', age: 20 });
const { name, age } = toRefs(user);
name.value = '李四'; // 视图会更新
toRefs 会把对象里的每个属性都转成 ref,这样解构后仍然保持响应式。
陷阱 2:直接替换整个 reactive 对象
import { reactive } from 'vue';
let user = reactive({ name: '张三' });
user = { name: '李四' }; // 响应式丢失
reactive() 返回的是代理对象,直接重新赋值会让原来的代理断开。
正确做法:修改对象属性,而不是替换对象
user.name = '李四';
如果你确实需要整体替换数据,可以用 ref:
import { ref } from 'vue';
const user = ref({ name: '张三' });
user.value = { name: '李四' }; // 响应式有效
陷阱 3:把响应式数据赋值给普通变量后修改
const state = reactive({ count: 0 });
let count = state.count;
count++;
这里 count 已经从响应式对象里“脱钩”,变成普通数字了。
正确做法:始终通过响应式对象或 ref 的 .value 修改
🧰 常用进阶 API
下面这些 API 在复杂场景下非常实用,建议熟练掌握。
toRefs:解构不丢响应式
toRefs 适合在需要把 reactive 对象展开到模板或 composable 返回值时使用。
<template>
<div>
<p>姓名:{{ name }}</p>
<p>年龄:{{ age }}</p>
<button @click="age++">长大一岁</button>
</div>
</template>
<script setup>
import { reactive, toRefs } from 'vue';
const user = reactive({ name: '张三', age: 20 });
const { name, age } = toRefs(user);
</script>
toRefs 返回的对象里,每个属性都是 ref,所以模板里可以直接用 {{ name }},但在 JS 里修改要写 name.value。toRef:给单个属性做 ref
如果你只想把 reactive 里的某一个属性转成 ref,用 toRef:
import { reactive, toRef } from 'vue';
const user = reactive({ name: '张三', age: 20 });
const name = toRef(user, 'name');
name.value = '李四'; // user.name 也会同步更新
toRef 和 toRefs 有什么区别?
toRefs是把整个reactive对象的所有属性都转成ref,适合解构后批量使用。toRef只转某一个指定属性,适合只需要单独拿出一个属性的场景。
import { reactive, toRefs, toRef } from 'vue';
const user = reactive({ name: '张三', age: 20 });
// 批量转 ref
const { name, age } = toRefs(user);
// 只转单个属性
const nameOnly = toRef(user, 'name');
toRaw:拿到原始对象
有时候你需要拿到响应式对象的原始数据,比如传递给不处理响应式的第三方库:
import { reactive, toRaw } from 'vue';
const state = reactive({ name: '张三' });
const rawState = toRaw(state);
console.log(rawState); // { name: '张三' },普通对象
toRaw 得到的原始对象不会触发视图更新,通常只用于读取或传递给外部。markRaw:标记对象永远不响应式
有些数据你确定不需要响应式,比如一个巨大的静态配置对象或第三方类实例,可以用 markRaw 跳过响应式转换,提升性能。
import { markRaw } from 'vue';
const bigConfig = markRaw({
list: [/* 大量数据 */],
settings: { theme: 'dark' },
});
markRaw 适合处理复杂对象、类实例、图表实例等不需要响应式追踪的数据。直接定义普通对象不行吗?
如果只是在组件里单独用,直接写普通对象确实没问题。但 markRaw 的关键作用是:即使这个对象被放进 reactive 或 ref 里,Vue 也不会把它转成响应式。
看下面这个对比:
import { reactive, markRaw } from 'vue';
// 普通对象:会被 Vue 递归代理成响应式
const normalConfig = { theme: 'dark' };
const state1 = reactive({ config: normalConfig });
state1.config.theme = 'light'; // 会触发依赖更新
// markRaw 标记的对象:不会被代理
const rawConfig = markRaw({ theme: 'dark' });
const state2 = reactive({ config: rawConfig });
state2.config.theme = 'light'; // 不会触发依赖更新,提升性能
所以 markRaw 不是“多此一举”,它是在你需要把对象放到响应式数据里、但又不希望它产生响应式开销时使用的。
readonly:创建只读响应式数据
如果你希望数据是响应式的,但不想被意外修改,可以用 readonly:
import { reactive, readonly } from 'vue';
const state = reactive({ count: 0 });
const readOnlyState = readonly(state);
readOnlyState.count++; // 控制台会警告,且修改不生效
readonly 常用于把内部状态暴露给外部组件读取,但防止外部直接修改。这和用 computed 返回有什么区别?
computed 是根据已有数据计算出一个新值,它本身只负责“读取”,但不会阻止你去修改原始数据。
import { reactive, computed } from 'vue';
const state = reactive({ count: 0 });
const readOnlyCount = computed(() => state.count);
readOnlyCount.value++; // 报错,computed 返回值是只读的
state.count++; // 仍然可以修改原始数据
而 readonly 是直接代理原数据,外部拿到它之后不能改,但内部修改原数据时,readonly 包装的视图也会同步更新。
import { reactive, readonly } from 'vue';
const state = reactive({ count: 0 });
const readOnlyState = readonly(state);
state.count++; // 内部修改原数据
console.log(readOnlyState.count); // 1,readonly 也会同步更新
readOnlyState.count++; // 外部修改会警告且无效
readonly 是递归只读的,对象内部的嵌套属性也会被保护起来,外部同样无法修改。简单理解:
computed适合“派生计算”,是从 A 算出 Breadonly适合“权限控制”,是把 A 暴露出去但禁止修改
isRef / isReactive / isProxy:类型判断
在写通用逻辑或 composable 时,经常需要判断一个值是不是响应式数据:
import { ref, reactive, isRef, isReactive, isProxy } from 'vue';
const count = ref(0);
const user = reactive({ name: '张三' });
console.log(isRef(count)); // true
console.log(isReactive(user)); // true
console.log(isProxy(user)); // true
isProxy 判断是否为 reactive、readonly 等 Proxy 代理对象,范围比 isReactive 更广。unref:自动解包 ref
如果你不确定一个值是普通值还是 ref,可以用 unref 自动拿到真实值:
import { ref, unref } from 'vue';
const count = ref(10);
const num = 20;
console.log(unref(count)); // 10
console.log(unref(num)); // 20
unref 在写通用函数时很有用,可以兼容 ref 和普通值两种入参。🛠 实战:一个表单场景综合运用
下面是一个简单的用户表单,综合使用 reactive、toRefs、readonly 等 API:
<template>
<form @submit.prevent="submit">
<input v-model="form.name" placeholder="姓名" />
<input v-model="form.email" placeholder="邮箱" />
<button type="submit" :disabled="!isValid">提交</button>
</form>
</template>
<script setup>
import { reactive, computed, toRaw, readonly } from 'vue';
// 1. 用 reactive 定义表单对象
const form = reactive({
name: '',
email: '',
});
// 2. 用 computed 计算表单是否有效
const isValid = computed(() => {
return form.name.trim() !== '' && form.email.includes('@');
});
// 3. 提交时拿到原始数据,传给接口
function submit() {
const payload = toRaw(form);
console.log('提交数据', payload);
}
// 4. 如果要把表单暴露给父组件只读查看
const readOnlyForm = readonly(form);
</script>
reactive 很适合管理表单这类结构化的对象数据,配合 computed 做校验,逻辑清晰又好维护。🧾 小节总结
- Vue3 用
Proxy替代了 Vue2 的Object.defineProperty,能更全面地监听对象变化。 ref适合基本类型,也能包对象;reactive只适合对象类型,但访问更自然。- 解构
reactive会丢失响应式,推荐用toRefs处理。 - 不要直接替换
reactive的变量,应该修改属性,或者用ref管理整体数据。 toRaw用于读取原始对象,markRaw用于跳过响应式转换,readonly用于创建只读响应式数据。isRef、isReactive、unref等工具函数能帮你写出更健壮的通用逻辑。
❓ 知识问答(Q&A)
Q:Vue3 为什么要用 Proxy 替代 Object.defineProperty?
A:Proxy 能监听对象属性的新增、删除、数组变化等,初始化时也不需要递归遍历整个对象,性能和灵活性都更好。
Q:ref 和 reactive 最本质的区别是什么?
A:ref 是把任意值包成对象,通过 .value 访问;reactive 是用 Proxy 代理一个对象,直接访问属性。
Q:解构 reactive 对象后为什么不响应了?
A:解构出来的属性变成了普通值,和响应式代理断开了联系。应该用 toRefs 保持响应式。
Q:readonly 和普通的响应式数据有什么区别?
A:readonly 创建的数据仍然是响应式的,但会阻止修改,适合对外暴露内部状态。
🧪 小练习
练习 1
补全下面的代码,使用 toRefs 让解构后的属性保持响应式:
<template>
<div>
<p>城市:{{ city }}</p>
<button @click="city = '上海'">切换城市</button>
</div>
</template>
<script setup>
import { reactive } from 'vue';
const location = reactive({ city: '北京', code: 100000 });
// 请在这里使用 toRefs 解构 city,并保持响应式
</script>
练习 2
实现一个 useReadOnlyUser composable,接收一个用户对象,返回只读的响应式用户数据和修改方法(只有内部可以修改)。
import { reactive, readonly } from 'vue';
export function useReadOnlyUser(initialUser) {
// 请在这里实现
}
🎉 恭喜你已经掌握 Vue3 响应式系统原理和常用进阶 API 啦!理解这些之后,你在写 Vue3 项目时会少踩很多响应式相关的坑,代码也会更健壮!
