多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

Vue3响应式原理:ref与reactive的坑与避坑指南

Vue3响应式原理:ref与reactive的坑与避坑指南 被 ref 和 reactive 坑过的人大概都经历过大同小异的诡异场景控制台打印数据值确实变了页面上的视图纹丝不动。你要是再仔细看看代码逻辑也没写错。我在把公司老项目从 Vue2 迁到 Vue3 的那段时间前前后后踩了三次这种数据改了页面死活不刷新的坑最后靠反复翻源码、写测试用例才算把 Vue3 的响应式原理真正搞明白。这篇文章就是那几次踩坑—翻源码—做实验的完整复盘。如果你也在用 Vue3 写项目或者正在准备 Vue3 面试花十来分钟读完这篇能让你少走好几个月弯路。1. 先把响应式原理的地基夯实1.1 从 Object.defineProperty 到 ProxyVue3 做对了什么Vue2 的响应式是建立在 Object.defineProperty 上的它只能拦截对象上已经存在的属性的 get 和 set。你可以把它想象成给一栋楼的每一层装一道闸机楼上每新加一层你得先去装闸机才能监控。新增属性、删除属性、数组通过下标修改元素这些事情defineProperty 天然监听不到所以 Vue2 才需要 $set、$delete 这些额外 API。说白了这不是框架能力不够是底层 API 天生有边界。Vue3 换成了 Proxy它可以拦截整个对象层面的 13 种操作包括 get、set、has、deleteProperty、ownKeys、getOwnPropertyDescriptor 等。对应到生活里就是大楼门外装了一个总门禁任何人进出、查人、删人、盘点人数全部都要过门禁。所以新增属性、删除属性根本不需要额外 API响应式系统天然覆盖。这也是为什么 Vue3 里已经不存在 $set 这个 API 了。不过 Proxy 也有代价。能拦住的只有那些经过 Proxy 包装后的对象上的操作。如果你把 Proxy 底下的原始对象悄悄传给了某个第三方库第三方库直接在原始对象上改数据那么拦截器完全不知道页面自然就不会更新。很多数据改了页面没反应的问题根源其实在这里别急着怀疑框架 bug。1.2 响应式的核心循环track 与 triggerVue3 响应式的核心是两个函数track 和 trigger。track 负责收集依赖trigger 负责触发更新。打个比方你在小程序里点了一个按钮服务端数据变化后小程序要刷新页面。track 就像是把这个页面依赖了哪条数据注册到后台trigger 就像是数据一变后台立刻发消息推送叫页面重新渲染。具体到代码逻辑当你读取一个响应式对象的属性时会走 Proxy 的 get 拦截器在 get 内部调用 track(target, key)把当前正在运行的副作用函数和这个 key 的关系记录下来。当你给这个属性赋值时会走 set 拦截器在 set 内部调用 trigger(target, key)找到所有依赖这个 key 的副作用函数挨个重新执行。什么算副作用函数组件渲染函数、computed、watch、watchEffect 其实都是 effect。Vue3 内部维护了一个全局的 activeEffect 指针任何时候只有一个 effect 正在执行。track 记录的就是这个 activeEffect。这就像外卖平台你下单effect 读取数据的时候平台把你的送餐地址依赖关系登记好商家出餐数据变化后骑手按登记地址配送触发 effect 重新执行。没有这个登记表出餐了也没人知道你该送到哪。1.3 依赖数据结构WeakMap Map Set 的默契配合Vue3 的依赖存储结构看起来是三层嵌套WeakMap原始对象, Map属性名, Set 。第一层用 WeakMap 以原始对象为 key。为什么不用 Map 而是 WeakMap因为 WeakMap 是弱引用当原始对象被垃圾回收时对应的依赖记录也能被回收不会出现内存泄漏。这是 Vue3 源码里一个容易被忽略但至关重要的设计。第二层 Map 以属性名为 key用来区分同一个对象上不同属性的依赖。第三层 Set 装着所有依赖该属性的 effect 函数用 Set 可以天然去重避免同一个 effect 被重复收集。还是用办公楼做类比WeakMap 相当于整栋大楼的台账每一层楼对象属性有一份员工名单Set。某个员工迟到数据变化楼管直接从名单上挨个打电话触发 effect。楼拆了对象被回收台账也跟着销毁不会留下死信息。这个三结构模型理解了再去看 Vue3 的响应式面试题基本就稳了一半因为很多题绕到最后都是在问依赖是怎么收集的、又是怎么触发的。2. ref 和 reactive 的源码级差异2.1 reactive给对象套上多层代理而且带缓存reactive 做的事情说起来不复杂入参必须是对象或者数组返回一个 Proxy 代理实例。但有几个细节值得注意。第一深度代理是懒加载的。比如 reactive({ a: { b: 1 } })刚开始只代理最外层。当你访问 state.a 时get 拦截器发现 a 的值还是普通对象才递归调用 reactive 给内层对象也套上代理。这样做的最大好处是性能一个巨大的配置对象不会在初始化时就对所有层级做代理只有真正用到的数据才会被代理。第二reactive 有缓存机制。同一个原始对象多次调用 reactive返回的是同一个代理实例不会重复创建。源码里是一张 WeakMap原始对象, 代理对象。这也是为什么你会看到同一个对象在多处使用改一处会同时触发多依赖的原因因为它们本质是同一个 Proxy。第三reactive 处理不了基本类型。你不可能 new Proxy(1, handler)所以 number、string、boolean 这些值必须用 ref 包一层。这也是 ref 存在的最根本理由。2.2 ref给基本类型穿一件外套本质是包装器ref 的底层是一个 RefImpl 类实例上有两个关键字段_value 和 value。当你访问 .value 时get 里会做依赖收集当你给 .value 赋值时set 里会做触发更新。如果 ref 接收的对象类型内部会交给 reactive 处理也就是 toReactive(value) 会把对象转成响应式代理。这意味着 ref({}) 和 reactive({}) 在深层响应能力上是等价的。我简化一下源代码长这样class RefImpl { constructor(value) { this.__v_isRef true 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) } } }看这段代码就能理解ref 就是一个带响应式能力的小盒子。基本类型进盒子value 就是原始值对象类型进盒子value 自动变成 Proxy。那为什么模板里写 {{ count }} 不用 .value因为 setup 返回的对象在进入模板前会被 proxyRefs 处理自动把 ref 解包成 value。这是框架给你开的便捷通道不是 ref 本身拥有魔法。2.3 到底该选 ref 还是 reactive我的取舍逻辑关于这个社区里吵了很久。有人说都用 ref有人说大对象用 reactive。我这里给一个我目前觉得最顺手的规则。默认情况下能 reactive 就用 reactive因为直接操作对象属性代码最干净。比如一个表单对象有十几个字段你用 reactive 定义模板里写 state.name、state.age 就完事了如果用 ref 写你得写 state.value.name多一个 .value 非常别扭。而且 reactive 在 JS 代码中修改时state.name xx 一目了然。单个值、或者需要被多个模块共享的状态用 ref。比如 loading、pageNum、scrollTop、定时器句柄这种单一值用 ref 保存配合 computed 和 watch 非常方便而且 .value 这种写法反而是在提醒你这是一个引用类型不要直接把它丢进函数里。不过要注意的是团队里最好统一风格不要一会儿 reactive 一会儿 ref。混用本身不是大问题但混用加解构就容易踩到我后面要讲的坑。3. 我踩过的三个坑3.1 坑一解构一时爽响应火葬场这个坑我是在一个筛选表单页面踩的。当时写法大概是const state reactive({ keyword: , pageNum: 1, pageSize: 20, userInfo: { name: 小明 } }) // 错误示范直接解构 const { keyword } state keyword abc // 这只是改了一个普通字符串变量state.keyword 完全没动你可能一眼就看出来了state 是 reactive但是解构出来的 keyword 只是个普通字符串改 keyword 跟 state.keyword 没有半毛钱关系。我当时排查了半天最后在 console 里打了个 log才意识到 keyword 已经完全没有响应性了。这里有个特别容易误导人的点如果你的解构对象是嵌套对象解构出来的还是响应式的。因为 reactive 深层代理后state.userInfo 本身就是一个 Proxyconst { userInfo } state userInfo.name 小红 // 这个是响应式的userInfo 本身还是 Proxy这种对象解构没事、基本类型解构就死的不对称让很多人以为解构没问题直到解构到一个基本类型才翻车。我的建议是reactive 对象一律不要直接解构尤其是基本类型字段。要用就 toRefs后面有说。3.2 坑二整体替换对象一夜回到解放前第二个坑出现在表格加载数据的时候。我从接口拿到列表脑子一热写成了整体赋值let state reactive({ list: [] }) // 接口回调里 const res await fetchList() state { list: res.data } // 错误示范直接把 Proxy 扔了如果我用 let 定义 state这个错误根本不会报错但它会把 state 换成普通对象模板里所有渲染全部失效。这个问题在调试器里很难看因为控制台打印 state 明明就是 { list: [...] }但页面就是不渲染。后来我是用 watchEffect 打了个 log 才确认原来 state 已经不是 Proxy 了。正确的做法有两种。一种是只改属性不改引用把接口数据塞进已经存在的响应式对象里state.list res.data另一种是用 Object.assign 把批量字段合并进去Object.assign(state, { list: res.data })这两个写法都能保住 Proxy 身份数据更新后页面能正常刷新。这个坑为什么常见因为大家从 Vue2 过来时习惯了直接把 data 里的一个对象整体替换而从后端返回的数据去替换也是潜移默化的习惯。关键是理解响应式对象这个身份一旦被替换就没了而不是我赋值给一个响应式变量就万事大吉。3.3 坑三reactive 和 ref 混用一份数据两份状态第三个坑最折腾也是我在一个多 Tab 业务里踩的。当时为了让一个表单对象在多个组件间共享我先用 ref 保存了初始值后来为了操作方便又在组件里 reactive 了一下结果一份数据出现了两个状态源。const formTextRef ref({ formText: hello }) // 某个函数里为了方便响应式操作把初始值再包一层 const formProxy reactive(formTextRef.value)看这段代码。formTextRef 保存的是普通对象formProxy 是响应式代理但你修改 formProxy.formText 时formTextRef.value 还是原来的字符串完全不知道你改了。反过来如果你直接改 formTextRef.valueformProxy 也不知道。为什么会这样因为 reactive(formTextRef.value) 是基于 formTextRef 当前的 value 创建一份代理它和 formTextRef 之间没有任何实时同步关系。Proxy 只能套在原始对象外面当原始对象被 ref 包住时如果你不通过 .value 去拿原始对象reactive 拿到的就是一份值拷贝自然不会双向互通。这个坑教会我一个原则同一个数据源必须在整个项目里只有一个响应式的入口。要么走 ref要么走 reactive不要在多个地方用不同的 API 分别包一次。特别是在团队协作场景里别人在另一个组件里用 ref 改了值你以为 reactive 这边会跟着变结果白白流失了联动。顺带说一句如果你确实需要在 reactive 里放 ref要记住三个边界直接属性会自动解包state.loading 读出来就是 ref 的 value。数组和 Map/Set 里的 ref 不会自动解包你得 .value 访问。解包只发生在那一层属性上不要指望它穿透到更深层。这就是我在实际开发里看到的第不知道多少位同学踩过的坑了。4. 响应式调试与避坑工具箱4.1 toRefs 与 toRef把失去的响应性找回来toRefs 的作用是把 reactive 对象里的每个属性都转换成 ref返回一个普通对象。这样你就可以在两个世界里飞外层可以随便解构解构出来的是 ref内层访问值用 .value模板中又可以自动解包。const state reactive({ keyword: , pageNum: 1 }) const { keyword, pageNum } toRefs(state) keyword.value vue3 // 会同步修改 state.keywordtoRef 是单属性版更省性能适合只打算用其中一个字段的场景。而且 toRef 返回的 ref 和源对象是同步的改 countRef.valuestate.count 也会变反之亦然。这个同步关系在很多场景里非常好用比如要把一个 reactive 对象里的某个字段作为独立响应式数据传给子组件时先用 toRef 取出来再传子组件内 .value 修改也能同步回父级。4.2 shallowRef 与 triggerRef性能与精细控制如果你有一个对象不关心对象内部字段的深度变化只关心整体被替换的情况shallowRef 是个性能利器。const config shallowRef({ theme: dark, chart: hugeObj }) config.value.theme light // 不会触发更新 triggerRef(config) // 手动触发一次shallowRef 不会对 value 内部做深度代理所以 config.value.theme light 这种深度修改不会自动触发。如果你确实想手动触发渲染必须调用 triggerRef。这在管理一些大体积、低频更新的对象时非常有用比如图表配置、大型静态数据。注意浅响应不代理内部不代表内部完全不能更新。你可以更新内部值只是不会自动触发渲染。如果你能接受改完手动 trigger或者整个替换 value性能收益是很明显的。很多后台管理系统首屏卡顿的问题就是因为在一个巨型响应式对象上做了深度代理换成 shallowRef 能立刻缓解。4.3 响应式边界清单这些情况不会更新这一节把容易翻车的边界情况整理成一个速查清单Object.freeze 冻结的对象不会响应因为 Vue 检测到 frozen 会直接返回原对象不做代理。markRaw 标记的对象不会被代理适合放一些不需要响应的第三方实例。第三方库拿到的是原始对象时对它的修改都不会触发更新。数组直接改 length 和按下标赋值Vue3 的 Proxy 是可以捕获的这点比 Vue2 强不少但要小心不要整体替换数组。Map/Set 本身可以用 reactive(new Map())访问时 map.get/set 都会走代理但解构出来的方法有 this 绑定的坑要留意。setTimeout、Promise 回调里修改响应式数据是正常的响应式不是局限于同步代码。如果你发现数据改了但视图不更新第一件事不是猜框架 bug而是写一行 watchEffect 去观察数据watchEffect(() { console.log(state.count) })如果 watchEffect 里能正常触发说明响应式链路是通的问题大概率是你自己的引用条件写错了。这个调试方式简单、直接比单步断点高效得多。5. 团队落地建议与面试考点5.1 一套不会吵起来的 ref/reactive 使用规范我自己在团队里推过一段规范核心就四条一个数据源只保留一个响应式入口禁止重复包装。reactive 对象禁止直接解构需要解构用 toRefs。禁止对 reactive 变量整体赋新对象想批量更新用 Object.assign。基本类型状态一律用 ref对象类型看团队统一风格。有了这四条大部分人遇到的诡异问题都能规避。Code Review 的时候我会重点盯那些从 reactive 对象解构基本类型直接给 reactive 整体赋值的写法一出现就立刻打回。办法听起来很笨但真的比出 bug 后再排查省太多时间。另外建议把 watchEffect 当成日常调试利器用起来。写代码时如果心里没底临时加一个 watchEffect等确认链路没问题再删掉这行临时日志。这套流程我已经用了大半年排查效率明显提升。5.2 面试官最喜欢的响应式原理题如果你在准备 Vue3 面试下面这些点是几乎绕不开的ref 和 reactive 有什么区别基本类型和对象类型各自该怎么选为什么 Vue3 用 Proxy 替换 definePropertyProxy 能拦截哪些操作依赖收集和派发更新的完整链路是什么track、trigger、effect 各是什么响应式数据为什么在解构后会丢失响应性toRefs 怎么解决reactive 的深层响应是懒加载的吗这样做是为了什么ref 在模板中为什么能自动解包在 setup 里为什么要用 .valuetoRef 和 toRefs 有什么区别各自什么场景shallowRef 和 triggerRef 适合用在什么场景同一个响应式对象被多处引用时为什么能联动更新回答这些东西不需要死记硬背只要把前面讲到的 WeakMap Map Set 依赖模型、Proxy 拦截机制、ref 包装器这三块搞明白自然就能串起来。说实话刚接触 Vue3 那阵子我对 ref 和 reactive 的态度就是能用就行直到连续三次被响应性丢失的 bug 折磨后才老老实实回头读源码。现在回头看最值的不是记住了多少 API 用法而是建立了一个思维模型响应式不是一个开关而是一条从数据到视图的管道。你必须在整条管道上维护一致的身份引用任何一环脱开结果就是数据改了、页面不动。希望这篇复盘能帮你省下我当初花掉的那些排查时间。遇到一次诡异的 bug把这篇文章再翻一遍你大概率会和我一样突然通透。
返回列表