Vue3 响应式数据常用方案及实践坑点
最近在做 Vue 项目相关的需求,复习一下 Vue 的响应式机制及其常用办法
从 Vue3 视角来看,它的响应式数据核心就是ref和reactive,都依赖于 ES6 的Proxy API,以此来代理监听整个对象,从而能关注到复杂数据类型内部属性的增删改的变化。值得一提的是,这里代理对象包含了多嵌套式对象的情况,也就是可以实现深度监听。
相较于 Vue2 的defineProperty()仅对于属性层面的监听,无疑在构建复杂数据类型的响应式时,性能提升是巨大的。
下面让我们聊聊无处不在的ref和reactive:
ref
ref通常用来包装基本数据类型,由于Proxy是对于复杂数据类型的 API,所以它的实质是在Proxy包装的基础上又在外封装了一层,所以我们需要用.value来读写数据,但在<template>模板中访问响应式数据无需.value因为此时已经做了解包的处理。
reactive
对于reactive相对的便是用来包装复杂数据类型,诸如Object、Array这样的数据,他可以直接监听整个对象的属性操作(增删改)。但要注意的是,切勿直接操作这个对象,也就是说不要改变这个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+inject、pinia/vuex这里便不再赘述
常见坑点
我们在接收到porps的数据以后,如果父组件传的是一个ref,或者是reactive,或者是非响应式?我们子组件接受到该怎么使用,需要加.value?可以直接使用?还是需要传给中间值?怎么传?
这些问题可能在学习阶段无需思考,已经知道了怎么用就顺着来写,但我们需要考虑的是如果好久不用了,我们能否通过自己的技术深度来知道怎么使用是正确的,怎么使用不会丢失响应式?不会导致异常?
1、如果传值是ref/reactive/非响应,子组件如何用?
结论:无论父组件传的是 ref、reactive 还是普通对象,子组件通过 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通知父组件,或操作本地副本
实力不济,新人小白,持续更新...