
《Vue3魔法手册》更新到第4篇主题是响应式数据。每次有同学拿着 ref.value 和 reactive 对象来回折腾跟我说调试 Vue3 项目最难受的不是语法而是“数据都改了页面怎么就是不动”。这其实不怪大家响应式数据是 Vue3 里最接近“魔法”的部分你只是改了一个普通变量的值UI 就能自动重刷。但魔法背后是一套被设计好的数据追踪机制弄懂了这套机制调试 Vue3 项目的效率至少翻一倍。这篇文章不打算复述官方文档而是按我自己的实战节奏把“04_响应式数据”这个主题重新过一遍。适合刚学过 Vue3 语法、但还没真正理解 ref 和 reactive 底层逻辑的开发者也适合准备面试想临门补一把的人。通篇分五块先讲为什么 Vue3 要重写响应式系统再拆 ref 和 reactive 的差异接着解剖依赖收集和触发更新的原理然后放一段真实业务场景代码最后把常见的踩坑问题整理成速查表。1. 响应式数据到底是什么Vue2 到 Vue3 的那道分水岭想理解 Vue3 的响应式数据绕不开一个底层问题Vue2 为什么要用 Object.definePropertyVue3 又为什么要换掉它。这不是单纯的技术升级而是整个框架的数据追踪能力被迫换代。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 1Proxy 的set陷阱会命中Vue3 能感知这一次新增属性执行delete obj.keydeleteProperty陷阱会命中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.valuereactive({ 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.countProxy 的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或者toRefimport { 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读取时仍需要.valueMap 和 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]忘了自动解包例外访问时手动加 .valueeffect 执行两次或死循环读写交叉 重复依赖拆分为纯派生和副作用函数结尾我还是想放一句经验之谈。响应式数据不是不可捉摸的魔法它是一套有因果的追踪系统。我在项目里遇到过无数次“数据改了但页面不动”的情况每次顺着 get 收集、set 触发这条链路查下去最终都能定位到问题。越是觉得 Vue3 响应式“玄学”的时候越要回到依赖收集和触发更新这两个基本动作上去推理。把这条主线想通你再看 ref 包装、reactive 代理、computed 缓存全是同一套逻辑的变体。后面想把响应式系统吃得更透可以往 effect 调度器、异步批处理、defineModel 的依赖处理方向继续挖这些是 Vue3 性能优化和价值释放的下一层入口。