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

前言

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

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

响应式原理快速回顾

Proxy:Vue3 响应式的基石

在深入 refreactive 之前,我们必须先理解 Vue3 响应式的核心:Proxy 代理。

在 Vue2 中, 使用的是 Object.defineProperty 来拦截属性的读写,但它有一个致命缺陷:无法检测属性的添加和删除,当我们需要添加属性等操作时,必须用 Vue.set()vm.$set() 等方式处理。而在 Vue3 中改用 Proxy 进行对象代理,完美解决了这个问题:

javascript 复制代码
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 代理:

javascript 复制代码
// 简化的 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 的方式进行访问的原因:

javascript 复制代码
// 简化的 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 的有无

这是两者最直观的区别:

javascript 复制代码
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 开发中,最容易踩的一个坑,我们先来看一个例子:

javascript 复制代码
// 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 在使用解构时也会出现问题:

javascript 复制代码
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 在这方面的表现就很好:

javascript 复制代码
// 组合式函数返回 ref 对象
function useFeature() {
  const count = ref(0)
  const name = ref('张三')
  
  return {
    count,
    name
  }
}

// 解构后依然是响应式的
const { count, name } = useFeature()
count.value++ // ✅ 正常工作

注:关于上述内容,在论坛中也存在争议:由于 reactive 本身设计特性,会导致响应式丢失问题。因此部分开发者(包括笔者),更推荐在实际开发中,直接使用 ref,弃用 reactive

深层响应性

两者都支持深层响应,但内部实现略有不同:

javascript 复制代码
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 根本不能处理基础类型:

javascript 复制代码
const count = ref(0)
const name = ref('张三')
const isLoading = ref(false)
const userInput = ref('')

需要整体替换的场景

当我们的数据状态需要整体重置或替换时,ref 是不二之选:

javascript 复制代码
// 表单数据,经常需要重置
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 呢?也是可以解决的,只是较为麻烦而已:

javascript 复制代码
// 如果用 reactive,重置会很麻烦
const formDataReactive = reactive({
  username: '',
  email: '',
  password: ''
})

function resetFormReactive() {
  // 需要逐个属性重置,或者使用 Object.assign
  Object.assign(formDataReactive, {
    username: '',
    email: '',
    password: ''
  })
}

从组合式函数返回时

当编写可复用的组合式函数时,返回 ref 对象可以更利于解构:

javascript 复制代码
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 的类型更清晰:

html 复制代码
<!-- 父组件 -->
<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

javascript 复制代码
<template>
  <div>子组件</div>
</template>

<script setup>
// 子组件的方法和数据
const childMethod = () => {
  console.log('子组件方法被调用')
}

// 需要暴露给父组件的属性和方法
defineExpose({
  childMethod,
  childData: '我是子组件的数据'
})
</script>

父组件 Parent.vue

html 复制代码
<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 的语法更简洁:

javascript 复制代码
// 复杂的状态对象
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 很合适:

javascript 复制代码
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 少一层包装,性能略好:

javascript 复制代码
// ref 多了一层对象包装
const refState = ref({ count: 0 })
// 访问路径: refState.value.count

// reactive 直接代理原始对象
const reactiveState = reactive({ count: 0 })
// 访问路径: reactiveState.count

// 在大量数据操作的场景下,reactive 可能稍有优势

注:这种说法只是出于纯理论上的,因为实际开发中,这种性能差异在99%的场景中都可以忽略不计。

为什么 reactive 解构后会失去响应性?

原因:解构破坏了 Proxy 的代理

要想理解这个问题,还是得回到 Proxy 的工作原理中,我们先用一段简单的代码模拟 reactive 的行为:

javascript 复制代码
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(推荐)

javascript 复制代码
import { reactive, toRefs } from 'vue'

const user = reactive({
  name: '张三',
  age: 18
})

// toRefs 将每个属性转换为 ref
const { name, age } = toRefs(user)

// 现在可以安全解构了
name.value = '李四' // ✅ 触发更新
age.value++ // ✅ 触发更新

toRefs 的简化原理:

javascript 复制代码
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 处理单个属性

javascript 复制代码
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

javascript 复制代码
const user = ref({
  name: '张三',
  age: 18
})

选择决策树

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

快速选择指南

决策依据详解

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

最终建议

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

结语

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

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

相关推荐
mldong3 小时前
你的 Vue3 项目也能有钉钉同款审批流设计器:npm 装包,10 分钟画出第一条审批流
前端·vue.js
2分钟速写快排4 小时前
什么是 RAG?如何用 RAG 实现一个用户记忆?
前端·后端·ai编程
passerby60614 小时前
如何自己造一个时间处理库
前端·javascript·github
走到天涯海角5 小时前
react里面的长列表渲染优化
前端·react.js·前端框架
小羊没烦恼!5 小时前
Hello Web API系列教程——Web API与国际化
java·服务器·前端·javascript·php
北岛贰6 小时前
迷茫焦虑期,我做了一个带支付带官网的 AI 聊天虚拟恋人 App
前端·人工智能·后端
mayaairi7 小时前
Vue2 组件通讯(三):全局事件总线、PubSub、插槽与组件实例属性
前端·javascript·vue.js
kyriewen8 小时前
面试官问我:AI 都能写代码了,前端凭什么还值 25K
前端·javascript·人工智能
风骏时光牛马8 小时前
AI源码分析:拆解模型底层实现逻辑
前端
BillKu9 小时前
全局样式变量:CSS 自定义属性(--border-color)和SCSS 变量($border-color)的说明
javascript·css·scss