从 Vue2 迁移过来的人,十个有九个第一个卡住的地方就是 ref 和 reactive。我到现在还记得第一次在项目里看到同事代码时的那种状态:同一个文件里一会儿xxx.value,一会儿直接xxx.xxx,两者的边界到底是什么,看得人头皮发麻。后来我专门把 Vue3 的响应式源码翻了一遍,又陆续做了几个从零到一的项目,这个问题才算彻底想明白。
今天就从一个实战开发者的角度,把 Vue3 + TypeScript 下 ref 和 reactive 的区别、原理、选型和坑位全部讲清楚。内容不绕弯子,不需要你背什么概念,全部是可落地的东西,尤其适合刚入门 Vue3、以及准备用 TypeScript 重构 Vue2 项目的同学。
先给一个最简结论:ref 和 reactive 没有谁比谁高级,它们是同一个响应式体系下针对不同场景的两种封装。reactive 做事更直接,ref 做事更“保险”。但具体怎么选,背后有一套清晰的判断逻辑,不是随心情或团队某个人写一段代码就定了。下面我从原理开始一层层拆,最后给你一套可以直接抄的选型方案。
1. 先搞明白响应式原理:ref和reactive到底怎么工作的
很多教程上来就直接列区别表,这导致大家记住了结论却不懂原因,换个场景又不知道怎么判断。所以我想先带你看一眼它们底层的“机械结构”,等你理解了为什么 ref 要套一层.value,为什么 reactive 不能整体赋值,后面所有问题都能举一反三。
1.1 Vue3的响应式基石:Proxy到底比defineProperty强在哪
Vue3 响应式体系的核心是 ES6 的 Proxy。Vue2 时代用的Object.defineProperty只能劫持对象上已经存在的属性,所以你给对象新增一个 key、删除一个 key,或者直接通过索引改数组项,都是无法触发更新的。这也是为什么 Vue2 里要用Vue.set、要重写数组方法,本质上都是在给 defineProperty 的缺陷打补丁。
Proxy 不一样,它代理的是整个对象,不管这个 key 之前存不存在,只要你在 proxy 上做读写操作,都会被get、set、has、deleteProperty等拦截器捕获。一个最基本的代理可以写成这样:
const raw = { name: '张三', age: 18 } const observed = new Proxy(raw, { get(target, key, receiver) { // 这里可以做依赖收集 return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { const ok = Reflect.set(target, key, value, receiver) // 这里可以做派发更新 return ok }, deleteProperty(target, key) { const ok = Reflect.deleteProperty(target, key) // 删除操作也能触发更新 return ok }, })注意我用了Reflect.set配合receiver参数。这里的细节是:当对象存在 getter/setter,或者使用了继承属性时,Reflect能保证this指向正确,避免出现“改了值却没触发更新”的诡异问题。这个坑在 Vue3 源码里是有专门处理的,我自己手写响应式时就踩过,所以这里多写一句。
Proxy 还有一个隐性的性能优势:按需代理、惰性代理。Vue3 在访问嵌套对象的时候才会递归代理下一层,不像 Vue2 初始化时就要把整个对象递归遍历一遍挂 getter/setter。所以同样的深层对象,Vue3 初始化更快,内存占用也更小。
1.2 源码视角看分工:ref为什么非要套一层.value
reactive 的实现就是直接 new Proxy,这一点你已经知道了。但 reactive 有一个硬限制:只能代理对象类型。基本类型string、number、boolean这些,是没有属性可代理的,你总不能const count = reactive(0)然后监听一个数字吧?数字上没有 getter 和 setter 可以拦截。
ref 存在的意义就是打破这个限制。它不管底层是什么类型,都统一包一层对象:{ value: xxx }。基本类型写在value这个属性上,同样可以拦截读写;对象类型写在value属性上后,再交给reactive去深层代理。这样无论你传什么值给 ref,它都能保证响应式。
源码里简化一下,ref 的底层结构大概是这样的:
class RefImpl<T> { private _value: T public readonly __v_isRef = true constructor(value: T) { // 如果 value 是对象,会走 toReactive 变成 reactive 代理后的对象 this._value = toReactive(value) } get value() { trackRefValue(this) // 收集依赖 return this._value } set value(newVal) { if (hasChanged(newVal, this._value)) { this._value = toReactive(newVal) // 新值如果是对象,也要重新代理 triggerRefValue(this) // 触发更新 } } }这里最核心的就是get value()和set value()两个拦截方法。ref 本质上就是用 getter/setter 手动做的响应式拦截,而 reactive 是用 Proxy 自动做的拦截。一个偏“手动”,一个偏“自动”,这是二者最底层的差异。
也正因为 ref 的值挂了 getter/setter,在模板里才会自动解包。Vue 模板编译时会对顶层 ref 自动调用unref,所以你在模板里写{{ count }}其实等价于访问count.value,这就是为什么模板里不需要加.value的原因。但这个解包只发生在模板渲染上下文里,在 script 中必须老老实实写.value。
到了这一步,你再看 ref 和 reactive 的区别,就不会稀里糊涂了。ref 能装基本类型也能装对象,reactive 只能装对象;ref 在代码里要用.value,reactive 直接用属性。搞懂了原理,我再花一大节对比它们在实际开发中的行为差异。
2. ref与reactive六维对比:使用方式、类型推断、赋值解构全拆开
在讲具体场景之前,我们先做一次比较完整的对比。我把平时开发中最容易遇到的六个维度列在一张表里,后面每一节再针对重点展开讲。
| 对比维度 | ref | reactive |
|---|---|---|
| 支持数据类型 | 任意类型(基本类型、对象、数组) | 仅对象类型(对象、数组、Map、Set 等) |
| script 中访问方式 | xxx.value | 直接访问属性 |
| 模板中访问方式 | 顶层自动解包 | 直接访问属性 |
| 整个对象重新赋值 | 可以,xxx.value = newObj | 不行,会丢失响应式 |
| 解构后是否响应式 | 单独解构不会破坏 | 解构后丢失,必须用 toRefs |
| TypeScript 类型推断 | 自动推断为Ref<T> | 推断为传入对象类型 |
这张表基本覆盖了开发中的高频差异,接下来拆开说几个最容易出问题的点。
2.1 脚本和模板中的访问差异,以及自动解包的两个坑
在 script 中,ref 一定要加.value,reactive 不需要,这是最直观的区别:
const count = ref(0) const user = reactive({ name: '张三', age: 18 }) count.value = 1 user.name = '李四'模板里反而简单,二者都不需要加额外处理:
<template> <p>{{ count }}</p> <p>{{ user.name }}</p> </template>但自动解包有两个容易踩的坑。
第一个坑:嵌套在 reactive 对象里的 ref 会自动解包。比如:
const state = reactive({ count: ref(0), })你访问state.count拿到的是0而不是一个 Ref 对象,修改state.count = 1也会直接改到那个 ref。这种“自动解包”在多数场景是方便的,但如果你一开始不理解,很容易困惑:为什么这里的 ref 不用.value?答案是 Vue 在访问 reactive 对象属性时,对 ref 做了特殊处理。这个规则是有意设计的,不是 bug。
第二个坑:数组里嵌套的 ref 不会被解包。
const list = reactive([ref(1), ref(2)]) console.log(list[0].value) // 这里必须 .valueVue 只对 reactive 对象的顶层属性解包,数组元素不会自动解包。刚上手的人经常在这翻车,写了list[0]结果渲染出来的东西不对。记住一条规律:reactive 对象属性里的 ref 自动解包,数组里和容器里的 ref 不解包。
2.2 赋值与解构:一个能整体换新,一个换完就废
这一节是面试题的重灾区,也是项目里 bug 的高发区。
ref 的.value是一个普通 setter,所以你可以随时把整个值替换掉:
const user = ref({ name: '张三' }) user.value = { name: '李四' } // 完全没问题,新对象也会被代理但 reactive 不行,reactive 代理的是那个对象本身。如果你写:
const user = reactive({ name: '张三' }) user = { name: '李四' } // 直接报错,或者覆盖掉响应式就算你换一种方式:
let user = reactive({ name: '张三' }) user = { name: '李四' } // 此时 user 已经指向一个普通对象,响应式丢失你只是把user这个变量指向了新对象,原来的 reactive 对象被丢弃,新对象完全没有被代理。
正确做法是什么?用对象展开合并:
Object.assign(user, { name: '李四' })或者从源头上换容器,把整个数据用一个 ref 装起来。很多团队统一用 ref 的原因就在这:从服务端拉数据后整体赋值太常见了,ref 的state.value = await fetchXxx()简直是刚需。
再看解构。reactive 解构后就是普通变量:
const state = reactive({ count: 0, name: '' }) const { count, name } = state // 响应式丢失因为count只是一个从对象里拿出来的普通数字,后续count++改的是局部变量,跟state没有任何关系。要保留响应式就得上 toRefs:
const { count, name } = toRefs(state) // 此时 count 是 Ref<number>,用的时候 count.value而 ref 在解构时不存在这个问题,因为它的值本来就挂在.value属性上,解构出来的是一个 Ref 对象,Ref 对象的引用不变,响应式就不会断。
2.3 TypeScript场景下的类型体验:ref 、reactive 与接口约束
ref 在 TypeScript 里最自然的一点是,大多数情况下类型可以自动推断:
const count = ref(0) // Ref<number> const name = ref('') // Ref<string> const user = ref<User | null>(null) // 联合类型要显式声明如果你把值改成一个不匹配的类型,编辑器立刻报错,这在开发体验上是巨大的提升。而 reactive 更适合配合接口使用:
interface User { name: string age: number } const user = reactive<User>({ name: '张三', age: 18, }) const age: number = user.age但注意,如果接口里有可选属性,或者服务端返回的数据结构不完全可控,reactive 的泛型会逼你写一堆非空断言或者初始化兜底。比如:
interface User { name?: string age?: number } const user = reactive<User>({}) // 空对象初始化 // 访问 user.name 时 TS 会推断为 string | undefined这时候反而不如 ref 灵活:
const user = ref<User | null>(null) // 拿到数据后 user.value = res.data在组合式函数的返回类型上,ref 也明显更友好:
function useCount() { const count = ref(0) const double = computed(() => count.value * 2) return { count, double } } // 返回类型自动推断为 { count: Ref<number>, double: ComputedRef<number> }这个返回类型在组件里解构使用也没问题,因为 ref 解构不丢响应式。如果是 reactive 对象,在返回时通常要toRefs转换,否则调用方一解构就废了。这一点在很多组件库的源码里体现得很明显,它们大量使用返回 ref 的组合式函数,而不是返回响应式对象。
3. 实战选型建议:表单、列表、全局Store分别该用哪个
明白了原理和差异之后,真正的核心问题来了:项目里到底什么时候用 ref,什么时候用 reactive?
我自己的经验是,先看数据形态,再看操作方式。数据形态决定你能不能直接用 reactive,操作方式决定你用了会不会踩坑。下面按几种高频率场景给结论和代码。
3.1 表单数据推荐用reactive:少打一堆.value
如果你在开发一个表单,数据是一个结构固定的对象,字段已知且不会整体替换,那么 reactive 是最顺手的选择:
interface LoginForm { username: string password: string remember: boolean } const form = reactive<LoginForm>({ username: '', password: '', remember: true, }) function handleSubmit() { // 直接访问属性,不用 .value loginApi(form) }模板里可以直接双向绑定:v-model="form.username",这里不需要.value,干净利落。如果你用 ref 包整个表单对象,那么请求接口时得form.value,模板里v-model也得考虑解包问题,代码会显得啰嗦。
当然,表单不同场景也有例外。比如动态表单,字段数量不固定,可能你需要整体替换 schema,那就得谨慎使用 reactive。我在一个低代码配置页里遇到过一个典型问题:用户切换表单模板后,整个字段对象要换成新的,当时直接给 reactive 对象赋值,页面完全没反应,排查了很久才意识到是这个原因。最后改成 ref 容器才解决。
3.2 接口列表数据推荐ref:整体替换是刚需
从服务端拉列表数据是最常见的整体替换场景。用 ref 就是最直接的方案:
const list = ref<User[]>([]) const loading = ref(false) async function fetchList() { loading.value = true try { const res = await getUserList() list.value = res.data // 整体替换,完全不用慌 } finally { loading.value = false } }如果你的数据是一个大对象,比如包含列表、分页信息、加载状态,那也可以整体用 ref:
interface ListState { rows: User[] total: number loading: boolean } const state = ref<ListState>({ rows: [], total: 0, loading: false, }) state.value = await fetchData()这里如果用 reactive:
const state = reactive<ListState>({ rows: [], total: 0, loading: false, }) // 注意:不能 state = await fetchData(),会丢失响应式 // 必须改成 Object.assign(state, await fetchData())Object.assign写法虽然也能用,但它有几个坑:如果新数据里没有某个字段,旧数据不会被清掉;如果新数据多了嵌套对象,深层代理也是逐层处理的。用 ref 整体赋值则没有这些担心,赋值即替换,新对象自动变成响应式。所以在接口数据场景,我强烈建议用 ref。
3.3 全局状态管理和跨组件通信怎么选
全局状态管理,比如自己写一个简单的 store,很多教程推荐用 reactive:
export const store = reactive({ token: '', userInfo: null as UserInfo | null, login() { // ... }, })reactive 的好处是定义简单、访问直观,在组件里直接store.token。但它有个隐患:只要有人在某处把 store 里的字段解构出来用,响应式就断了。尤其是当项目规模变大以后,这种情况很难控制,你不可能追着每个人说“不要解构”。
所以我的建议是,团队统一约定优先用 ref 或者 Pinia。如果你是在业务代码里手写全局状态,可以围绕 ref 来做,因为 ref 在解构上的宽容度更高,传给子组件、从函数里返回,都不太容易弄丢响应式。如果你用 Pinia,内部本身帮你处理了这些问题,你在业务代码里拿到的 state 已经是响应式的,不需要自己再去定义 reactive 还是 ref。
对于跨组件通信,只要不涉及深层对象整体替换,reactive 也能用;但为了让你自己省心,凡是涉及“从接口拿数据然后赋值”的通信场景,继续坚持 ref 是更安全的选择。
3.4 DOM、组件实例的模板引用:从ref到useTemplateRef
还有一个经常被新手混淆的点:模板里面的ref="xxx"到底和ref()响应式 API 有什么关系?
实际上它们是两个东西。模板ref是 Vue 提供的模板引用功能,用来拿 DOM 元素或组件实例;而ref()是响应式 API。但它们在声明方式上有相似性:
<template> <input ref="inputEl" /> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' const inputEl = ref<HTMLInputElement | null>(null) onMounted(() => { inputEl.value?.focus() }) </script>这个inputEl虽然来自ref(),但它的值会在组件挂载后被 Vue 自动填入对应的 DOM 元素,卸载时变成 null。
Vue 3.5 以后还提供了更语义化的useTemplateRef:
import { useTemplateRef, onMounted } from 'vue' const inputEl = useTemplateRef<HTMLInputElement>('inputEl') onMounted(() => { inputEl.value?.focus() })它不需要你在模板 ref 字符串和脚本变量名之间手动保持一致,类型提示也更明确。如果你在用 Vue 3.5+,推荐直接用这个;如果还在用 3.4 及以下,就保持原来的写法。
说到这,值得提醒一个坑:子组件拿 ref 时,如果子组件用了<script setup>,默认是不会对外暴露内部属性的,你需要通过defineExpose显式暴露。这个不算响应式 API 的范畴,但很多新人把模板 ref 和响应式 ref 搞混后,在这里浪费了大量时间。
4. 脱源码手写响应式核心:reactive、ref、effect、computed的实现
只看别人的代码、只调 API,你对响应式原理的记忆始终是浅的。我在完全脱离 Vue 源码的情况下,用原生 Proxy 手写过一套包含reactive、ref、effect、computed的最小实现,写完之后很多之前想不通的问题都通了。这里我把实现思路完整还原出来,你如果有精力,建议也敲一遍,受益很大。
4.1 依赖收集与派发更新:track和trigger
响应式最重要的事就两件:收集依赖、触发更新。用生活化的类比来说,你有一个“花名册”,当一个副作用函数读取了某个响应式属性,你就把这个函数记在花名册里;当这个属性被修改了,你就把花名册里的函数全部拿出来执行一遍。
在 Vue3 源码里,这个花名册的数据结构是WeakMap<对象, Map<属性, Set<副作用函数>>>。为什么用 WeakMap?因为如果对象不再被使用,WeakMap 不会阻止垃圾回收,避免内存泄漏。
let activeEffect: (() => void) | null = null const targetMap = new WeakMap<object, Map<string | symbol, Set<() => void>>>() function track(target: object, key: string | symbol) { if (!activeEffect) return let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) } dep.add(activeEffect) } function trigger(target: object, key: string | symbol) { const depsMap = targetMap.get(target) if (!depsMap) return const dep = depsMap.get(key) if (dep) { for (const effect of dep) effect() } }这里activeEffect是全局唯一标记,它的作用是在“当前正在执行的副作用函数”和“响应式数据”之间建立关联。如果当前没有副作用在跑,即使读属性,也没有依赖可以收集。
4.2 实现effect:副作用函数的自动调度
effect接收一个函数,在 Vue 里它可以理解为 watchEffect 的最底层实现。它的职责是:先把函数设为activeEffect,然后立即执行一次,让函数里面的读取操作把activeEffect自己收集到对应的 dep 集合里;执行完后,activeEffect再清空。
function effect(fn: () => void) { activeEffect = fn fn() // 首次执行,触发读取,完成依赖收集 activeEffect = null }这个简单版本够用,但真跑起来会有问题:函数里如果修改了依赖的同一个 key,可能造成无限循环。Vue 源码里对这种情况做了队列批量处理和去重,这里我们不深究,先保证最小模型能跑通。
4.3 实现reactive与ref:Proxy代理和RefImpl包装
有了track和trigger,reactive就很简单了:
function reactive<T extends object>(target: T): T { return new Proxy(target, { get(obj, key, receiver) { track(obj, key) return Reflect.get(obj, key, receiver) }, set(obj, key, value, receiver) { const result = Reflect.set(obj, key, value, receiver) trigger(obj, key) return result }, deleteProperty(obj, key) { const result = Reflect.deleteProperty(obj, key) trigger(obj, key) return result }, }) }再实现ref。底层是一个类,通过get value()和set value()实现拦截:
function ref<T>(value: T) { return { _value: value, get value() { track(this, 'value') return this._value }, set value(newVal: T) { if (newVal !== this._value) { this._value = newVal trigger(this, 'value') } }, } }如果你把这套代码跑起来,会发现一个有趣的结果:ref 在内部也可以被 targetMap 收集。因为它本质上也是一个对象,track(this, 'value')会以这个 ref 实例作为 WeakMap 的 key。这就解释了为什么 ref 本来就是对象、为什么它的响应式和 reactive 是同一套体系。
4.4 实现computed:一个带缓存的特殊effect
computed 的实现比 effect 复杂一些,因为它需要有缓存。Vue 里 computed 是一个特殊 ref,它有dirty标记:依赖没变的时候读的是缓存值,依赖变了才重新计算。
function computed<T>(getter: () => T) { let value: T let dirty = true const runner = effect(() => { value = getter() dirty = false }) return { get value() { if (dirty) { runner() } return value }, } }这里第一次读 computed 的 value 时,如果 dirty 为 true,会执行一次 runner(也就是 effect),从而触发 getter 计算并收集依赖;之后如果依赖没变,dirty 一直是 false,直接返回缓存的 value。这个原理和面试里常问的“computed 为什么有缓存而 methods 没有”是一回事。
你把这套小小的响应式系统连起来跑一遍,会发现它真的能工作。写一遍这些代码,再回去看 ref 和 reactive 的区别,脑子里就不是死记硬背的规则,而是一张清晰的结构图了。
5. 常见问题与排查技巧实录:项目里最容易踩的5个坑
这一节全是实打实遇到的报错和诡异行为,我按问题现象、原因、解决方式整理出来,你在项目里排查时可以对照着看。
5.1 reactive整体赋值后页面不更新怎么破
现象:接口数据返回后state = res.data,控制台能看到数据变了,但页面纹丝不动。
原因:你已经把变量重新指向一个新对象,原来的 reactive 对象被丢掉了,新对象不是响应式。
解决方式有三条,按推荐顺序:
// 方式一:换容器,把 state 声明成 ref const state = ref<PageData>({ list: [], total: 0 }) state.value = res.data // 方式二:用 Object.assign 合并属性 Object.assign(state, res.data) // 方式三:手动逐个字段赋值 state.list = res.data.list state.total = res.data.total我实际项目里两种都用过。如果这个对象会被频繁整体替换,我强烈建议一开始就用 ref,不要在 reactive 上用 Object.assign 硬撑,后期维护太容易踩坑。
5.2 解构出来的数据不是响应式的
现象:const { count } = reactive({ count: 0 }),然后count++,页面不更新。
原因:解构出来的是原始值,和 reactive 对象之间没有任何关联。
解决方式:
const state = reactive({ count: 0, name: '' }) const { count, name } = toRefs(state) // count 现在是 Ref<number>,使用 count.value如果你只需要某一个字段,用toRef更精确:
const count = toRef(state, 'count')这个坑在组件拆分发散的时候尤其常见。比如你有一个 store 是 reactive 对象,然后多个组件里都解构它的字段,只要其中一个人解构时没转 toRefs,那个字段就悄悄变成非响应式,问题非常隐蔽。
5.3 TypeScript类型报错集合:从vue-tsc报错到TS 7弃用警告
最近很多 Vue3 + TS 项目升级后出现了类型报错和警告,这里挑几个最常见的说一下。
第一个是 vue-tsc 和 typescript 的版本匹配问题。如果你在 electron 打包或者 CI 里用"vue-tsc": "^1.8.27"+"typescript": "^5.3.3",这个组合本身是稳定的,没问题。但如果你把 TypeScript 升到 5.5 以上,vue-tsc 1.x 可能会报版本不匹配;反过来,vue-tsc 2.x 对 TS 版本有一定下限要求。经验是:先看 vue-tsc 的 peerDependencies,再锁 TS 版本,不要盲目升最新。
第二个是你可能看到这样一条废弃警告:
Option "baseUrl" is deprecated and will stop functioning in TypeScript 7.0. Specify "compilerOptions.paths" instead.这是 TypeScript 近年来的清理动作。在很多 Vite 创建的 Vue3 项目里,tsconfig 是这么写的:
{ "compilerOptions": { "baseUrl": ".", "paths": { "@/*": ["src/*"] } } }要消掉这个警告,就把baseUrl删掉,paths改成相对 tsconfig 文件的路径:
{ "compilerOptions": { "paths": { "@/*": ["./src/*"] } } }同时把moduleResolution设置成"bundler",这是 Vite 项目的推荐值,也能避免node10相关的弃用警告。
第三个是 reactive 泛型常见报错。比如:
const user = reactive({}) // 然后赋值 user.name = '张三' 会报错因为空对象类型上根本没有name属性。解决方式就是显式声明接口并给初始值:
interface User { name: string age: number } const user = reactive<User>({ name: '', age: 0 })这类报错不是响应式本身的问题,而是 TS 对越界属性访问的拦截。很多后台管理框架刚上手的人会遇到这个问题,本质都是“没有给 reactive 声明完整类型”。
5.4 数组与Map、Set的边界情况
Vue3 用 Proxy 代理数组后,arr[0] = x、arr.push()、arr.length = 0都可以触发更新了,这是比 Vue2 舒服的地方。但日常还是有几个边界情况需要注意。
第一个是数组整体替换,和 reactive 对象整体赋值是同一个问题:
const state = reactive({ list: [] as string[] }) // 不行:state.list = ['a', 'b'] 是可以的,但如果是 state = ...大多数时候我们操作的是state.list这个字段,那是可以整体赋值的,不算丢响应式。真正危险的是把整个 reactive 数组变量直接替换:
let list = reactive<string[]>([]) list = ['a', 'b'] // 响应式丢失第二个是 Map、Set 的代理。reactive 对它们也做了设置,但调用map.set()、set.add()时,Vue 会自动触发更新。不过如果你直接给整个 Map 重新赋值,同样丢响应式。
第三是深度监听的问题。Vue3 的 reactive 默认是深层响应式,但如果你用shallowRef或shallowReactive,就要清楚它们不会递归代理,只是浅层响应式。有些性能优化场景会用到shallowRef,配合triggerRef手动触发更新,比如处理超大列表时。如果你不了解这个差异,用了 shallow API 后会发现“为什么字段改了不刷新”,这时候要回头检查是不是用了浅层版本。
5.5 后台模板项目里频繁出现的ref和reactive混用问题
很多基于 Vue3 的开源后台管理系统模板,比如若依这种前后端分离的版本,页面里大量使用 reactive 来包裹查询参数和表单数据。这是可行的,因为查询条件字段稳定,不易整体替换。但如果你在这种模板基础上做二次开发,加入“重置查询条件”“从接口动态生成表单”等功能时,很容易踩到两个问题。
一个是重置表单时直接给整个 reactive 对象赋值,丢失响应式。正确做法是先记录初始值,然后Object.assign(form, initialForm),或者用替代方案。另一个是把 reactive 属性传给子组件后,子组件内部直接解构使用,导致后续变化不更新。如果你在封装公共组件时发现传进去的 data 偶尔不刷新,检查一下是不是在子组件里解构了。
我的经验是,在基于现有模板开发时,先看团队的代码风格。如果整个项目都已经约定用 reactive,那你就继续用 reactive,但要遵循两个死规矩:不整体赋值、不解构。如果做不到,就把这一块数据改成 ref。
我个人的体会是,在不明确怎么选的时候,优先用 ref。因为 ref 的门槛低、兼容面广,基本类型、对象、数组都能装,整套代码风格统一,所有响应式数据都用.value访问,没有例外,脑子里不需要频繁切换规则。等到你明确某块数据是结构稳定、频繁读取属性的对象时,再换成 reactive 也不迟。反过来,如果团队里大家习惯把 reactive 当“小 store”来用,那就一定要约定好不要解构、不要整体赋值,并用 toRefs 处理需要解构的场景。
最后再分享一个小经验:如果你想真正打通 read 和 reactive 这两块知识,不要只看文档。拿一组业务数据,分别用 ref 和 reactive 各写一遍同样的功能,边写边观察哪些地方用起来别扭,哪些地方报了类型错误。再照着上面第四节的方式,脱离 Vue 源码手写一套最小响应式系统。这两件事做完,你在项目里遇到任何响应式问题,都不会再瞎猜了。