ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Vue3响应式系统深度解析:reactive、ref与依赖收集原理

Vue3响应式系统深度解析:reactive、ref与依赖收集原理 Vue3 正式发布都过去好几年了网上解析响应式系统的文章一抓一大把但我发现一个奇怪的现象很多人收藏了一大堆源码解析真到面试或者自己排查问题的时候依然说不清楚reactive和ref到底差在哪更别提手写一个简化版的响应式系统了。这篇文章我不打算逐行贴源码那玩意儿 Vue 仓库里都有我重点讲“为什么这么设计”和“你能拿它做什么”顺带把我在实际项目里踩过的坑和排查思路一并交代清楚。如果你是刚把 Vue2 项目迁到 Vue3或者正准备跳槽想啃下响应式这块硬骨头又或者你只是好奇“数据一变页面就更新”这玩意儿到底怎么实现的这篇文章应该都能给你一个比较完整的答案。内容会有点长建议先收藏再慢慢看。1. 响应式系统为什么非重写不可1.1 Vue2 响应式的天然短板Vue2 的响应式系统建立在Object.defineProperty之上这个 API 本身没什么问题问题在于它只能劫持对象的属性而且是在初始化时就锁定属性列表。这意味着两件很麻烦的事第一你给对象新增一个属性它不会自动变成响应式必须调用Vue.set第二通过下标修改数组元素或者直接改变数组长度页面也不会更新因为defineProperty对数组索引的劫持成本太高Vue2 干脆放弃了。我在维护一个老项目的时候经常遇到这种场景接口返回的数据结构后端临时加了个字段前端直接this.someObj.newField xxx结果页面纹丝不动。当时大家的解决方案就是初始化时把所有可能用到的字段都预先声明好或者用$set补救。这套心智负担非常重尤其在复杂业务里你永远不知道后端下个版本会给你塞什么字段。除了新增删除属性监听不了Vue2 对嵌套对象的处理也很有问题。它是递归遍历整个对象用defineProperty给每个属性加上 getter/setter这个操作是启动时就一次性完成的。对象层级一深、数据量大初始化就会明显卡顿。而且这个递归过程没法按需触发哪怕某个深层属性压根没被模板用到它照样会被完整劫持一遍。1.2 Proxy 带来的本质改变Vue3 把底层换成了 ES6 的Proxy这不是简单换个 API 的事而是整个响应式模型的范式转移。Proxy可以代理整个对象而不是某个属性所以新增属性、删除属性天然就是可监听的不需要再费劲去调用各种补救 API。更关键的是Proxy的 get/set 拦截是懒执行的。Vue3 的reactive在创建代理时并不会立刻递归处理嵌套对象而是在属性被访问的时候才去建立响应式关系。这意味着一个深层嵌套的巨大对象只有你真正访问到的那些分支才会被代理其他部分都只是原始引用。这个“按需代理”的设计让初始化的开销大幅下降也让真实项目里的响应式性能有了质的提升。我打个比方你就明白了。Vue2 相当于你住进酒店时保洁阿姨把所有房间的门都打开检查了一遍登记了每间房的物品Vue3 则像是装了智能门锁只有你刷开某扇门的时候它才记录这间房的使用情况。同样的对象Vue2 是一进来就全量体检Vue3 是用到哪查到哪。数据量小的时候感觉不到差别一旦数据到几千条甚至上万条差别就非常明显了。1.3 为什么还要有 refProxy只能代理对象对原始类型字符串、数字、布尔值无能为力。但你的业务里肯定有大量const count ref(0)这种场景于是 Vue3 给原始类型设计了一个包装容器这就是ref。ref的实现思路其实很朴素它用一个对象把原始值包起来value属性走的就是reactive的那套依赖收集和触发机制。你写count.value 1本质上就是触发了set拦截然后通知所有依赖它的副作用函数重新执行。有一点需要特别说明如果ref的value是个对象Vue3 内部会直接调用reactive把它变成代理对象所以你用ref存一个复杂对象也有响应式效果只是访问的时候要记得加.value。很多人纠结“到底用ref还是reactive”我的看法很简单能用ref就用ref模板里会自动解包不需要写.value而且ref可以赋值整个对象不怕解构丢失响应式心智负担小得多。reactive更适合那些从接口拿回来的、结构比较固定的数据对象比如表单数据、筛选条件这种。2. 响应式系统的核心链路拆解2.1 三大角色reactive、effect、track/trigger整个响应式系统如果抽象成一句话就是“数据变化自动通知依赖它的函数重新运行”。这个机制里一共有三个核心角色reactive把普通对象变成代理对象负责拦截 get 和 set 操作。effect副作用函数也就是那些要重新执行的函数比如组件的渲染函数、computed的计算逻辑、watch的回调底层都挂在effect上。track和trigger一个负责在 get 时收集依赖一个负责在 set 时触发依赖更新。这三者的配合关系是当你读取一个响应式对象的属性时get拦截器会执行track把当前正在运行的effect收集到这个属性的依赖集合里当你修改这个属性时set拦截器会执行trigger把这个属性收集到的所有effect拿出来依次执行。我画过很多次这个链路图最形象的理解方式是把它想象成一个订阅系统。track是读者订阅杂志trigger是杂志社更新后给所有订户寄新刊。没有订阅的人没有被模板用到的数据永远不会收到邮件不会触发更新这也就是 Vue3 响应式系统精准更新的核心原因。2.2 依赖收集的数据结构长什么样Vue3 的依赖收集不是简单的key: effect[]这样的单向映射它需要支持多级嵌套的响应式对象所以内部维护的数据结构是一个WeakMap嵌套Map的组合targetMap: WeakMap目标对象, Map属性名, Seteffect最外层用WeakMap而不是Map这是为了让响应式对象在不再使用时可以被垃圾回收避免内存泄漏。WeakMap的 key 是弱引用如果对象没有其他引用垃圾回收器可以直接把它收走对应的依赖关系也会随之消失。这个细节面试经常考答案是“利用弱引用机制实现无残留的依赖管理”。内层每个属性对应一个Set正是因为Set天然去重同一个effect即使被多次收集也只会保留一份。比如组件渲染函数里既读了a又读了b这个渲染 effect 会同时出现在a和b的依赖集合里但同一个集合里它只出现一次更新时不会重复执行。2.3 从 get 到 set 的完整流程我用一个非常简单的例子串一遍完整流程你跟着走一遍就彻底通了。假设有这样一个对象和渲染逻辑import { reactive, effect } from vue const state reactive({ count: 0 }) effect(() { console.log(count is, state.count) })第一步reactive({ count: 0 })创建了一个Proxy它的get和set拦截器已经挂好。第二步我们手动调用effect(fn)Vue 会把fn包装成一个“当前活跃的 effect”然后立即执行一次。执行过程中访问了state.count触发了get拦截器。第三步get拦截器里执行track(state, count)。此时框架内部做了三件事在targetMap里找到state对应的Map找不到就新建在这个Map里找到count对应的Set找不到就新建然后把当前正在运行的effect放进这个Set。第四步我们修改数据state.count 1。这触发了set拦截器执行trigger(state, count)。框架从targetMap里找到state对应Map中count的Set把里面所有的 effect 取出来依次执行。第五步effect 重新执行控制台打印出count is 1。至此一次完整的“读取收集、赋值触发”循环就结束了。整个链路里没有任何魔法全是标准的拦截器操作加数据结构管理。理解了这条链路你就能解释 Vue3 官方文档里那句“所有响应式都是基于 Proxy 的访问拦截”到底是什么意思了。2.4 渲染函数与响应式如何联动组件的更新本质上也是靠effect。Vue3 的组件渲染逻辑里模板会被编译成一个render函数这个render函数会跑在一个effect里面。render执行时访问了哪些响应式数据就自动完成了依赖收集数据变化时effect重新执行render重新执行生成新的虚拟 DOM然后走 diff 更新真实 DOM。所以你在模板里写了{{ state.count }}编译后的 render 函数里就有state.count的读取操作。当state.count变化时只有这个组件的 render effect 被触发其他组件即使也在页面里只要没读到这个数据就不会重新渲染。这也是 Vue3 性能优于 Vue2 的一个底层原因——它天然做到了组件级精准更新不需要额外的渲染优化手段。很多新手常犯一个错误以为只要变量在组件里被引用了数据一变组件就会重渲染。实际上不是的必须满足两个条件值变化触发了set拦截器而且这个值在 render 执行时被读取过、依赖关系已经建立。如果你在setTimeout里改了数据但改的是普通变量而非响应式对象或者你读取的是一个被解构出来、已经失去代理关系的普通值组件是不会更新的。3. reactive 和 ref 的深度用法与实战细节3.1 reactive 的代理机制与限制reactive接受一个对象返回一个 Proxy 代理对象。它内部做了一层缓存同一个原始对象多次调用reactive返回的是同一个代理对象如果你把一个已经是代理对象的玩意儿再传给reactive它会直接返回原代理不会二次代理。这个缓存机制保证了响应式关系的唯一性和稳定性。但reactive有一个非常“坑”的底层行为类型必须是对象。你传一个原始类型进去比如reactive(1)它拿到的不是对象就会直接返回原值没有任何响应式效果。这个行为在开发环境会报警告很多人忽视了。另外reactive返回的代理对象和原始对象不是同一个引用。如果你把原始对象存到一个变量里传到子组件或者在函数里直接操作原始对象响应式不会生效。我见过不少线上 bug就是后端返回的 data 被存到了 store 里的非响应式字段渲染时读的还是原始对象。还有更隐蔽的一种reactive对象被 JSON 序列化后再解析回来得到的对象和原代理对象没有任何关系必须重新reactive一次。比如你用JSON.parse(JSON.stringify(state))做深拷贝拷贝出来的对象是普通对象改它不会触发任何更新。3.2 ref 的底层实现与模板解包ref的实现比很多人想象中简单。它本质上是一个类实例里面有_value和_rawValue两个内部字段value的 getter 和 setter 是由类定义的。getter 里调用track收集依赖setter 里调用trigger触发更新同时对嵌套对象走reactive。模板里写count而不是count.value是因为 Vue 的模板编译器对 ref 做了自动解包处理。编译器在解析模板表达式时如果识别到变量是 ref 类型会自动把它转成count.value。但这里有个特别容易踩坑的点模板解包只对顶层属性生效。你写{{ object.count }}如果object本身是reactive的那没问题但如果object是ref且count是普通属性你必须写object.value.count才拿得到值。我记得 Vue3 早期版本里很多人被这个问题坑过改了好久才发现不是自己的代码错了而是解包规则的边界没搞清。数组里的 ref 对象也能自动解包但前提是数组本身是响应式的。const arr reactive([ref(1), ref(2)])模板里写arr[0]拿到的就不是 ref 对象而是 1。这个解包发生在reactive的 get 拦截器里当你访问数组下标时它会自动把 ref 对象解包成值返回。3.3 解构丢失响应性的原因与应对很多人一上来就踩坑从reactive对象里解构出属性修改之后页面不更新。原因很简单解构出来的属性是一个普通值跟原始对象的 getter/setter 没关系了。你在函数里const { count } state; count 1实际上是操作一个局部变量state.count依然是 0自然也不会触发响应。应对方案有几个模板里直接用state.count不进行中间变量赋值这是最简单的。用toRefs把reactive对象的每个属性转成 ref解构出来的每个变量都保持响应式。用toRef针对单个属性只把一个属性单独拿出来作为响应式引用适合作为参数传给组合式函数。干脆用ref管理状态把整个状态塞进一个ref对象里然后访问state.value.count。模板自动解包后写起来也还行。我在实际项目里最常用的组合是一个页面级的状态对象用reactive管理然后配合toRefs解构给 setup 里用避免模板里到处写state.xxx从后端接口拉回来的数据则直接放ref这样后续整体替换数据很方便比如重新请求后data.value res.data不需要一个个字段手动更新。3.4 响应式丢失的典型场景盘点我汇总了一下响应式丢失大概有这么几类都属于“看起来能用实际改了不动”的典型场景场景原因解决方案从 reactive 对象解构属性解构出的属性是普通值丢失了 getter/setter用toRefs或直接访问state.xxx函数参数传入 reactive 属性传的是值不是响应式引用形参修改不会回流传state本身或用toRef包装JSON 序列化/反序列化序列化结果是普通对象与代理关系无关重新reactive或ref包装数组通过下标赋值Proxy 能监听下标但直接arr[i] val要数组本身是响应式的用reactive包数组或ref包数组Object.freeze冻结对象冻结后的对象 Proxy 拦截器无法正常修改不要 freeze 响应式对象每次排查“改了数据页面不更新”的问题我都会优先按这张表对照一遍基本十有八九能命中。4. computed、watch 与读写拦截的进阶玩法4.1 computed 的懒计算与缓存机制computed底层就是effect加上一个缓存标志位。它有两个关键特性懒计算和缓存。懒计算的意思是只有当你访问computed的.value时它才会真正执行计算函数如果一直没人读它就算依赖的数据变了一百遍计算函数也不会执行。缓存的意思是依赖的数据没变化时多次访问.value不会重复计算直接返回上一次的结果。这两个特性决定了computed适合做那些计算成本较高、但不一定每次渲染都用到的派生状态。我举个经典例子一个列表数据用户可能根据关键词过滤也可能按价格排序。这种派生数据的计算逻辑如果写在模板里每次渲染都跑一遍写在 method 里的话需要手动调用用computed则能自动管理依赖且过滤结果只在数据变化时才重新计算性能优势在数据量大时非常显著。有个面试必考点computed能不能修改直接computedVar.value xxx会警告。但你可以给computed传一个带get和set的对象实现“可写计算属性”。比如一个全选 checkbox它的值由列表项的选中状态派生出来而设置它时应该把所有子项的状态统一改掉const allChecked computed({ get: () todoList.every(item item.checked), set: (val) { todoList.forEach(item item.checked val) } })get是数据派生逻辑set是反向写入逻辑。这个模式在表单联动、权限控制这类业务里非常实用。4.2 watch 与 watchEffect 的行为差异watch和watchEffect是两个经常被搞混的 API它们的底层都是effect但使用方式不同watch必须显式指定监听的数据源并且只有数据源变化时才触发回调。它默认是懒执行的——也就是你写watch(state, callback)只有state变化时才调用 callback初始化不会执行。watchEffect不需要指定数据源它会立即执行一次执行过程中访问了哪些响应式数据就自动监听哪些数据。之后任何被监听的数据变化都会重新执行这个函数。实战中的选择逻辑很简单如果你关心“某个数据变化后做什么事”比如监听路由参数变化重新拉取数据用watch如果你关心“基于当前数据做一次副作用”比如把当前筛选条件立刻同步到 URL 参数里用watchEffect。watch还有一个隐藏特性值得注意它的回调可以拿到变化前后的值。这在需要比较新旧数据做逻辑判断时非常有用比如监听用户输入判断是否要弹出提示、监听商品数量变化计算优惠等。watchEffect拿不到旧值因为它连数据源都不需要指定自然无法告诉你是哪个数据变了。4.3 自定义响应式readonly 和 shallow 系列readonly返回一个只读代理任何修改操作都会被拦截并警告。它的实现和reactive几乎一样只是 set 拦截器里做了判断直接返回 false 并提示。这个 API 主要用于保护需要传给子组件但禁止子组件修改的数据比如全局配置、后端返回的常量数据。shallowReactive和shallowRef则是“浅响应式”系列。它们只代理第一层深层对象不会被转换为响应式。这两兄弟的性能更好因为无需深层递归代理但代价是深层数据变化不会触发更新。它们适用于那些“只替换整个对象、不关心内部字段变化”的场景比如接口返回的大对象整体替换。我一度觉得shallow系列没什么实际用途后来做监控大屏项目时意外发挥了作用。大屏上有上千个点位数据每个点位有几十个属性如果全部做成深层响应式初始化耗时能明显感知到。后来改成shallowRef存储原始数据渲染函数只需要知道“数据变了要重新渲染”这个层级点位内部属性更新则交给原生的 canvas 重绘逻辑性能问题迎刃而解。4.4 基于 watch 实现异步数据刷新我在项目里经常用watch来做“依赖参数变化自动刷新数据”的逻辑。最简单的写法watch( () route.query.page, async (newPage, oldPage) { loading.value true try { const res await fetchList({ page: newPage }) tableData.value res.list } finally { loading.value false } }, { immediate: true } )immediate: true表示组件创建时立即执行一次等于初始化数据。这样写有个好处页码变化、组件重建、路由参数恢复三种情况都只要维护这一条监听逻辑不用再单独写初始化的onMounted。这里有个细节你监听的页面变化是异步操作如果用户快速切换好几页请求返回的顺序可能和触发顺序不一致导致数据显示错页。实际项目中我通常会在请求前记录一个自增序号只有最新一次请求的返回值才允许写入tableData前面的过期请求直接丢弃。这种并发防护逻辑虽然简单但能省去大量烦恼。5. 手写一个简化版响应式系统所谓“完全解析”光看还是不够自己写一遍才是真懂。这里我实现一个极简但完整的响应式系统包含reactive、effect、computed三个核心 API代码量控制在 60 行左右每行都可以直接对应到 Vue3 源码里真实存在的逻辑。5.1 核心模块实现// 1. 依赖存储结构 const targetMap new WeakMap() let activeEffect null // 2. 收集依赖 function track(target, key) { 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) } // 3. 触发更新 function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (!dep) return dep.forEach(effect effect.run()) } // 4. 创建响应式代理 function reactive(target) { return new Proxy(target, { get(obj, key) { track(obj, key) // 注意如果是嵌套对象需要递归代理简化版不处理也很说明问题 return obj[key] }, set(obj, key, value) { obj[key] value trigger(obj, key) return true } }) } // 5. 副作用函数 function effect(fn) { const effectFn () { // 执行前把自身挂到全局供 track 收集 activeEffect effectFn fn() activeEffect null } effectFn() return effectFn } // 6. 计算属性 function computed(getter) { let value let dirty true const effectFn effect(() { value getter() dirty false }) return { get value() { if (dirty) { effectFn.run() } return value } } }这段代码去掉了很多边界处理和优化但核心骨架和 Vue3 源码的reactive.ts、effect.ts是对得上的。targetMap对应源码里的targetMapactiveEffect对应activeEffect全局变量track和trigger的名字都是直接从源码里搬的。5.2 一个完整的运行示例用上面的代码跑一个例子验证效果const state reactive({ count: 0 }) effect(() { console.log(count , state.count) }) state.count state.count控制台的输出应该是count 0 count 1 count 2第一次输出是effect立即执行的结果第一次修改触发trigger后 effect 重新执行输出 1第二次修改再触发输出 2。这个例子看起来简单但它完整覆盖了“初始化收集依赖、赋值触发更新、副作用重新执行”这条主链路。你可以在track函数里加一个console.log观察每次读取属性时的依赖收集过程也可以在trigger里打印哪些 effect 被触发了这对理解整个机制非常有帮助。5.3 这个简化版暴露了哪些真实设计问题写完这个简化版你再回去看 Vue3 源码会发现它需要处理很多我刻意忽略的问题嵌套对象的代理真实reactive在 get 时如果发现值是对象会递归调用reactive并返回代理对象。这保证深层访问也能建立响应式关系。唯一代理的缓存同一个原始对象多次reactive应该返回同一个代理对象否则重复代理会导致依赖混乱。真实的reactive用WeakMap做了缓存。数组的特殊处理Proxy 能拦截数组下标但 Vue 源码里对数组的push、splice等操作做了专门处理避免length变化引发多余更新。批量调度与防抖一个数据变化可能触发多个 effect直接同步执行可能导致同一 effect 重复执行多次。真实的调度器会把 effect 放进队列在微任务里批量执行这就是 Vue3 的scheduler机制。Reflect的使用真实代码里用的是Reflect.get/set而不是直接obj[key]这样能保证 this 指向正确在处理继承和 getter 时行为更符合预期。自己动手写完之后再去看源码你会觉得那些“神奇”的代码都变得顺理成章因为你已经知道它为什么要解决这些问题了。6. 常见响应式问题与面试高频考点6.1 页面不更新的经典排查思路每次有人来问我“为什么我改了数据模板不更新”我都会让他按顺序检查这四步第一步先确认这个数据是不是响应式的。如果你是在 setup 外面定义的普通变量或者用const { count } state解构出来的那它并不是响应式的。第二步确认你修改的是响应式对象本身而不是它的副本。JSON.parse(JSON.stringify(...))、数组的map/filter返回的新数组、展开运算符{...state}生成了新对象这些都不是原代理对象。第三步确认修改操作触发了 set 拦截器。如果你用Object.assign(state, { count: 1 })在 Vue2 里不会触发Vue3 里Object.assign会触发属性的 set但如果整个赋值给新对象就不行。第四步确认依赖关系已经建立。组件渲染时如果没读取过这个数据比如数据只写在console.log里那数据变化自然不会触发重渲染。这套排查顺序我用了很久基本能覆盖 95% 的响应式失效场景。剩下 5% 多半是第三方库内部把数据变成了普通对象这种情况只能自己手动用reactive或ref包装。6.2 数组和 Map/Set 的响应式陷阱数组在响应式系统里比较特殊。Proxy 能拦截下标访问和赋值但 Vue 源码对数组的length变化、push/pop/shift/unshift/splice这些变异方法都做了额外处理。简单理解就是你调用arr.push(item)时Vue 会拦截这个方法执行原方法之后再手动触发一次length相关的更新确保数组的响应式行为符合预期。但有一个坑是arr.length 0这种清空操作。在 Vue2 里它是无效的必须用splice(0)。在 Vue3 里修改length会触发更新但这个更新只通知依赖了length属性的 effect不会通知依赖了具体下标的 effect。如果你某个computed是遍历数组计算总价它依赖的是数组本身而不是length那清空数组时它不会重新执行。实际项目里如果需要清空数组最稳妥的方案是直接替换整个数组arr.value []这样所有依赖都能正确感知到。Map和Set在 Vue3 里也是可以响应式的因为 Proxy 能拦截它们的get/set/add/delete等操作方法。但写的时候要小心new Map()那个原始对象不是响应式的你必须用reactive(new Map())包裹。另外对Map的set方法触发更新时Vue 做的逻辑是调用原方法后触发这个 key 的依赖所以读取时要用同样的 key。6.3 ref 在模板、函数参数、v-model 中的应用细节ref在模板里自动解包在函数参数里不会。这两者之间的差异经常导致 bug你在组件里用const user ref({ name: 张三 })模板里写{{ user.name }}没事但如果你把这个 ref 传给一个函数handleData(user)函数里访问的是user.value.name如果你忘了.value拿到的就是整个 ref 对象。v-model和ref配合时有个套路自定义组件里用defineModel或者手写modelValue的 prop/emit。默认情况下ref绑定到v-model时会自动解包和更新所以v-modeluser.name如果user是 ref 对象需要写成v-modeluser.value.name但模板里顶层 ref 会自动解包所以实际上写v-modeluser.name是可以的。这个细节很容易让人迷糊习惯了就好。6.4 Vue3 面试必问的 6 个响应式问题结合搜索热词里很多人问“vue3面试最经典6个问题”我整理了一下响应式领域的高频问题基本跳不出下面这些Q1Vue2 和 Vue3 的响应式原理有什么区别标准答法Vue2 用Object.defineProperty劫持属性Vue3 用Proxy代理整个对象。前者无法监听新增/删除属性数组下标修改也无效后者原生支持这些操作。Vue3 通过惰性代理和依赖收集的精准调度性能更好。Q2reactive 和 ref 的区别标准答法reactive只能代理对象返回的是 Proxy 代理对象ref可以包装任意类型通过.value访问内部对对象类型会走reactive。模板中 ref 会自动解包reactive 不会。reactive解构会丢失响应式ref解构不会。Q3computed 和 watch 的区别标准答法computed基于effect实现缓存和懒计算不能有副作用适合派生数据watch是显式监听的副作用逻辑适合异步操作、数据对比、手动控制是否执行回调。Q4effect 的依赖收集过程是怎样的标准答法读取响应式数据触发 get 拦截器执行 track把当前 activeEffect push 到对应 key 的依赖集合赋值时触发 set 拦截器执行 trigger把依赖集合里的 effect 逐个执行。Q5响应式数据为什么解构后不生效标准答法解构出来的是普通值或普通对象会丢失 getter/setter 和 Proxy 拦截机制。需要用toRefs/toRef保持响应式或者直接访问原对象。Q6如何监听响应式对象的某个深层属性标准答法可以用watch的 getter 形式比如watch(() state.obj.deepProp, cb)也可以用watchEffect自动追踪。这些问题如果你能用自己的话讲清楚说明响应式系统的基本原理已经真正掌握了比死记硬背源码要实用得多。6.5 响应式相关的性能优化实操响应式系统不是越响应越好有时候需要适度“非响应化”来提升性能。我总结了几条实际项目中比较有效的优化手段第一大数组用 shallowRef 或 shallowReactive。如果你有一个渲染后基本不变、只是整体替换的大列表用shallowRef完全可以。模板里读取list.value或自动解包后的list整体赋值list.value newList时触发渲染但列表项内部的属性变化不会触发更新。第二大数据量时避免让每个字段都进依赖集合。如果某个对象有几百个字段但模板只需要其中一小部分可以采用“响应式对象 自定义渲染”的方式。比如 canvas 大屏项目渲染逻辑直接在 effect 外部手动控制响应式数据只做状态标志位把开销降到最低。第三用markRaw跳过代理。如果某个对象不需要响应式但会被嵌套进reactive对象里可以用markRaw标记它让 Vue 的reactive直接跳过它。这能避免一些第三方库生成的大型对象被无意义的代理比如 ECharts 的配置项、地图实例这些。第四按需引入模块。Vue3 是 tree-shaking 友好的如果你只用了ref和computed打包时核心响应式模块会被完整保留但readonly、shallowReactive这些没用到就不会打包。这在开发大型项目时对包体积有实打实的帮助。7. 实战中探索响应式系统的边界7.1 复杂状态管理store 的响应式建模用响应式系统做状态管理时有一个常见误区把整个应用状态塞进一个巨大的reactive对象里。这样的问题是任何模块修改任何字段都会触发所有依赖这个对象的 effect 检查排查困难性能也会逐步退化。我更推荐按业务模块拆分 store。比如一个商城项目购物车、用户信息、商品列表、筛选条件各自独立的 store。每个 store 内部用一个ref或者reactive管理对应域的数据通过组合式函数的形式导出。组件里按需引入副作用范围最小化这也是 PiniaVue3 官方推荐的 store 库的设计思路——把 store 拆成一个个独立的组合式模块。在实际项目中我还会给 store 的 action 抽象成纯函数只负责“计算新状态”不直接修改响应式数据。这样做的好处是修改数据的入口统一收敛在一个地方未来做持久化、日志记录、调试回放都更方便。7.2 与第三方库集成的响应式边界ECharts、Leaflet、Canvas 这一类重型第三方库操作的是它们内部的 DOM 或实例对象不是你自己定义的响应式状态。如果你把第三方实例本身塞进reactive不仅性能差而且很可能因为 Proxy 代理了内部方法导致库的行为异常。正确做法是第三方实例用shallowRef或普通变量存储只有“驱动实例更新的数据”才放进响应式系统。比如 ECharts 图表你存chartInstance用shallowRef选项数据则用ref存起来数据变化时手动调用chartInstance.value.setOption(newOptions)。这样既保证了数据更新的自动化又避免代理 wrapping 破坏了第三方库的内部状态管理。7.3 从 Vue2 项目迁移的常见响应式兼容问题搜索热词里有一条“成熟项目 vue2 能转 vue3”实践下来响应式层面最常碰到的问题就是this.$set的替代、Vue.observable的替代以及this.xxx语法到组合式 API 的转变。在 Vue2 里你到处写this.$set(a, b, c)来新增属性Vue3 里完全不需要了。直接a.b c就能触发更新。但有个前提a必须是reactive或ref包裹过的。如果你在 setup 外面定义一个普通对象再往里面塞属性那肯定不生效。Vue.observable在 Vue3 里可以直接用reactive替代行为几乎一致。如果你迁移的代码里用了this.$root、this.$parent这种跨层通信响应式层面不受影响但语法上要改成 provide/inject 或者组合式函数这也是迁移工作中比较繁琐的一部分。另外还要注意 Vue2 的data选项在 Vue3 里虽然还能用选项式 API但它内部也被reactive包了一层。如果你用选项式 API 写data() { return { count: 0 } }模板里{{ count }}的响应式逻辑和组合式 API 完全不同底层虽然都是响应式系统但依赖收集的建立时机和 effect 的调度略有差异。如果你在迁移过程中发现某些“在 Vue2 里能更新”的写法在 Vue3 里失效了优先怀疑是不是响应式代理没有建立起来。我自己经历过一个比较大的 Vue2 迁移项目最深刻的体会是迁移不是机械地把this.xxx改成xxx.value而是要重新理解“数据在哪里被读取、在哪里被修改、依赖关系如何建立”这套新的心智模型。很多人迁移后代码看着是 Vue3 语法实际写出来的响应式逻辑还是 Vue2 的习惯遇到问题自然一脸懵。响应式系统这套东西你能写出多少代码、能讲出多少原理直接决定你对 Vue3 的信心有多足。我见过不少同行靠着背题过了面试但一到线上排查性能问题就露怯。真正的方法只有一个自己动手把简化版的实现写一遍然后在真实项目里用遍布的console.log去验证每一次依赖收集和触发的时机。等你对“数据一变函数就跑”这个过程产生肌肉记忆的时候Vue3 响应式对你来说就不是黑盒了而是手里一个可以任意拆装和定制的工具。以后遇到再奇怪的 bug你也不再害怕——因为你已经知道底层的每一步是怎么回事了。
返回列表