响应式探秘:ref vs reactive,我该选谁?

0 阅读10分钟

前言

在 Vue3 的 Composition API 中,有两个主要的响应式 API:refreactive。很多开发者,尤其是刚从 Vue2 迁移过来的同学,常常会困惑:到底该用哪一个响应式 API ?什么时候该用 ref?什么时候该用 reactive

这个问题看似简单,实则涉及 Vue3 响应式系统的核心设计理念。本文将从源码原理出发,深入剖析两者的本质区别。

响应式原理快速回顾

Proxy:Vue3 响应式的基石

在深入 refreactive 之前,我们必须先理解 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

性能敏感的场景

虽然差别很小,但理论上 reactiveref 少一层包装,性能略好:

// 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 ,它都会发生哪些事呢?

  1. 读取 proxy.name ,此时会触发 get 拦截 -- 没有问题
  2. 将获取到的值 张三 赋值给 name 变量 -- 问题产生了
  3. name 被重新赋值为一个普通的字符串,和 proxy 没有任何关系了
  4. 后续对 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
})

选择决策树

基于以上分析,我们可以建立一套清晰的选择决策树:

快速选择指南

选择决策树

决策依据详解

场景推荐方案原因
基础数据类型refreactive 无法处理基础类型
需要整体重置的表单ref支持直接替换 .value
组合式函数返回值ref方便使用者解构
复杂嵌套对象reactive语法更简洁
一次性初始化配置reactive不需要整体替换
需要解构的场景ref + toRefs保持响应性

最终建议

  • 默认用 refref 更灵活,适用场景更广,虽然多了 .value,但换来的是确定性和可预测性
  • 在特定场景用 reactive:当需要使用复杂对象且不需要解构时,reactive 能让代码更简洁
  • 要理解并善用工具函数toRefstoRefisRefisReactive
  • 团队统一规范:无论选择哪种策略,团队内要保持一致,避免混用导致混乱
  • 无法确定用哪个时:直接用 refref 是更安全、更通用的选择

结语

ref 是更安全、更通用的选择;reactive 则是在特定场景下的优化选择。理解了它们的设计哲学和适用场景,就能帮我们在适当的场合做出正确的选择。

对于文章中错误的地方或有任何疑问,欢迎在评论区留言讨论!