前言
在 Vue3 的 Composition API 中,有两个主要的响应式 API:ref 和 reactive。很多开发者,尤其是刚从 Vue2 迁移过来的同学,常常会困惑:到底该用哪一个响应式 API ?什么时候该用 ref?什么时候该用 reactive?
这个问题看似简单,实则涉及 Vue3 响应式系统的核心设计理念。本文将从源码原理出发,深入剖析两者的本质区别。
响应式原理快速回顾
Proxy:Vue3 响应式的基石
在深入 ref 和 reactive 之前,我们必须先理解 Vue3 响应式的核心:Proxy 代理。
在 Vue2 中, 使用的是 Object.defineProperty 来拦截属性的读写,但它有一个致命缺陷:无法检测属性的添加和删除,当我们需要添加属性等操作时,必须用 Vue.set() 或 vm.$set() 等方式处理。而在 Vue3 中改用 Proxy 进行对象代理,完美解决了这个问题:
const target = { name: 'Vue' }
const handler = {
get(target, key, receiver) {
console.log(`读取属性: ${key}`)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
console.log(`设置属性: ${key} = ${value}`)
return Reflect.set(target, key, value, receiver)
}
}
const proxy = new Proxy(target, handler)
proxy.name // 读取属性: name
proxy.name = 'Vue3' // 设置属性: name = Vue3
Proxy 的强大之处
- 拦截所有操作:包括属性读取、赋值、删除、in 操作符等,支持 13 种数据操作的拦截
- 动态属性响应:新增属性也能被追踪
- 数组方法拦截:push、pop 等方法也能触发更新
关于 Proxy 的相关内容,可以查看我在《JavaScript核心机制探秘》专栏中相关的文章介绍。
reactive 的实现原理
reactive 是 Vue3 中最直接的响应式 API,它接收一个对象,返回这个对象的 Proxy 代理:
// 简化的 reactive 实现
function reactive(target) {
// 创建 Proxy 代理
return new Proxy(target, {
get(target, key, receiver) {
// 依赖收集
track(target, key)
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver)
// 触发更新
trigger(target, key)
return result
},
deleteProperty(target, key) {
const result = Reflect.deleteProperty(target, key)
// 删除属性也要触发更新
trigger(target, key)
return result
}
})
}
// 使用
const state = reactive({
count: 0,
user: { name: '张三' }
})
state.count++ // 触发更新
state.user.name = '李四' // 嵌套对象也会被递归代理
ref 的实现原理
ref 的设计要处理一个根本性问题:Proxy 只能代理对象,无法代理基础类型(string、number、boolean)。因此,Vue团队 给出了一个解决方案:使用 value 属性,将基础类型值包装成一个对象,再对这个对象进行 Proxy 代理。这也是为什么 ref 响应式数据,需要用 .value 的方式进行访问的原因:
// 简化的 ref 实现
function ref(value) {
// 创建包装对象
const wrapper = {
value: value
}
// 将包装对象变为响应式
return reactive(wrapper)
}
// 更接近真实源码的实现
class RefImpl {
constructor(value) {
this._value = value
this.__v_isRef = true // 标记这是一个 ref
}
get value() {
// 依赖收集
track(this, 'value')
return this._value
}
set value(newVal) {
if (this._value !== newVal) {
this._value = newVal
// 触发更新
trigger(this, 'value')
}
}
}
function ref(value) {
return new RefImpl(value)
}
// 使用
const count = ref(0)
count.value++ // 必须通过 .value 访问
从上述代码中,我们也可以看出:ref 返回的本质上也是一个 reactive 对象!
关于 ref 和 reactive 的具体源码实现细节,可以参考我的《Vue3 源码解析》的相关文章。
ref vs reactive 的核心区别
访问方式:.value 的有无
这是两者最直观的区别:
import { ref, reactive } from 'vue'
// ref 需要 .value
const count = ref(0)
console.log(count.value) // 0
count.value++
console.log(count.value) // 1
// reactive 不需要 .value
const state = reactive({ count: 0 })
console.log(state.count) // 0
state.count++
console.log(state.count) // 1
重新赋值:整体替换 vs 属性修改
这其实是在 Vue3 开发中,最容易踩的一个坑,我们先来看一个例子:
// ref 支持整体替换
let user = ref({ name: '张三', age: 18 })
// ✅ 可以直接替换整个对象
user.value = { name: '李四', age: 20 }
// reactive 不支持整体替换
let state = reactive({ name: '张三', age: 18 })
// ❌ 这样会丢失响应式
state = { name: '李四', age: 20 }
// ✅ reactive 只能修改属性
state.name = '李四'
state.age = 20
// ❌ 即使使用 Object.assign 也可能出现问题
Object.assign(state, { name: '王五', age: 22 }) // ✅ 这样可以
state = Object.assign({}, state, { name: '王五' }) // ❌ 这样不行
类型推导与解构
reactive 在使用解构时也会出现问题:
const state = reactive({
name: '张三',
age: 18,
profile: {
city: '北京'
}
})
// ❌ 解构后失去响应性
const { name, age } = state
name // '张三',但不再是响应式的
// ✅ 使用 toRefs 保持响应性
const { name, age } = toRefs(state)
name.value // 需要通过 .value 访问
// ✅ 单个属性用 toRef
const city = toRef(state.profile, 'city')
city.value = '上海' // 会触发更新
而 ref 在这方面的表现就很好:
// 组合式函数返回 ref 对象
function useFeature() {
const count = ref(0)
const name = ref('张三')
return {
count,
name
}
}
// 解构后依然是响应式的
const { count, name } = useFeature()
count.value++ // ✅ 正常工作
注:关于上述内容,在论坛中也存在争议:由于
reactive本身设计特性,会导致响应式丢失问题。因此部分开发者(包括笔者),更推荐在实际开发中,直接使用ref,弃用reactive。
深层响应性
两者都支持深层响应,但内部实现略有不同:
const refObj = ref({
user: {
name: '张三',
address: {
city: '北京'
}
}
})
// 深层属性也是响应式的
refObj.value.user.address.city = '上海' // 触发更新
const reactiveObj = reactive({
user: {
name: '张三',
address: {
city: '北京'
}
}
})
// 同样是深层响应式
reactiveObj.user.address.city = '上海' // 触发更新
什么时候用 ref?
基础类型值
这是 ref 的主要应用场景,因为 reactive 根本不能处理基础类型:
const count = ref(0)
const name = ref('张三')
const isLoading = ref(false)
const userInput = ref('')
需要整体替换的场景
当我们的数据状态需要整体重置或替换时,ref 是不二之选:
// 表单数据,经常需要重置
const formData = ref({
username: '',
email: '',
password: ''
})
// 重置表单 - ref 轻松搞定
function resetForm() {
formData.value = {
username: '',
email: '',
password: ''
}
}
// 更新整个表单 - 从 API 获取数据后整体替换
async function loadForm(id) {
const data = await api.getForm(id)
formData.value = data // ✅ 直接替换
}
当然,如果一定要用 reactive 呢?也是可以解决的,只是较为麻烦而已:
// 如果用 reactive,重置会很麻烦
const formDataReactive = reactive({
username: '',
email: '',
password: ''
})
function resetFormReactive() {
// 需要逐个属性重置,或者使用 Object.assign
Object.assign(formDataReactive, {
username: '',
email: '',
password: ''
})
}
从组合式函数返回时
当编写可复用的组合式函数时,返回 ref 对象可以更利于解构:
export function useUser() {
const user = ref(null)
const loading = ref(false)
const error = ref(null)
async function fetchUser(id) {
loading.value = true
try {
user.value = await api.getUser(id)
} catch (e) {
error.value = e
} finally {
loading.value = false
}
}
// 返回 ref 对象,使用者可以随意解构
return {
user,
loading,
error,
fetchUser
}
}
// 在组件中使用
const { user, loading, fetchUser } = useUser()
// 解构后依然保持响应式
watch(user, () => {}) // ✅ 正常
跨组件传递时的类型安全
当通过 props 进行父子组件通信,传递响应式数据时,ref 的类型更清晰:
<!-- 父组件 -->
<script setup>
const userData = ref({ name: '张三', age: 18 })
</script>
<template>
<ChildComponent :data="userData" />
</template>
<!-- 子组件 -->
<script setup>
// 明确知道接收的是一个 ref
const props = defineProps<{
data: { name: string; age: number } // 注意:这是 Ref 的内部类型
}>()
// 使用 toValue 统一处理
const data = toValue(props.data) // toValue 可以处理 ref 和普通值
</script>
获取子组件实例
当父组件想要访问子组件的方法或数据时,可以直接使用 ref 获得子组件的实例,访问子组件通过 defineExpose 暴露的方法或数据:
子组件 Child.vue:
<template>
<div>子组件</div>
</template>
<script setup>
// 子组件的方法和数据
const childMethod = () => {
console.log('子组件方法被调用')
}
// 需要暴露给父组件的属性和方法
defineExpose({
childMethod,
childData: '我是子组件的数据'
})
</script>
父组件 Parent.vue:
<template>
<!-- 子组件 -->
<Child ref="childRef" />
<button @click="callChildMethod">调用子组件方法</button>
</template>
<script setup>
import { ref } from 'vue'
import Child from './Child.vue'
// 创建一个ref来存储子组件实例
const childRef = ref(null)
// 调用子组件方法
const callChildMethod = () => {
if (childRef.value) {
childRef.value.childMethod() // 调用子组件暴露的方法
console.log(childRef.value.childData) // 访问子组件暴露的数据
}
}
// 在生命周期钩子中访问
import { onMounted } from 'vue'
onMounted(() => {
console.log('子组件实例:', childRef.value)
})
</script>
什么时候用 reactive?
深层嵌套的对象
当数据结构复杂且嵌套层级较深时,reactive 的语法更简洁:
// 复杂的状态对象
const store = reactive({
user: {
profile: {
personal: {
name: '张三',
age: 18
},
contact: {
email: 'zhang@example.com',
phone: '1234567890'
}
},
preferences: {
theme: 'dark',
language: 'zh-CN',
notifications: {
email: true,
sms: false
}
}
},
ui: {
sidebar: {
collapsed: false,
width: 240
},
modal: {
visible: false,
type: null
}
}
})
// 访问深层属性 - reactive 很方便
store.user.profile.personal.name = '李四'
store.ui.sidebar.collapsed = true
// 如果用 ref,每次都要 .value,略显繁琐
const storeRef = ref({
// 同样的数据结构
})
storeRef.value.user.profile.personal.name = '李四' // 多了 .value
不需要整体替换的数据
对于不需要整体替换的数据,比如配置数据等,只用初始化一次,后期只会更改属性,reactive 很合适:
const appConfig = reactive({
apiUrl: 'https://api.example.com',
timeout: 5000,
retryCount: 3,
features: {
logging: true,
cache: false
}
})
// 后续只修改属性
appConfig.timeout = 10000
appConfig.features.cache = true
性能敏感的场景
虽然差别很小,但理论上 reactive 比 ref 少一层包装,性能略好:
// ref 多了一层对象包装
const refState = ref({ count: 0 })
// 访问路径: refState.value.count
// reactive 直接代理原始对象
const reactiveState = reactive({ count: 0 })
// 访问路径: reactiveState.count
// 在大量数据操作的场景下,reactive 可能稍有优势
注:这种说法只是出于纯理论上的,因为实际开发中,这种性能差异在99%的场景中都可以忽略不计。
为什么 reactive 解构后会失去响应性?
原因:解构破坏了 Proxy 的代理
要想理解这个问题,还是得回到 Proxy 的工作原理中,我们先用一段简单的代码模拟 reactive 的行为:
const raw = { name: '张三', age: 18 }
const proxy = new Proxy(raw, {
get(target, key) {
console.log(`读取 ${key}`)
return target[key]
},
set(target, key, value) {
console.log(`设置 ${key} = ${value}`)
target[key] = value
return true
}
})
此时,我们对 proxy 解构 const { name } = proxy ,它都会发生哪些事呢?
- 读取
proxy.name,此时会触发 get 拦截 -- 没有问题 - 将获取到的值
张三赋值给name变量 -- 问题产生了 name被重新赋值为一个普通的字符串,和 proxy 没有任何关系了- 后续对
name的操作都只是修改一个普通变量,不会触发任何拦截
解决方案
方案一:使用 toRefs(推荐)
import { reactive, toRefs } from 'vue'
const user = reactive({
name: '张三',
age: 18
})
// toRefs 将每个属性转换为 ref
const { name, age } = toRefs(user)
// 现在可以安全解构了
name.value = '李四' // ✅ 触发更新
age.value++ // ✅ 触发更新
toRefs 的简化原理:
function toRefs(obj) {
const result = {}
// 遍历对象的所有key
for (const key in obj) {
result[key] = toRef(obj, key) // 为每个属性单独创建 ref
}
return result
}
// 创建的 ref 和原对象保持连接
const nameRef = toRef(user, 'name')
nameRef.value = '李四' // 等价于 user.name = '李四'
方案二:使用 toRef 处理单个属性
import { reactive, toRef } from 'vue'
const user = reactive({
name: '张三',
age: 18
})
// 只需要处理个别属性
const name = toRef(user, 'name')
const age = toRef(user, 'age')
name.value = '李四' // ✅ 触发更新
方案三:直接用 ref
如果发现需要频繁解构,可能在一开始就应该使用 ref:
const user = ref({
name: '张三',
age: 18
})
选择决策树
基于以上分析,我们可以建立一套清晰的选择决策树:
快速选择指南
决策依据详解
| 场景 | 推荐方案 | 原因 |
|---|---|---|
| 基础数据类型 | ref | reactive 无法处理基础类型 |
| 需要整体重置的表单 | ref | 支持直接替换 .value |
| 组合式函数返回值 | ref | 方便使用者解构 |
| 复杂嵌套对象 | reactive | 语法更简洁 |
| 一次性初始化配置 | reactive | 不需要整体替换 |
| 需要解构的场景 | ref + toRefs | 保持响应性 |
最终建议
- 默认用 ref:
ref更灵活,适用场景更广,虽然多了.value,但换来的是确定性和可预测性 - 在特定场景用 reactive:当需要使用复杂对象且不需要解构时,
reactive能让代码更简洁 - 要理解并善用工具函数:
toRefs、toRef、isRef、isReactive等 - 团队统一规范:无论选择哪种策略,团队内要保持一致,避免混用导致混乱
- 无法确定用哪个时:直接用
ref,ref是更安全、更通用的选择
结语
ref 是更安全、更通用的选择;reactive 则是在特定场景下的优化选择。理解了它们的设计哲学和适用场景,就能帮我们在适当的场合做出正确的选择。
对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!