普通视图

发现新文章,点击刷新页面。
昨天 — 2026年2月11日首页

Vue3 响应式数据常用方案及实践坑点

作者 b1ng
2026年2月10日 19:38

最近在做 Vue 项目相关的需求,复习一下 Vue 的响应式机制及其常用办法

从 Vue3 视角来看,它的响应式数据核心就是refreactive,都依赖于 ES6 的Proxy API,以此来代理监听整个对象,从而能关注到复杂数据类型内部属性的增删改的变化。值得一提的是,这里代理对象包含了多嵌套式对象的情况,也就是可以实现深度监听。

相较于 Vue2 的defineProperty()仅对于属性层面的监听,无疑在构建复杂数据类型的响应式时,性能提升是巨大的。

下面让我们聊聊无处不在的refreactive:

ref

ref通常用来包装基本数据类型,由于Proxy是对于复杂数据类型的 API,所以它的实质是在Proxy包装的基础上又在外封装了一层,所以我们需要用.value来读写数据,但在<template>模板中访问响应式数据无需.value因为此时已经做了解包的处理。

reactive

对于reactive相对的便是用来包装复杂数据类型,诸如ObjectArray这样的数据,他可以直接监听整个对象的属性操作(增删改)。但要注意的是,切勿直接操作这个对象,也就是说不要改变这个reactive数据的引用,这会使他丢失响应式。

二者怎么抉择呢,尤大大提倡使用ref,事实也正是如此,绝大多数场景,简单和复杂数据类型均使用ref构建响应式,虽然理论上全部加一层包装会有性能损耗,但对于团队代码可读性和可维护性,这点损耗微乎其微。下面举个例子:

// 情景:初始化一个 list,后续调接口拿到数据 res.data,需要赋值(先不考虑使用 TS)
// 使用 reactive
const list = reactive({})
Object.keys(res.data).forEach(key => {
    list.key = res.data[key]
})
// 使用 ref
const list = ref({})
list.value = res.data
// 或更严谨
list.value = {...list.value, ...res.data}

高下立判,无论从可读性还是维护性上讲ref也是完胜的。当然对于一些构造表单模板即对象属性增删不频繁的场景reactive不免为更优雅的选择...

以上是在学习阶段对于两个兄弟的基本认识。

响应式数据在组件间通信

说起这点,最常用的便是父子组件间props+emit的通信

父→子:通过props传给子组件,子组件可直接使用,值得一提的是这里的props虽然是响应式的但我们不能直接通过props.a来修改,这虽然可行但违背了 Vue 单向数据流的原则会报错,试想如果一个响应式数据想在哪里修改就在哪里修改,姑且不说可能导致的异常,就代码规范性而言就不过关

子→父:所以我们通过emit的方法来修改,通过$emit触发父组件传给子组件的事件类型,父组件监听并响应触发事件

这里以 Vue3 组合式 API 的写法为例:

// Parent.vue -->
<template>
  <Child :count="count" @update-count="handleUpdate" />
</template>

<script setup>
import { ref } from 'vue'
import Child from './Child.vue'

const count = ref(0)
const handleUpdate = (newVal) => {
  count.value = newVal
}
</script>

// Child.vue -->
<template>
  <button @click="update">Count: {{ count }}</button>
</template>

<script setup>
const props = defineProps(['count'])
const emit = defineEmits(['update-count'])

const update = () => {
  emit('update-count', props.count + 1)
}
</script>

这里仅仅讲述最常用的通信,还有provide+injectpinia/vuex这里便不再赘述

常见坑点

我们在接收到porps的数据以后,如果父组件传的是一个ref,或者是reactive,或者是非响应式?我们子组件接受到该怎么使用,需要加.value?可以直接使用?还是需要传给中间值?怎么传?

这些问题可能在学习阶段无需思考,已经知道了怎么用就顺着来写,但我们需要考虑的是如果好久不用了,我们能否通过自己的技术深度来知道怎么使用是正确的,怎么使用不会丢失响应式?不会导致异常?

1、如果传值是ref/reactive/非响应,子组件如何用?

结论:无论父组件传的是 refreactive 还是普通对象,子组件通过 props 接收到的都是一个「普通响应式对象」,也就是Proxy,你永远不需要、也不应该在子组件中对props使用.value

原因:Vue 对 props 的统一处理机制

当你在父组件这样传递数据:

// 父组件
const a = ref({ name: 'Alice' })        // ref
const b = reactive({ name: 'Bob' })     // reactive
const c = { name: 'Charlie' }           // 普通对象

<Child :data-a="a" :data-b="b" :data-c="c" />

Vue 在传递给子组件前,会自动将所有值标准化为响应式对象(如果还不是的话),并注入到 props 中。

子组件接收到的 props 是一个由 Vue 内部创建的 响应式 Proxy 对象,结构如下:

// 子组件中的 props(概念上)
props = reactive({
  dataA: { name: 'Alice' },   // ← 已解包 ref,并转为响应式
  dataB: { name: 'Bob' },     // ← 原 reactive 对象(或其代理)
  dataC: { name: 'Charlie' }  // ← 普通对象被自动 reactive 包装
})

所以:props 中的每个属性都已经是“解包后”的响应式对象,无需 .value

2、该如何使用?

结论:始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量),并在需要修改但不影响源数据时创建本地副本。

原因

先看个错误的示例:

❌ 错误做法:解构或顶层赋值

setup(props) {
  const { name } = props.user;     // ❌ name 是普通字符串,失去响应式
  const age = props.user.age;      // ❌ age 是快照引用,不会随父更新

  // 后续使用 name/age 都是非响应式的!
}

有的兄弟可能要说了,我们有时候就只是需要其中的一个属性数据,也不用响应式,这样直接拿到不就好了?

但是请注意,如果父组件传的数据是异步获取的,当你直接结构或取值时可能拿到的是执行完异步操作前的数据,也就是说可能永远拿到的都是初始化时的空数据,因为就算异步操作完成,也会因丢失响应式而不会更新数据,造成问题!

🔔 ESLint 规则 vue/no-setup-props-destructure 就是为了防止这类错误。

所以始终通过 props.xxx 访问数据(不解构、不赋值给顶层变量)

而当我们本地需要创建副本来维护这个数据,但不影响父组件时:

import { ref, watch } from 'vue'
import _ from 'lodash' // 或自定义 deepClone

setup(props) {
  // 创建深度独立副本(保持本地响应式)
  const localUser = ref(_.cloneDeep(props.user));

  // 可选:监听 prop 变化以重置本地状态(如父组件刷新数据)
  watch(() => props.user, (newUser) => {
    localUser.value = _.cloneClone(newUser);
  });

  const updateName = (name) => {
    localUser.value.name = name; // ✅ 修改本地副本,不影响父组件
  };

  return { localUser, updateName };
}

终极建议:

  • 模板中:直接写 {{ props.xxx.yyy }} ✅

  • setup 中

    • 只读 → 用 props.xxx 或 () => props.xxx(在 watch/computed 中)
    • 需修改 → 创建 ref(deepClone(props.xxx)) 作为本地状态
  • 绝不在 setup 顶层解构 props 或赋值给普通变量

  • 修改数据 → 通过 emit 通知父组件,或操作本地副本

实力不济,新人小白,持续更新...

❌
❌