ARTICLE DETAIL

资讯详情

深耕商务建站与企业官网运营的一线实战洞察。

Vue3响应式系统原理:从Proxy到track/trigger的完整实现与排障

Vue3响应式系统原理:从Proxy到track/trigger的完整实现与排障 做过几次Vue3项目之后你会发现一个现象很多同学能把Vue3的API背得滚瓜烂熟reactive、ref、computed张口就来但一遇到数据明明改了视图就是不动的问题就开始瞎猜乱试。根子上的原因就是对响应式系统只有模糊的感觉没有真正理解它内部的角色分工和数据流向。这篇文章打算把Vue3响应式系统掰开揉碎讲清楚从设计思路、Proxy与Reflect的配合、track和trigger依赖收集机制到reactive、ref、computed的具体实现再到实际项目中我踩过的坑和排查技巧一条线拉通。适合正在学Vue3的人、准备面试的人以及已经被响应式问题坑过几次的开发者。看完之后你不仅能答上面试题还能真正写出符合响应式原理的代码排查问题时也有方向了。1. 响应式系统的整体设计与定位1.1 从Vue2到Vue3为什么要推倒重来Vue2的响应式是基于Object.defineProperty实现的。这个API能拦截属性的读取和写入但有几个绕不开的硬伤对象新增属性不会触发视图更新删除属性也不会数组的索引赋值和length变化只能通过重写数组方法的方式做部分补救。所以Vue2里不得不提供Vue.set和Vue.delete这些额外API来处理边界场景组件里到处都是this.$set写起来不优雅漏写就出bug。Vue3整个换成了Proxy。Proxy代理的是整个对象而不是某个属性因此新增属性、删除属性天然能被拦截数组索引和length也不再需要hack。更关键的是Vue3把响应式能力拆成了reactive、ref、computed、watchEffect等独立的API不依赖组件实例才能收集依赖。Vue2里依赖收集是绑定在组件Watcher上的到了Vue3变成了一个底层通用的effect机制组件更新只是其中一种effect。这个设计让响应式系统可以脱离组件独立存在也为Tree-Shaking、TypeScript类型推导铺好了路。我记得第一次看Vue3源码时最大的感受是它是把响应式当成了一个完全可以独立运行的模块来设计的而不是组件框架的附属品。理解了这一点再看reactive和ref的设计很多困惑都会迎刃而解。1.2 核心设计目标依赖收集与触发更新整个响应式系统可以用一句话概括读取数据时收集依赖修改数据时触发依赖。听起来简单但拆开之后里面有几个关键问题需要解决如何知道当前正在读取数据的代码是谁如何把数据变化和依赖建立映射关系如何在数据变化时精准找到所有相关依赖并执行它们如何避免重复收集、如何处理嵌套effect、如何防止无限循环Vue3的答案是一套三层数据结构WeakMap目标对象, Map属性名, Set副作用函数。最外层用WeakMapkey是目标对象value是对应于这个对象上所有属性的依赖映射中间层是Mapkey是属性名最里层是Set存放该属性关联的所有effect副作用函数。选择WeakMap而不是Map原因很巧妙WeakMap的key是弱引用当目标对象本身不再被任何地方引用时它对应的整个依赖映射就能被垃圾回收不会造成内存泄漏。如果你用普通的Map被响应式包装过的对象即使业务上已经不需要了依赖映射还挂在Map上内存就泄漏了。这一点在长页面、大数据列表的场景下非常关键。中间层用Map而不是直接把Set挂在对象上是因为直接挂载会修改对象自身又会触发响应式拦截形成死循环而且Map按key定位属性的性能也比遍历好得多。2. 核心机制Proxy与Reflect的配合2.1 Proxy拦截能力全面解析Proxy可以拦截13种底层操作Vue3的响应式系统中主要用到了其中5种get、set、has、deleteProperty、ownKeys。get在读取属性时触发是依赖收集的主战场。set在修改属性时触发是触发更新的主战场。has在in操作符检查属性是否存在时触发deleteProperty在delete obj.xxx时触发ownKeys在Object.keys、for...in等遍历操作时触发。后三者解决的是Vue2完全做不到的属性是否存在这个维度的问题。看一个简化但完整的手写reactive核心const targetMap new WeakMap() let activeEffect null function track(target, key) { if (!activeEffect) return let depsMap targetMap.get(target) if (!depsMap) { targetMap.set(target, (depsMap new Map())) } let dep depsMap.get(key) if (!dep) { depsMap.set(key, (dep new Set())) } // 把当前激活的effect加入dep同时反向记录dep方便清空 dep.add(activeEffect) activeEffect.deps.push(dep) } function trigger(target, key) { const depsMap targetMap.get(target) if (!depsMap) return const dep depsMap.get(key) if (dep) { dep.forEach(effect effect()) } }这只是最朴素的骨架真实源码里还得处理迭代key、新旧值判断、effect的scheduler调度等但主干思路就是这样。先理解这个再往源码深处走就不迷路。2.2 为什么必须用Reflect只写return target[key]行不行大多数时候行但遇到访问器属性和继承场景就会出问题。看这个例子const obj { _value: 1, get value() { // this指向谁直接决定能不能拿到响应式属性 return this._value } }如果用new Proxy(obj, { get(target, key) { return target[key] } })来包装当通过代理对象访问value时getter里的this指向的是target原始对象而不是代理对象。如果_value在原始对象上没有响应式包装this._value取到的就是普通值。一旦对象存在继承关系子类实例通过继承的getter访问自身属性时这个this指向问题就更加致命。Reflect.get(target, key, receiver)的第三个参数receiver就是来解决这个问题的。它让getter里的this指向receiver也就是代理对象本身从而保证整个链路都在响应式系统的掌控之中。Vue3源码里对get、set、deleteProperty、has全部使用Reflect对应方法并不是为了炫技而是确保代理对象在语义上和原始对象完全一致。这一点很多手写响应式的教程不会讲但往往是生产环境诡异bug的根源。2.3 特殊对象与标记位处理reactive只处理对象对原始类型值无能为力所以Vue3才吗要设计ref来兜底。此外reactive还有一些内置的特殊处理对Date、RegExp、Map、Set、WeakMap、WeakSet这些内置对象会走专门的collectionHandlers去拦截因为它们的读取和修改多数是通过方法调用而不是属性访问。对带有__v_skip标记的对象直接跳过对只读对象也有单独的readonlyHandlers。我用实际开发中的例子来说如果你在reactive对象里塞了一个Set然后直接set.add(item)这个操作默认是不会触发更新的必须通过Vue3重写过的一层方法调用才能被捕获。之所以能做到就是因为在get拦截时如果发现目标对象是内置集合类型会返回一个包装过的add、delete等方法这些方法内部先执行原始操作再手动调用trigger。这个机制平时不太会被注意到但一旦碰到Set里加了数据视图死活不更新的问题排查方向就对了。3. 依赖收集与派发更新的完整逻辑3.1 track的完整实现与细节真实源码里的track没有我上面写的那么简略它需要处理不同操作类型并且只关心那些可能被追踪的key。比如ITERATE_KEY这个特殊key用来代表for...in和Object.keys这类遍历操作。当对象新增或删除属性时不仅要找到对应属性的dep还要找到ITERATE_KEY的dep一起触发。这就是为什么set拦截在新增属性和修改已有属性时的处理逻辑不一样新增属性要额外触发迭代依赖因为遍历集合的成员数变多了。还有一点容易被忽略track里每个effect会维护一个deps数组作用是在effect重新执行前先把上一次收集到的依赖全部清空再重新收集。这个过程叫分支切换。假设某个条件的值导致effect读不到了某个属性不去清理的话下次触发这个属性时还会执行这个effect造成无意义的re-run。Vue3用了一个cleanupEffect的过程遍历effect的deps数组把effect从每个dep里删掉然后把deps数组置空再重新跑一次effect重新收集依赖。3.2 trigger的完整实现与调度机制trigger要处理的核心问题有两个一是精准找到应该被触发的effect二是控制effect执行的时机和方式。第一点通过targetMap就能定位到具体dep。但set操作还要区分是修改已有属性还是新增属性新增和删除都要额外触发ITERATE_KEY的依赖。还有一个细节是如果对象是一个通过ref包装后暴露出来的对象触发方式会不太一样因为ref的value属性本身就是一个key。第二点涉及effect的options。Vue3的trigger在执行依赖时不会直接无脑调用它会先判断effect是否带有computed标志如果当前正在执行的effect和即将触发的effect有依赖关系比如computed在更新过程中又触发自己的依赖需要通过id大小关系决定是否跳过避免循环更新。还会检查effect是否带scheduler如果带了就把schedule执行机会交给调度器而不是直接执行effect本身。watchEffect的异步批量执行就是依靠scheduler配合Promise微任务队列实现的。简单理解scheduler就是先排队、再执行的调度入口。普通effect直接跑带watch的effect会把更新任务放进一个队列等当前同步代码执行完后统一flush这样多次修改数据只会触发一次视图更新性能收益在复杂组件里非常明显。3.3 activeEffect栈解决嵌套effect的依赖归属什么是嵌套effect最常见的就是computed内部访问了响应式数据而computed又在另一个effect比如组件渲染中被读取。此时computed内部的读取操作依赖应该收集到谁身上如果只用单一的activeEffect变量内层computed执行时会把外层effect顶掉等computed跑完外层effect的激活状态就丢了之后的依赖收集就全乱套了。Vue3的解法是用一个栈activeEffectStack。effect开始执行前把自身push进栈执行结束后pop出来activeEffect始终取栈顶元素。这样嵌套结构就能正确地让内层读取收集到内层effect外层读取收集到外层effect。我在看源码前一直不理解为什么源码里到处是activeEffect ! undefined这种判断看了这个栈的设计才明白它是一个执行上下文的标记和函数调用栈是同一个思路。4. reactive、ref与computed的源码实现4.1 reactive从Proxy到缓存复用reactive的入口其实非常简洁new Proxy(target, baseHandlers)其中baseHandlers就是前面说的那组拦截器。但有一个隐藏设计同一个原始对象多次调用reactive()必须返回同一个代理对象。Vue3用一个reactiveMapWeakMap记录了原始对象到代理对象的映射。第一次包装后存入map后续再调用直接return缓存结果。这个缓存机制除了性能考量更关键的是保证同一个对象在整个应用中只有一个代理身份这样依赖收集才不会重复。baseHandlers的get拦截中还有一层isReadonly判断但核心逻辑是拿到原始值后如果是对象就递归调用reactive继续深层包装对耗时操作Vue3还做了缓存——同一个key多次读取时返回的绑定函数不会重复创建。源码中的cache属性就是干这个的。还有个容易被忽略的现象reactive包装后得到的代理对象再被reactive一次会直接返回同一个代理。这个行为底层就是靠reactiveMap查缓存实现的。4.2 ref为什么需要一个.valueref解决的是reactive无法处理基本类型的问题。它的核心实现是一个类内部用一个_value存值通过访问器属性value暴露读写。class RefImpl { constructor(value) { this._value toReactive(value) // 对象值的化内部用reactive包装 this.__v_isRef true } get value() { track(this, value) return this._value } set value(newVal) { this._value toReactive(newVal) trigger(this, value) } }读.value时track写.value时trigger因为ref实例本身是个对象targetMap的key就是ref实例。设计中值得注意的点是toReactive如果传入ref的值是个对象它内部会用reactive再包装一层。所以ref({})和reactive({})在深层对象上是殊途同归的只是ref多了一层.value的外壳。模板里的自动解包、toRefs、isRef这些API本质都是围绕这个.value设计展开的。模板自动解包其实就是渲染过程中对读取的属性做了一次isRef判断是ref的话就直接取.value用户不需要在模板里手动写.value。4.3 computed惰性求值与缓存computed是基于effect实现的但它有一个dirty标记来控制缓存。第一次读取computed的值时dirty为true执行内部函数计算并缓存结果然后设置dirty为false。之后只要依赖的数据没变每次读取都直接返回缓存值不会重复计算。当依赖的数据被修改触发内部的scheduler把dirty置回true但此时不立即计算等到下一次computed.value被读取时才重新计算。这就是惰性求值。这个机制在复杂计算场景下收益极大。比如一个computed遍历几千条数据做过滤只要依赖数组没变哪怕被模板读取一百次也只会计算一次。如果换成一个watchEffect每次依赖变化都会立刻执行计算开销完全不同。computed还有一个特殊点它自己也是一个effect当它内部访问其他响应式数据时会作为依赖收集进去当它被外层effect读取时又会把自己的计算结果贡献出去。嵌套关系就是靠这个链条建立的。5. 驱动视图更新与异步调度细节讲到这里很多人会产生一个疑问数据变了trigger执行了effect但effect怎么知道要更新组件、更新DOM这就要看componentUpdateFn了。Vue3的组件渲染其实也是一个effect它包着一层render函数render里会读取模板用到的响应式数据从而触发track数据变化时trigger触发这个渲染effect重新执行render。但这里有个重要的调度机制组件更新不是同步立即执行的。Vue3把渲染effect的trigger放在了一个scheduler里更新请求会被推入一个队列再通过nextTick或者微任务批量flush。所以你在一次同步操作里连续改三次数据最终只会触发一次组件更新。这也是为什么watch回调里拿到的DOM不一定是最终状态要操作更新后的DOM必须等到nextTick。生产环境里这个调度机制带来的最典型体验是大量数据在极短时间内的连续变化不会导致渲染层反复震荡。比如你在一个for循环里改了100次某个reactive数组整个过程只会触发一次重渲染。queueJob的执行还有一个优先级细节Vue3会给不同任务的job分配id在下一轮微任务中按id从小到大执行确保父子组件之间更新的顺序是合理的避免一个组件在更新时读到尚未更新的子组件状态。6. 实际项目中的坑与排查技巧实录6.1 解构丢失响应性这个坑面试必问项目里也是重灾区。直接看代码const state reactive({ count: 1, name: vue3 }) // 解构之后count就是普通数字不再响应式 const { count } state state.count 2 // 视图不会更新原因还不明显的话可以往下看一眼解构拿到的count只是一个普通值快照它和state.count这个属性之间并没有维持关系。解决方案是用toRefsconst { count, name } toRefs(state) // 此时count是一个refcount.value始终与state.count保持同步toRefs内部其实就是把每个key包装成一个ObjectRefImpl该类的value访问器里get时读取原始对象对应key的值set时写回。这样解构出来的每个值都还是响应式的。我在项目里统一规定从reactive对象上解构属性一律用toRefs包一层从根上杜绝这类问题。6.2 数组索引与length的触发范围Proxy虽然能拦截数组索引赋值但Vue3对数组的响应式处理有细致边界。直接用arr[10] 1这种方式给一个length为5的数组新增一个下标会触发Index类型操作并且触发的依赖key是10但同时length的变化不会触发length这个key的依赖。如果你的界面是依赖arr.length来展示数量的数字不会同步变。规范的做法是要么直接改arr.length 11这会触发length依赖要么用push、splice这些会同步更新length的数组方法。我自己排查过类似问题界面显示的列表条数是基于computed(() list.value.length)写的但动态在下标上赋值导致列表内容变了总条数纹丝不动。6.3 shallowRef与triggerRef性能优化和强制刷新shallowRef只对.value本身做响应式.value指向的对象的深层属性变化不会被追踪。比如const info shallowRef({ name: vue3, version: 3 }) info.value.name Vue3.4 // 不触发更新但如果你确切知道对象已经变了只是想手动通知视图刷新可以用triggerRef(info)强制触发。源码里triggerRef就是绕开依赖检查直接对该ref做一次trigger操作。这个API在优化超大对象、第三方原生对象比如ECharts实例时很好用但日常业务代码不建议频繁使用它本质上把自动检测降级成了手动通知。6.4 reactive对象直接操作Map/Set的问题前面提过reactive包装的Map和Set需要在get拦截时返回包装方法才能触发更新。实际编码中最常见的错法是state.map.set(key, value)之后界面不更新。这是因为state.map拿到的已经是被拦截过的代理方法但如果这个Map是在reactive外部创建的而你又把原始Map塞进了reactive对象后续直接用原始Map的set方法就错过了拦截层。排查这类问题的万能手段是在trigger函数源码里打断点或者给effect重新执行加log看看数据变化到底有没有走到派发链路。我在内网排障时常用Chrome的Sources面板直接在trigger的实现位置打条件断点条件设成target的key名数据变化后走到这里就停住然后逐步看dep集合里有没有预期effect。这套方法比盲目console.log高效得多。6.5 依赖收集与内存泄漏的常见误解有些同学担心WeakMap只对target弱引用是不是意味着多次创建reactive(obj)会让旧的代理对象无法清理其实不会因为reactiveMap本身持有target到proxy的强引用target在业务中如果没有被释放proxy也会一直被缓存。反过来如果target已经没有任何引用reactiveMap里的条目就会被GC自动回收proxy自然也就没有持有者了。这个设计是深思熟虑的。但需要注意一个实践场景当一个对象不再需要响应式时单纯的delete obj.xxx只会触发更新不会自动从targetMap里移除依赖映射。真正要释放响应式关系只能让对象本身失去引用。如果业务上有长生命周期对象动态注册到reactive容器的需求最好及时清理容器中的引用否则依赖会越积越多。虽然响应式系统本身不会泄漏但业务层不清理引用GC照样无能为力。最后分享两个调试技巧排查响应式问题时我个人的习惯是第一眼先判断数据是基本类型还是对象类型分别对应ref和reactive两条排查路径再判断是没收集到依赖还是触发了但没反映到视图。前者去track里看dep集合后者去effect的调度队列里看job是否正常执行。还有一个对我帮助很大的技巧在组件setup里临时挂一个全局变量比如window.__state state然后直接在控制台里对state赋值观察页面是否实时更新。这样一来就能快速区分数据真的没变还是响应式链路断了。控制台里看到的reactive对象值变化时你会看到它显示为Proxy包裹状态这一点本身也能帮你确认对象到底有没有被正确包装。响应式系统并不神秘核心就是读时收集、写时触发这八个字。理解了track、trigger、effect这三者的闭环再看Vue3的其他API你会发现它们都是在基本盘上做的封装和优化。希望这篇文章能让你在遇到响应式问题时少一些猜测多一些底气。
返回列表
PREV
查看更多资讯
NEXT
返回资讯列表