Vue3

响应式系统原理与进阶 API

深入理解 Vue3 响应式系统原理,掌握 ref、reactive 的本质区别,以及 toRefs、toRaw、readonly 等常用进阶 API 的使用。

🎯 引言

学会这篇文章,你能真正理解 Vue3 响应式系统的工作原理,清楚 refreactive 在底层有什么区别,掌握 toRefstoReftoRawmarkRawreadonly 等进阶 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 的底层区别

refreactive 都能创建响应式数据,但它们的适用场景和底层实现不同。

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 返回值时使用。

UserInfo.vue
<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 的关键作用是:即使这个对象被放进 reactiveref 里,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 算出 B
  • readonly 适合“权限控制”,是把 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 判断是否为 reactivereadonly 等 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 和普通值两种入参。

🛠 实战:一个表单场景综合运用

下面是一个简单的用户表单,综合使用 reactivetoRefsreadonly 等 API:

UserForm.vue
<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 用于创建只读响应式数据。
  • isRefisReactiveunref 等工具函数能帮你写出更健壮的通用逻辑。

❓ 知识问答(Q&A)

Q:Vue3 为什么要用 Proxy 替代 Object.defineProperty?

A:Proxy 能监听对象属性的新增、删除、数组变化等,初始化时也不需要递归遍历整个对象,性能和灵活性都更好。

Q:refreactive 最本质的区别是什么?

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 项目时会少踩很多响应式相关的坑,代码也会更健壮!