《Vue3魔法手册》更新到第4篇,主题是响应式数据。每次有同学拿着 ref.value 和 reactive 对象来回折腾,跟我说调试 Vue3 项目最难受的不是语法,而是“数据都改了,页面怎么就是不动”。这其实不怪大家,响应式数据是 Vue3 里最接近“魔法”的部分:你只是改了一个普通变量的值,UI 就能自动重刷。但魔法背后是一套被设计好的数据追踪机制,弄懂了这套机制,调试 Vue3 项目的效率至少翻一倍。
这篇文章不打算复述官方文档,而是按我自己的实战节奏把“04_响应式数据”这个主题重新过一遍。适合刚学过 Vue3 语法、但还没真正理解 ref 和 reactive 底层逻辑的开发者,也适合准备面试想临门补一把的人。通篇分五块:先讲为什么 Vue3 要重写响应式系统,再拆 ref 和 reactive 的差异,接着解剖依赖收集和触发更新的原理,然后放一段真实业务场景代码,最后把常见的踩坑问题整理成速查表。
1. 响应式数据到底是什么:Vue2 到 Vue3 的那道分水岭
想理解 Vue3 的响应式数据,绕不开一个底层问题:Vue2 为什么要用 Object.defineProperty,Vue3 又为什么要换掉它。这不是单纯的技术升级,而是整个框架的数据追踪能力被迫换代。
1.1 Vue2 响应式机制的三个天花板
Vue2 的响应式核心是Object.defineProperty。它在初始化时遍历 data 里的每个对象,把每个属性重新定义成 getter/setter,然后让组件在访问属性时做依赖收集,在修改属性时触发视图更新。这个思路本身没问题,但套在 JavaScript 对象的真实使用场景上,有三个绕不过去的天花板。
第一个是“新增属性失灵”。因为 getter/setter 是提前定义好的,后添加的属性根本没有被改写,自然谈不上响应式。所以 Vue2 才不得不补出Vue.set和Vue.delete这套特殊 API。你在业务里漏写一个Vue.set,页面就会很安静地不更新,排查起来特别隐蔽。
第二个是数组的监听的残缺。Vue2 通过改写push、pop、splice等数组方法,勉强解决了一部分变异方法的响应式问题。但直接用索引赋值this.list[0] = {},或者修改length,仍然无法触发更新。这个坑到今天还在很多老项目里被人踩。
第三个是性能上的浪费。Vue2 在初始化 data 时,必须递归遍历每一层对象,把全部属性一次性改写。哪怕某个深层嵌套的数据在前端从没被用过,也得完整走一遍 defineProperty 流程。数据量一大,初始化就会明显变慢。
1.2 Proxy 给响应式系统带来的降维打击
Vue3 把核心换成 Proxy,这并不是“换个 API 实现同一个功能”,而是元编程能力的维度变化。Proxy直接代理整个目标对象,不管目标对象上有多少个属性、后续会不会新增属性,都能拦截到get、set、deleteProperty、has、ownKeys等一系列操作。
举个例子,对一个对象执行obj.newKey = 1,Proxy 的set陷阱会命中,Vue3 能感知这一次新增属性;执行delete obj.key,deleteProperty陷阱会命中,Vue3 也能感知。这直接消灭了Vue.set和Vue.delete存在的必要,也彻底解决了数组索引赋值的问题。
另一个维度上的优势是“懒代理”。Vue3 的reactive并不会在一开始就递归改写所有层级,而是当代码真正访问到某个嵌套对象时,才在这个 get 环节用reactive再包一层。这就是为什么 Vue3 对深层大数据结构的初始化要比 Vue2 快很多。
1.3 面试官真正想听的那一版答案
Vue3 和 Vue2 的区别属于面试高频题,网上背答案的人太多。如果面试官问“Vue3 为什么要用 Proxy”,尽量别只答一句“Proxy 更强”。
我个人建议的作答思路是:先指出 Vue2 必须提前定义 getter/setter,本质上是“预祝式监听”,它没办法响应未来新增的属性,也没办法监听索引赋值;然后说 Proxy 是“后置式代理”,运行期无论对象结构怎么变,都能拦下来;最后补一句依赖收集上的差异,Vue3 不需要像 Vue2 那样一初始化就递归遍历,而是在 get 时按需临时构建响应式嵌套结构。这个回答既覆盖了原理,又点出了性能提升的来源,比背概念要有说服力得多。
2. 选 ref 还是 reactive:响应式数据的两副面孔
Vue3 对外暴露了两套写法让人很纠结:ref 和 reactive。两者都能做到响应式,却有着不同的设计约束。我刚开始用的时候也犯过糊涂,后来把它们的底层差异理清之后,选择就变得很自然了。
2.1 ref 的包装逻辑与那把钥匙
ref存在的根本原因,JavaScript 的原始值无法直接做代理。一个数字1,一个字符串'hello',它们没有对象结构,既不满足Object.defineProperty的“目标必须是个对象”的条件,也不满足Proxy代理“目标必须是对象或函数”的条件。所以 Vue3 做了一个包装:用RefImpl类把原始值装进一个对象,然后通过value属性来读取和修改内部值。
这里很多人会问,为什么偏偏是value,换成data行不行?从源码看,value只是Ref接口约定好的访问出口。模板编译时会识别 ref 类型的对象,自动解包value,如果你换成别的字段名,框架就不知道去哪里取真正的值了。所以对于原始值类型的响应式数据,对ref()的包装,行为上就像你给一个文件打了个压缩包,想读里面的内容必须先解压钥匙.value,而模板里则是被框架自动解压好的状态。
更关键的是,ref不仅能包装原始值,也能包装对象。当你执行ref({ name: '张三' })时,Vue3 内部并没有偷懒,它会把传入对象转交给reactive再包一层,保证嵌套的对象属性也是深度响应式的。也就是说,ref 其实覆盖了 reactive 的使用范围,这也是很多人推崇“全局统一用 ref”的底气来源。
2.2 reactive 的代理范围和那层隐身的壳
reactive接受的参数类型是对象,包括普通对象、数组、Map、Set。它直接返回一个 Proxy 实例,你访问到的任何属性都会走拦截逻辑。
不过需要注意一件事:reactive返回的对象和原对象已经不是同一个引用了。你劫持了原对象,最终拿到的代理壳才是响应式数据。如果业务里不小心把原对象塞到状态里,或者用原对象去赋值给另一个 reactive,响应式链路会在这里断开。源码里对重复代理做了缓存,同一个原对象多次调用reactive会返回同一个代理对象,但反过来,代理对象和原对象做===比较永远是false。
从架构习惯上看,reactive更适合“把一堆关联数据放一起管理”的场景,比如一个表单对象、一组筛选条件。它语法上更像 Vue2 里直接操作data的方式,不需要到处写.value。
2.3 自动解包机制与项目里的选型建议
Vue3 在模板和reactive内部做了两处自动解包:模板里写{{ count }}会自动读取count.value,reactive({ count: ref(1) })之后用state.count能直接读到数值,不用写state.count.value。
但有两处不会自动解包,特别容易踩:数组和 Map/Set。const list = reactive([ref(1), ref(2)])这时如果list[0].value才拿得到数字。因为自动解包机制针对的是 Reactive 对象属性的访问路径,数组索引本身不享受这套特殊处理。
选型方案上,我参考过社区的各种流派,也问过不少做 Vue3 实战项目的朋友,最后没有绝对标准。最稳的组合是:业务状态尽量用reactive聚合管理,单个原始值或需要传递/解构的变量用ref。如果你不喜欢这种来回切换的感觉,整个项目统一ref也没有问题,因为ref包装对象时内部就是reactive,只是多套了一层.value外壳。真正的雷区不是选哪套,而是同一份数据一会儿用 raw 对象、一会儿用 ref,导致响应式断链。
3. 依赖收集与触发更新:把魔法拆开看
如果说前两章是 Vue3 响应式数据的“外在表现”,这一章就是它的大脑和中枢。很多新手卡在“数据改了页面为什么更新”这个问题上,核心就两个词:依赖收集(track)、触发更新(trigger)。
3.1 三张表组成的数据依赖图
Vue3 的响应式系统内部维护了一个依赖表结构,源码里的容器是 WeakMap,嵌套结构是targetMap→depsMap→deps,一共三层。
targetMap:以“被代理的目标对象”为 key,值是depsMap。因为目标对象是一个对象,所以能用 WeakMap。depsMap:以“属性名”为 key,值是deps依赖集合。deps:一个 Set,里面存着所有依赖该属性的副作用函数 effect。
把这三层结构用人话翻译一遍:我先记录“哪个对象”被观察了,再记录“对象的哪个属性”被观察了,最后记录“谁在观察这个属性”。每条数据变更通知,本质上就是去这三层表里查两遍,找到对应属性名下的所有 effect,然后挨个执行。
这里为什么用WeakMap而不是普通Map,是面试中很有分量的一道题。WeakMap 的 key 是弱引用,它不会阻止目标对象被垃圾回收。如果组件销毁、对象不再被使用,依赖表里对应的项目会被 GC 自动清掉,不会造成内存泄漏。换成普通 Map,只要依赖表一直存在,目标对象就会被 Map 一直强引用,内存迟迟得不到释放。
3.2 get 里埋下因果,set 里引爆结果
假设你写了一个effect函数,里面读取state.count。effect执行时会先把自己暂存到一个全局变量activeEffect里,这就像给当前执行的 effect 贴了一张“当前任务”的标签。然后函数体开始读取state.count,这一步会触发 Proxy 的get陷阱,track函数趁机把“正在运行的这个 effect”放进依赖表。
等到某个时刻代码执行state.count++,Proxy 的set陷阱被触发,trigger函数会去依赖表里找到对应属性名下的所有 effect,逐个重新执行。这个机制特别像报纸订阅:每次读数据都相当于来编辑部前台登记“我要订这一期的报纸”,每次改数据就相当于印了一批新报纸,发行员按登记表逐个敲门投递。
但 Vue3 实际源码远不止这么简单,它还要处理调度器。生产环境里如果同一个事件循环里连续改十次数据,不可能触发十次页面渲染。Vue3 把 trigger 触发的 effect 放进一个异步队列,等当前同步代码执行完,再统一冲刷一次。这就是nextTick存在的原因:你同步改了数据,马上读 DOM,读到的还是旧值,必须等微任务队列跑完。
3.3 手写一个 50 行响应式核心
原理讲得再多,不如动手写一遍。为了把核心链路抽出来,我简化了一个版本的响应式系统:
const targetMap = new WeakMap() let activeEffect = null function effect(fn) { const _effect = function () { activeEffect = _effect fn() activeEffect = null } _effect() } function track(target, key) { if (!activeEffect) return let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } let deps = depsMap.get(key) if (!deps) { deps = new Set() depsMap.set(key, deps) } deps.add(activeEffect) } function trigger(target, key) { const depsMap = targetMap.get(target) if (!depsMap) return const deps = depsMap.get(key) if (deps) { deps.forEach((fn) => fn()) } } function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const res = Reflect.get(target, key, receiver) track(target, key) return res }, set(target, key, value, receiver) { const res = Reflect.set(target, key, value, receiver) trigger(target, key) return res }, }) }这段代码的执行流程是:先执行effect,把当前函数标记为活跃依赖,然后函数里读state.count,触发get陷阱收集依赖;改state.count,触发set陷阱,从依赖表取到 effect 并执行,于是页面依赖的更新函数重新跑一遍。
实际源码比这个版本多了hasChanged判断、ITERATE_KEY、数组特殊处理、对象新增属性等边界逻辑。比如 Vue3 中set里要先判断旧值和新值是否相同,不同才触发 trigger,否则会出现“改一个没变化的属性也触发更新”的性能浪费。同时,如果修改的是数组的索引,trigger还需要找到所有依赖 length 的 effect,统一触发。
依赖收集对整个 Vue3 体系意味着一切:普通变量的更新只是内存里换了数字,响应式变量的更新会引起整个依赖链的连锁反应。
4. 响应式数据实战:从列表查询到状态共享
原理吃透之后,还是得回到业务里用。这一章用一个小型后台管理系统最常见的“搜索筛选列表”场景,把 ref、reactive、computed 串起来。
4.1 搜索筛选场景的状态设计
页面需求是:顶部有几个筛选条件,下面是一张表格,请求接口拿数据,支持分页。我先定义状态:
import { ref, reactive, computed } from 'vue' const loading = ref(false) const tableData = ref([]) const queryParams = reactive({ page: 1, pageSize: 10, keyword: '', category: '', status: '', }) async function fetchList() { loading.value = true try { const res = await getListApi({ ...queryParams }) tableData.value = res.data.list } finally { loading.value = false } }这里我把单个状态用ref,把功能上强相关的筛选条件用reactive聚成一个对象。为什么不干脆都拆成keyword、category、status三个 ref?因为查询参数需要整体传给接口,还会统一重置、统一修改,聚合在一个对象里的代码可读性明显更好。
loading为什么要用ref?因为函数入参、模板表达式里的 loading 都需要那个布尔值本身,用 ref 可以方便地通过.value修改,模板里又自动解包,不会增加心智负担。
4.2 computed 的缓存逻辑与适用边界
在列表接口返回后,我需要给表格加一个操作列:某些状态下显示“禁用”按钮,某些状态下显示“启用”。这个判断不复杂,但逻辑放在模板里会很乱,放在函数里又会每次渲染都执行一遍。用 computed 刚刚好:
const rowActionText = computed(() => { return (row) => row.status === 1 ? '禁用' : '启用' })不过这里有个很容易误导人的地方:computed 接收的函数如果依赖外部响应式变量,那它只会在依赖变化时重新计算。如果函数体里没有任何响应式依赖,那它就只会计算一次,之后永远返回缓存值。
我见过有人用 computed 做接口调用,这属于典型误用。computed 的设计目标是“根据现有状态派生一个新值”,内部应保持纯计算。如果你在 computed 里发请求、写 localStorage,副作用一旦发生,缓存机制会让这段副作用只执行一次,还是会重复多次,完全取决于依赖变化时机,极易出现诡异问题。副作用操作应该放到 watch 或事件函数里。
computed 相对于 watch 的核心优势是懒:computed 只有被访问时才会计算并缓存结果;watch 则是在依赖变化时立刻拿到前后值,适合处理“变化之后要做什么”的问题。两者职责边界清晰,不要混用。
4.3 跨组件共享响应式状态的最佳姿势
很多项目一旦出现跨组件传值,第一反应是上 Pinia。但如果共享的状态只是登录用户的昵称、主题色、某个通用配置这种小粒度数据,用一个组合式函数维护单例 reactive 对象会更轻。
// store/user.js import { reactive } from 'vue' const state = reactive({ name: '', avatar: '', token: '', }) export function useUserStore() { return state }在任意组件中引入useUserStore,拿到的都是同一个state对象,一个组件改state.name,另一个组件渲染的内容会跟着更新。这就是“模块级单例”在响应式系统里的效果:reactive 返回的 Proxy 被模块长期持有,任何组件修改的都是同一份代理壳。
这个模式适合中小型项目,但一旦状态逻辑复杂、需要持久化、需要大量异步 action,还是乖乖用 Pinia 这类正规状态库,否则光管理模块引入关系和生命周期就会很痛苦。
5. 响应式数据踩坑地图:新手必看的 5 个问题
最后这部分是我自己踩过坑、也被身边同学问得最多的几个点,每个都配了排查思路,建议先收藏再对号入座。
5.1 解构 reactive 对象为什么会断链
const state = reactive({ count: 1 }) const { count } = state count++ // state.count 不会跟着变原因不复杂:解构是从 reactive 对象上取出原始值,取出一瞬间就是数字 1,后续对count的修改和state.count没有任何联系。要解决这个问题,用toRefs或者toRef:
import { reactive, toRefs } from 'vue' const state = reactive({ count: 1, name: '张三' }) const { count, name } = toRefs(state) // 此时 count 是一个 ref,修改 count.value 会同步到 state.count这里要补充一点,toRefs只是给每个属性“建立了一条通往原对象对应位置的通道”,它并不复制数据。所以之后无论如何解构,都不会产生独立副本。
5.2 reactive 对象整体重新赋值,别直接替换
很多人在表格翻页时喜欢写state.xxx = res.data,如果这个状态是 reactive 定义的,直接整体替换会断开原来的代理关系。正确做法是往已有对象里填数据,或者干脆把整个状态定义成 ref 再替换。
// 错误示范 const state = reactive({ list: [] }) state = { list: res.data } // 直接覆盖了代理对象的引用,响应式断链 // 正确示范 const state = ref([]) state.value = res.data在复杂表单场景中,这个坑尤其常见。比如动态添加删除 form 的一行数据,如果直接把整个数组替换给一个 ref,没问题;如果替换给一个 reactive 的某个属性,要确认替换目标不是 reactive 对象本身,而是对象的字段。
5.3 数组索引修改和 length 修改的响应式边界
Vue3 的 Proxy 可以拦截数组索引赋值,比如arr[0] = 'x',此时能触发响应式。但一些“看起来改数组、实际是在改数组内部”的操作仍然有讲究,最典型的是sort、reverse、filter这类方法。sort和reverse是原地修改,能触发;filter返回的是新数组,不会触发原数组的响应式更新。
处理这类情况,我习惯直接对 ref 持有数组,用.value = newArray完成整体替换。这样既简单又可预测,也不容易受数组方法返回值的干扰。至于splice,它在 Vue3 中能触发的依赖范围包括 length,整体没问题。
5.4 ref 自动解包的三个例外
模板中的 ref 自动解包,reactive 内部嵌套的 ref 自动解包,但下面的情况要吃透:
- 数组里嵌套的 ref,读取时仍需要
.value - Map 和 Set 内部嵌套 ref,不自动解包
- 在 ref 包装的自身上写
foo.value,内部对象属性是响应式,但当你把 ref 塞进 reactive 之后再取出来,取到的是自动解包后的内部值,不是 ref 本身
这些例外会让第一次遇到的人很迷茫,因为它们看起来跟“自动解包”的直觉相反。我的经验是:写代码时尽量避免在数组和 Map 里存 ref,统一存普通值,这样能省掉一大半排查精力。
5.5 effect 无限循环和其他诡异反射
如果某个 effect 函数里既读了一个响应式变量,又改了它,触发更新后 effect 重新执行,再次读再次改,就形成死循环。平时用 watch 也有类似风险:在 watch 回调里直接修改被监听的源,很可能导致无限触发,除非修改逻辑是幂等的。
排查这类问题的思路一般是先看触发链路上是否有影响同一响应式变量的读写交叉,其次检查 set 里新增值是否与原值相同。我给项目做性能分析时,会特意关注那些频繁被触发、但函数体里又包含重型计算的 effect,这往往是响应式系统里最容易拖慢渲染的角落。
| 问题 | 可能原因 | 解决思路 |
|---|---|---|
| 解构 reactive 后改值没反应 | 拿到的是原始值 | 用 toRefs / toRef 建立通道 |
| 整体替换 reactive 状态后不刷新 | 代理引用被覆盖 | 改用 ref 持有整体数据,或逐字段赋值 |
| 数组索引改了没生效 | 使用了会返回新数组的方法 | 用整体替换或 splice 原地修改 |
| 模板里某个 ref 显示成 [object Object] | 忘了自动解包例外 | 访问时手动加 .value |
| effect 执行两次或死循环 | 读写交叉 + 重复依赖 | 拆分为纯派生和副作用函数 |
结尾我还是想放一句经验之谈。响应式数据不是不可捉摸的魔法,它是一套有因果的追踪系统。我在项目里遇到过无数次“数据改了但页面不动”的情况,每次顺着 get 收集、set 触发这条链路查下去,最终都能定位到问题。越是觉得 Vue3 响应式“玄学”的时候,越要回到依赖收集和触发更新这两个基本动作上去推理。把这条主线想通,你再看 ref 包装、reactive 代理、computed 缓存,全是同一套逻辑的变体。后面想把响应式系统吃得更透,可以往 effect 调度器、异步批处理、defineModel 的依赖处理方向继续挖,这些是 Vue3 性能优化和价值释放的下一层入口。