从 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为什么非要套一层.valuereactive 的实现就是直接 new Proxy这一点你已经知道了。但 reactive 有一个硬限制只能代理对象类型。基本类型string、number、boolean这些是没有属性可代理的你总不能const count reactive(0)然后监听一个数字吧数字上没有 getter 和 setter 可以拦截。ref 存在的意义就是打破这个限制。它不管底层是什么类型都统一包一层对象{ value: xxx }。基本类型写在value这个属性上同样可以拦截读写对象类型写在value属性上后再交给reactive去深层代理。这样无论你传什么值给 ref它都能保证响应式。源码里简化一下ref 的底层结构大概是这样的class RefImplT { 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 在代码里要用.valuereactive 直接用属性。搞懂了原理我再花一大节对比它们在实际开发中的行为差异。2. ref与reactive六维对比使用方式、类型推断、赋值解构全拆开在讲具体场景之前我们先做一次比较完整的对比。我把平时开发中最容易遇到的六个维度列在一张表里后面每一节再针对重点展开讲。对比维度refreactive支持数据类型任意类型基本类型、对象、数组仅对象类型对象、数组、Map、Set 等script 中访问方式xxx.value直接访问属性模板中访问方式顶层自动解包直接访问属性整个对象重新赋值可以xxx.value newObj不行会丢失响应式解构后是否响应式单独解构不会破坏解构后丢失必须用 toRefsTypeScript 类型推断自动推断为RefT推断为传入对象类型这张表基本覆盖了开发中的高频差异接下来拆开说几个最容易出问题的点。2.1 脚本和模板中的访问差异以及自动解包的两个坑在 script 中ref 一定要加.valuereactive 不需要这是最直观的区别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没有任何关系。要保留响应式就得上 toRefsconst { count, name } toRefs(state) // 此时 count 是 Refnumber用的时候 count.value而 ref 在解构时不存在这个问题因为它的值本来就挂在.value属性上解构出来的是一个 Ref 对象Ref 对象的引用不变响应式就不会断。2.3 TypeScript场景下的类型体验ref 、reactive 与接口约束ref 在 TypeScript 里最自然的一点是大多数情况下类型可以自动推断const count ref(0) // Refnumber const name ref() // Refstring const user refUser | null(null) // 联合类型要显式声明如果你把值改成一个不匹配的类型编辑器立刻报错这在开发体验上是巨大的提升。而 reactive 更适合配合接口使用interface User { name: string age: number } const user reactiveUser({ name: 张三, age: 18, }) const age: number user.age但注意如果接口里有可选属性或者服务端返回的数据结构不完全可控reactive 的泛型会逼你写一堆非空断言或者初始化兜底。比如interface User { name?: string age?: number } const user reactiveUser({}) // 空对象初始化 // 访问 user.name 时 TS 会推断为 string | undefined这时候反而不如 ref 灵活const user refUser | null(null) // 拿到数据后 user.value res.data在组合式函数的返回类型上ref 也明显更友好function useCount() { const count ref(0) const double computed(() count.value * 2) return { count, double } } // 返回类型自动推断为 { count: Refnumber, double: ComputedRefnumber }这个返回类型在组件里解构使用也没问题因为 ref 解构不丢响应式。如果是 reactive 对象在返回时通常要toRefs转换否则调用方一解构就废了。这一点在很多组件库的源码里体现得很明显它们大量使用返回 ref 的组合式函数而不是返回响应式对象。3. 实战选型建议表单、列表、全局Store分别该用哪个明白了原理和差异之后真正的核心问题来了项目里到底什么时候用 ref什么时候用 reactive我自己的经验是先看数据形态再看操作方式。数据形态决定你能不能直接用 reactive操作方式决定你用了会不会踩坑。下面按几种高频率场景给结论和代码。3.1 表单数据推荐用reactive少打一堆.value如果你在开发一个表单数据是一个结构固定的对象字段已知且不会整体替换那么 reactive 是最顺手的选择interface LoginForm { username: string password: string remember: boolean } const form reactiveLoginForm({ username: , password: , remember: true, }) function handleSubmit() { // 直接访问属性不用 .value loginApi(form) }模板里可以直接双向绑定v-modelform.username这里不需要.value干净利落。如果你用 ref 包整个表单对象那么请求接口时得form.value模板里v-model也得考虑解包问题代码会显得啰嗦。当然表单不同场景也有例外。比如动态表单字段数量不固定可能你需要整体替换 schema那就得谨慎使用 reactive。我在一个低代码配置页里遇到过一个典型问题用户切换表单模板后整个字段对象要换成新的当时直接给 reactive 对象赋值页面完全没反应排查了很久才意识到是这个原因。最后改成 ref 容器才解决。3.2 接口列表数据推荐ref整体替换是刚需从服务端拉列表数据是最常见的整体替换场景。用 ref 就是最直接的方案const list refUser[]([]) const loading ref(false) async function fetchList() { loading.value true try { const res await getUserList() list.value res.data // 整体替换完全不用慌 } finally { loading.value false } }如果你的数据是一个大对象比如包含列表、分页信息、加载状态那也可以整体用 refinterface ListState { rows: User[] total: number loading: boolean } const state refListState({ rows: [], total: 0, loading: false, }) state.value await fetchData()这里如果用 reactiveconst state reactiveListState({ rows: [], total: 0, loading: false, }) // 注意不能 state await fetchData()会丢失响应式 // 必须改成 Object.assign(state, await fetchData())Object.assign写法虽然也能用但它有几个坑如果新数据里没有某个字段旧数据不会被清掉如果新数据多了嵌套对象深层代理也是逐层处理的。用 ref 整体赋值则没有这些担心赋值即替换新对象自动变成响应式。所以在接口数据场景我强烈建议用 ref。3.3 全局状态管理和跨组件通信怎么选全局状态管理比如自己写一个简单的 store很多教程推荐用 reactiveexport 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还有一个经常被新手混淆的点模板里面的refxxx到底和ref()响应式 API 有什么关系实际上它们是两个东西。模板ref是 Vue 提供的模板引用功能用来拿 DOM 元素或组件实例而ref()是响应式 API。但它们在声明方式上有相似性template input refinputEl / /template script setup langts import { ref, onMounted } from vue const inputEl refHTMLInputElement | null(null) onMounted(() { inputEl.value?.focus() }) /script这个inputEl虽然来自ref()但它的值会在组件挂载后被 Vue 自动填入对应的 DOM 元素卸载时变成 null。Vue 3.5 以后还提供了更语义化的useTemplateRefimport { useTemplateRef, onMounted } from vue const inputEl useTemplateRefHTMLInputElement(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 WeakMapobject, Mapstring | 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与refProxy代理和RefImpl包装有了track和triggerreactive就很简单了function reactiveT 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 refT(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一个带缓存的特殊effectcomputed 的实现比 effect 复杂一些因为它需要有缓存。Vue 里 computed 是一个特殊 ref它有dirty标记依赖没变的时候读的是缓存值依赖变了才重新计算。function computedT(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 refPageData({ 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 现在是 Refnumber使用 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.27typescript: ^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 reactiveUser({ 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 reactivestring[]([]) 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 源码手写一套最小响应式系统。这两件事做完你在项目里遇到任何响应式问题都不会再瞎猜了。
