尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

从零实现轻量级响应式状态管理:Proxy依赖收集与批量更新实战

从零实现轻量级响应式状态管理:Proxy依赖收集与批量更新实战 去年做跨端项目时我们内部需要一套不依赖大框架的状态管理核心简单一个代号叫“rea”——取自Reactive的前三个字母。它可以理解为一个轻量级响应式状态管理模块只做“对象变了依赖它的东西自动更新”这一件事。如果你写过React、Vue应该能立刻感受到这东西的价值不需要引入几十KB的运行时不需要学习和记忆一堆概念就能在任意JS环境里获得响应式能力。适合想彻底搞懂响应式原理的前端开发者也适合需要在原生项目、小程序或Node脚本里快速接入状态管理的朋友。这篇文章我会把rea从零实现一遍不是简单贴代码而是把设计思路、为什么这么做、以及我实际踩过的坑都记录下来。你能看到Proxy代理如何拦截读写、依赖收集怎么避免重复、computed的缓存与失效、批量异步更新怎么保证性能。只要认真跟一遍你会对市面上所有响应式框架的内部机制有非常清晰的理解。1. 项目定位为什么要自研一个轻量级响应式模块1.1 重框架带来的隐形成本很多人觉得反正Vue、React都内置了响应式或状态管理方案直接拿来用不就行了但对做基础架构的同学来说每次把一个大依赖塞进项目就得考虑它的体积、更新策略、周边生态的兼容性以及它和你业务模型的匹配度。说一个场景我们有一个自研的轻量级组件渲染层运行环境很受限不能依赖DOM相关的库也没有完整的框架运行时。这时候想给组件加状态引入Vue太重、引入Redux又太机械MobX倒是灵活但它的Proxy拦截策略和历史包袱也不少。于是“rea”就定位成一件事用最少的代码解决“数据变更后自动通知依赖方更新”这个核心问题。1.2 rea要解决的三个核心问题响应式设计里最绕不开的就是三个问题如何知道数据变了、如何知道谁依赖了数据、如何让依赖方优雅地响应变化。对应到实现上就是三个子系统代理拦截、依赖收集、调度更新。代理拦截解决“如何知道数据变了”通常用JavaScript的Proxy对象就能搞定。依赖收集解决“谁在依赖”核心是维护一张“数据属性到依赖它的函数”的关系表。调度更新解决“怎么通知”既要保证同步触发也要考虑批量合并避免无意义的重复执行。这三个问题拆开后你会发现它就该是一个小而美的模块不依赖框架、不依赖Babel插件纯JS就能写。这也是rea存在的最大理由把复杂的东西拆回基本面然后做得足够可靠。1.3 与Vue、MobX方案的功能对照这里我拿rea的设计目标与其他方案做个直观对比功能点reaVue响应式MobXRedux依赖体积极小单个模块框架自带较大中等响应式粒度属性级属性级属性级整棵树异步批量更新支持支持支持无学习成本低中中中高外部依赖无无无有这张表不是说哪个最好而是强调一点在很多轻量场景里你需要的不是全家桶而是一个用起来不啰嗦、出问题能一秒定位的响应式内核。rea就是奔着这个目标去的。2. 核心设计思路响应式的本质拆解2.1 从普通对象到响应式对象拦截读写如果你有一个普通对象想让它在读写时被感知第一反应就是重写属性的get/set方法。早期Vue 2就是这么做的但Object.defineProperty有一个明显的天花板——只能拦截已存在的属性新增或删除属性时无能为力数组索引操作也有兼容性坑。所以rea直接采用Proxy。Proxy可以拦截整个对象的任何操作包括新增属性、删除属性、读取属性、函数调用等覆盖面广得多。核心步骤是这样的先传入一个原始对象用Proxy套一层在读取任意属性时触发依赖收集在写入任意属性时触发更新通知。这里有个容易忽略的点Proxy是惰性代理嵌套对象只有被访问到的时候才会继续创建代理而不是一口气递归代理整棵树。这带来两个好处一是初始化速度快二是不会对未用到的深层数据产生多余开销。但副作用也随之而来后文我会详细说它带来的深层对象代理一致性问题。2.2 依赖收集谁在依赖谁依赖收集是整个响应式系统的灵魂。假设有个函数effectA读到了对象obj的name属性那么我们就应该建立“name属性 - effectA”这条关系。当name变化时只通知effectA执行而不通知别的无关函数。具体实现需要一个全局的“当前正在执行的副作用函数”变量。在effect函数内部先把当前函数挂到全局然后立即执行一次函数体。执行时会触发属性的get拦截get拦截里发现“当前有活跃的effect”就把这个effect登记到对应属性的依赖集合里。这个设计其实特别像事件监听get动作是“订阅”set动作是“发布”。订阅时记录下来发布时把记录的通知一遍。只要把这个映射关系处理好响应式就成立了一大半。我建议你画一张这样的关系表来辅助理解数据属性订阅者集合obj.nameeffectA, effectBobj.ageeffectCarr[0]computedD关键点是同一属性被多个effect读取就会被收集进多个订阅者同一effect读取多个属性则会出现在多个属性的订阅者集合里。这种多对多的关系要处理干净否则容易出现“改了某个数据不该更新的函数白白执行”的性能浪费。2.3 computed与watch的职责划分很多人把computed理解为“有缓存的函数”这没问题但它其实是一个叠加在依赖收集之上的机制。computed内部其实就是一个effect读取多个响应式源后计算出一个值。不同点是它要缓存这个值只有依赖源发生变化时才会重新执行计算函数。watch更像是显式订阅你自己指定一个数据源然后当它变化时执行一个回调。computed适合“从现有状态推导出新状态”watch适合“状态变化后的副作用操作”比如发请求、写存储、操作DOM。rea在这层设计上把两者分开都基于同一个底层的effect机制但各自维护自己的生命周期。这样代码不会重复语义也清晰。后面我会把具体代码写出来就很容易看明白这层关系。3. 实操过程从零实现rea核心模块3.1 整体API设计动手写代码前先把API面固定下来。rea对外暴露的核心方法其实只有四个const { reactive, effect, computed, watch } require(./rea)其中reactive负责将普通对象转成响应式代理effect是注册副作用函数的基础方法computed在effect之上加缓存watch把数据源变化映射到用户回调。我不能一上来就堆代码先把调用方式写清楚后面实现才有目标。先设计一个最简单的使用流程const state reactive({ count: 1 }) effect(() { console.log(count is, state.count) }) state.count 2 // 控制台输出count is 2这个流程看起来简单但它囊括了完整的“订阅-发布”链路。后续所有高级API都是这条链路的变体。设计API时我应该特别注意两点一是返回值自然直观二是类型语义明确。reactive接收对象返回代理对象effect接收函数返回一个停止函数computed返回一个带value属性的对象watch返回取消观察的函数。3.2 先实现依赖收集的基础结构我先定义一张依赖存储表。存储结构选择WeakMap有一个很重要的原因WeakMap的键是弱引用当原始对象不再被使用时不会因为我们的依赖表还存在而阻止垃圾回收避免内存泄漏。const targetMap new WeakMap() 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) } if (!dep.has(activeEffect)) { 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) return const effectsToRun new Set(dep) effectsToRun.forEach((effectFn) { if (effectFn ! activeEffect) { effectFn() } }) }这个结构是核心中的核心。track负责把当前活跃的effect加入对应属性名key的依赖集合里trigger负责取出这个集合并依次执行。这里用Set存储订阅者天然去重避免同一个effect被同一属性重复收集。activeEffect是一个全局变量指向当前正在执行的effect。它会在effect函数内部被设置和还原。这个全局变量看起来有点不够“优雅”但它就是Vue内部跑了几年的成熟模式简单有效。3.3 reactive代理实现接下来通过Proxy实现reactive的核心拦截逻辑。读取属性走track修改属性走trigger新增属性和删除属性也走trigger因为它们都可能影响依赖方。const reactive (target) { return new Proxy(target, { get(target, key, receiver) { const result Reflect.get(target, key, receiver) track(target, key) return result }, set(target, key, value, receiver) { const oldValue target[key] const result Reflect.set(target, key, value, receiver) if (oldValue ! value) { trigger(target, key) } return result }, deleteProperty(target, key) { const hadKey key in target const result Reflect.deleteProperty(target, key) if (hadKey) { trigger(target, key) } return result } }) }写到这里有几个细节要注意。第一get返回结果前必须track但如果返回值是对象应该递归变成响应式代理否则深层属性无法被跟踪。第二set里最好先对比旧值和新值值没变就不要触发更新这个优化能够避免很多无意义的函数执行。第三不管是通过receiver还是target去读取track时键名是相同的但嵌套场景下需要处理receiver以应对原型链读取的情况这个后面我会单列一节讲。至于嵌套对象代理我的做法是在get里加一个判断如果返回值是非null的object就递归调用reactive再返回。但这里有个坑需要提前说明每次get到同一个对象时如果每次都重新reactive一次会产生不同代理对象导致引用不相等。解决办法是用一个缓存Map或WeakMap把已经代理过的对象缓存起来保证同一个原始对象对应同一个代理对象。这个细节很关键否则computed缓存和依赖比对都会出bug。3.4 effect实现与重复执行的坑effect的基础实现如下let activeEffect null const effectStack [] function effect(fn) { const effectFn () { try { effectStack.push(effectFn) activeEffect effectFn cleanup(effectFn) return fn() } finally { effectStack.pop() activeEffect effectStack[effectStack.length - 1] } } effectFn.deps [] effectFn() return () { cleanup(effectFn) activeEffect null } } function cleanup(effectFn) { for (let i 0; i effectFn.deps.length; i) { const dep effectFn.deps[i] dep.delete(effectFn) } effectFn.deps.length 0 }这里很重要的一点是执行fn前先cleanup把当前effect从所有依赖集合里删除再随着fn的执行重新收集。为什么必须这样因为副作用函数里可能有条件判断比如“当flag为true时读取a否则读取b”。如果第一次执行读取了a并注册依赖后来flag变化导致第二次执行只读取b而a的依赖集合里仍然残留这个effect那么将来a变化时这个effect仍然会被触发这就是典型的“遗留依赖导致多余执行”。cleanup加重新收集可以保证依赖表的动态收敛到实际读取的那些属性上。effectStack是用来处理嵌套effect的。函数A内部调用函数BB内部又读响应式数据这时B执行完成之后activeEffect必须还原到A。用栈来管理这个执行上下文是最直观的做法。一开始我只用单个activeEffect变量没有栈结果嵌套effect场景下依赖全部收集到最内层函数上排查了很久才发现问题。3.5 computed的实现缓存与失效computed不是简单函数它是一个“有惰性求值的响应式订阅者”。如果依赖的源没变多次读取computed.value不会重复计算。为了实现这个效果需要给computed设计两个状态标记脏值标记和值缓存。function computed(getter) { let value let dirty true const runner effect(() { value getter() dirty false }) return { get value() { if (dirty) { runner() } return value } } }这段代码是computed最精简的形态但直接使用会出现一个问题如果依赖源变了effect会重新执行value会被重新计算并置为最新值但dirty要如何同步更新关键在于依赖源变化时effect的直接执行会把dirty设为false但此时我们想让“读取computed.value时重新计算”逻辑正好反了。实际做法是改造effect让调度器可以定制“依赖变化时做什么”。具体来说effect函数可以接收第二个参数options支持一个scheduler函数。当依赖变化时不会立即执行副作用函数本身而是调用scheduler由它决定下一步动作。对computed来说scheduler做的事就是标记dirty为true表示“缓存已过期”。真正重新计算要等到有人读取value时才发生。这既保证了缓存的有效性也避免了过早计算。代码如下function computed(getter) { let value let dirty true const runner effect(getter, { scheduler() { dirty true } }) return { get value() { if (dirty) { value runner() dirty false } return value } } }这里的runner就是被改造的effectFn它返回getter的执行结果。dirty为true时调用runner计算出新值并缓存之后再次读取直接返回缓存值直到依赖源再次变化把dirty重新标记为true。这个“脏值检查”模式是很多响应式系统的通用解法理解了它再看任何框架的computed实现都会觉得眼熟。3.6 watch实现显式订阅数据源watch与computed的区别在于它不关心返回值只要求在数据变化后执行回调。watched的数据源可以是一个响应式对象属性也可以是一个getter函数。实现思路是把getter作为副作用函数包装进effect里然后在scheduler中调用用户传给watch的回调并把新旧值都传出去。function watch(source, cb) { let getter if (typeof source function) { getter source } else { getter () source } let oldValue let newValue const runner effect(() getter(), { scheduler() { newValue runner() cb(newValue, oldValue) oldValue newValue } }) oldValue runner() return () stop(runner) }这个实现里我第一次运行时就把runner执行了一次拿到旧值存起来。后续一旦依赖源变化scheduler被调用再次执行runner拿到新值然后调回调。watch返回一个停止函数调用它就把当前effect从所有依赖集合中移除数据源后续变化不再触发回调。有一个细节容易忽略getter函数里读取的响应式数据变化时effect触发依赖更新是通过scheduler完成所以watch的回调不会同步在赋值语句后立即执行而是走调度器。如果你希望它异步批量执行就需要在scheduler外面再加一层任务队列。rea初期版本是同步派发后来为了订阅多个状态只回调一次才加了批量调度我在后文单列一节讲。3.7 调度器与批量异步更新假设界面上有三个地方分别依赖同一个状态你修改一次状态它们应该都更新但不应该重复三次渲染。更合理的做法是把被触发的effect收集到一个队列里然后用微任务或宏任务异步执行同一轮循环里即使多次触发同一个effect也只执行一次。这其实和Vue的nextTick思路很像。我实现了一个简单的批量调度器const jobQueue new Set() let isFlushing false const p Promise.resolve() function queueJob(effectFn) { jobQueue.add(effectFn) if (!isFlushing) { isFlushing true p.then(() { jobQueue.forEach((fn) fn()) jobQueue.clear() isFlushing false }) } }在effect的options里配置scheduler为queueJob之后每次依赖变化不会立即执行effect而是把effect放入队列等当前同步代码执行完再统一执行。这带来的性能提升非常明显尤其是高频修改数据的场景比如拖拽、动画、实时输入。有一种常见误解是“异步更新会让页面响应变慢”实际恰恰相反同步执行多次更新是重复做无用功合并成一次反而让整体耗时更短。微任务批量的思路在rea依赖触发层做一次在渲染层再做一次两层配合可以实现帧级更新控制。4. 工程化打磨让rea真正可用4.1 深层对象代理的一致性前面提到了深层对象代理问题。第一次实现reactive时我在get里递归代理返回但没有缓存代理关系。于是一段代码里两次读取同一子对象竟然拿到了两个不同代理computed缓存比较时经常出问题因为引用地址不同。解决办法是用WeakMap做原始对象到代理对象的映射const proxyMap new WeakMap() function reactive(target) { if (proxyMap.has(target)) { return proxyMap.get(target) } const proxy new Proxy(target, handler) proxyMap.set(target, proxy) return proxy }这样反复获取同一对象永远返回同一代理整个系统里对象身份信息保持稳定。在做依赖比对、watcher新旧值对比时不会有“明明是同一个数据却因为代理不同而被误判为变化”的诡异bug。4.2 数组方法的特殊处理Proxy可以拦截数组索引和length的读写但数组方法如push、pop、shift在改动时会同时触发两次甚至多次set。例如push一个元素会触发索引set和length set如果不做处理trigger会执行两次造成重复更新。更麻烦的是push内部会在代理上读取length并写入索引这会在依赖收集阶段产生额外操作。为了避免effect内push自身触发依赖可以在trigger时跳过当前正在执行的effect。这个判断在前面的trigger代码里已经体现就是effectFn ! activeEffect那一行。但还有一个坑数组的迭代方法如map、forEach在遍历时会在代理上读取每一个索引然后这些索引全部被收集进依赖。这本身不是坏事情但如果依赖的effect是渲染函数而渲染函数里遍历了数组那么数组任意索引变化都会触发整个遍历重跑。更精细的做法是把数组的迭代操作集中到一次依赖里或者提供shallow标志让用户决定是否需要对数组每一项做响应式处理。4.3 Map与Set的代理陷阱普通对象的Proxy拦截对Map和Set并不能直接生效因为Map和Set内部使用私有槽位直接代理会报“Illegal invocation”错误。要支持这些内置类型需要拦截它们的get方法把方法内部用到的this指向原始对象。实际做法是在get中判断目标对象的类型如果是Map或Set就给方法调用绑定原始targetif (target instanceof Map || target instanceof Set) { const method Reflect.get(target, key, receiver) if (typeof method function) { return method.bind(target) } }同时拦截get、has、add、set、delete等方法在调用前做track调用后做trigger。rea早期版本没有做这个处理一度在业务里用Set管理选中项列表结果数据更新后视图纹丝不动调试了很久才发现是代理方式不对。这块属于响应式库最容易翻车的地方如果你自己实现建议把Map、Set相关的测试用例写在最前面。4.4 内存泄漏与停止观察effect返回的停止函数需要把当前effect从所有依赖集合中删除这就是cleanup函数做的事情。但这里有个容易忽略的场景如果一个子组件创建了effect组件卸载后没有调用停止函数那么这个effect会一直留在依赖集合里数据变化后照常执行甚至可能访问已销毁的DOM对象。我的习惯是在所有业务封装里把effect和watch的返回值明确暴露出来并且组件的生命周期结束点统一调用。rea本身不做自动垃圾回收因为JS无法感知“你的业务不再需要这个依赖了”所以手动停止是使用者必须承担的责任。好在API设计得清晰返回一个函数语义上非常接近removeEventListener不太容易误用。5. 常见问题与排查技巧实录5.1 数据改了页面没更新这是响应式系统最经典的问题。原因通常是以下几种修改的属性没有被get读取并收集过或者修改的属性是在effect执行之后新增的。第一种情况某个属性在effect运行期间没有被访问那么它和effect之间没有建立依赖关系后续无论怎么改effect都不会重新运行。很多初学者以为只要reactive包裹整个对象就能侦测所有变化其实不对侦测的前提是effect运行期间发生了属性读取读取才会收集。第二种情况新增属性的问题在Proxy时代其实已经解决因为Proxy可以拦截到新增属性的set。但你需要检查新增属性后是否触发了trigger。如果你自己实现reactive时只在set里判断旧值是否存在新增属性时也可能被忽略。排查这类问题我会在get和set里分别打日志看看到底有没有进入track和trigger。这个习惯能节省大量调试时间。如果get从来没被触发说明读取的是未被代理的对象通常是你的原始对象和响应式对象产生了分离。5.2 computed陷入无限循环computed无限循环通常是因为computed函数内部修改了它自己依赖的响应式数据。比如computed(() { state.count return state.count })这个computed执行时读取count会触发依赖收集修改count又触发更新scheduler把dirty置为true下一次读取value时再次执行getter再次修改数据循环往复直接栈溢出。解决办法是明确约定computed必须是一个纯函数内部不允许产生副作用只允许读取不允许写入。如果非要有副作用请改用watch。我在rea文档里把这条写在了最醒目的位置因为它在实际业务中出现频率非常高尤其是从别处迁移代码时很容易写出这种循环。5.3 深层嵌套后依赖丢失当你修改一个嵌套对象的深层属性时发现外层effect不响应。回忆一下reactive的Get拦截逻辑读取时不光要track当前属性还要判断属性值是不是对象若是则返回它的响应式代理。如果遗漏了这层effect里虽然读取了obj.child.name但实际收集到的依赖是child属性的代理没有建立name变化自然无法触发。深层嵌套的依赖丢失还有一种表现明明改了数组某一项的数据页面没更新。本质是完全一样的问题数组下标返回的对象没有正确代理。解决方法是统一回归到“get时返回reactive包装后的对象”并配合proxyMap保证同一原始对象只有一个代理。5.4 响应式对象被JSON序列化后丢失如果把响应式对象直接JSON.stringify输出的结果中嵌套结构可能丢失深层代理属性。这是因为Proxy和JSON序列化机制之间的兼容性序列化时读取到的属性可能是未代理的原始对象。更安全的做法是序列化之前先取原始对象模式代理上保存一个访问原始对象的内部标记。给Proxy对象加上一个不可枚举的内部标记例如通过get拦截识别RAW_KEY属性返回target本身。这样业务代码里可以随时通过state[RAW_KEY]拿原始对象再交给JSON.stringify或结构化克隆彻底避免代理对象在序列化链路中产生不可预期的行为。这是一个非常实战的经验网上聊响应式的帖子很少提到这一点但实际项目几乎必踩。5.5 问题排查速查表现象可能原因排查方向数据改后effect未执行属性在effect中没有被读取过检查get日志和依赖表effect重复执行多次同一属性被多次收集确认cleanup和dep去重是否生效computed死循环getter中产生副作用检查getter对数据的有写操作watch首次不触发未区分immediate需要加options.immediate参数深层对象不响应代理未递归或未缓存代理验证proxyMap与递归逻辑Map/Set操作异常内置方法this指向问题改用bind(target)调用原始方法6. 实战验证与使用建议6.1 结合一个组件的简单示例理论讲再多不如跑一个完整demo来得直观。我写一个最简单的响应式联动示例用rea管理两个状态用户名和一个派生出来的大写用户名。const { reactive, computed, watch } require(./rea) const state reactive({ name: alice }) const upperName computed(() state.name.toUpperCase()) effect(() { console.log(当前名字, upperName.value) }) watch(() state.name, (newVal, oldVal) { console.log(名字从${oldVal}变成${newVal}) }) state.name bob // 当前名字 BOB // 名字从alice变成bob这个示例本身不复杂但它把read链路、computed缓存、watch显式订阅全部串起来了。你用这个demo做基准后续每次改动rea的核心代码都可以立刻测试出有没有破坏原有特性。我把这组测试用例固化下来现在每次改处理逻辑都会先跑一遍效率高很多。6.2 在真实项目中的应用效果我在一个模拟项目中用rea替换了原先基于MobX的状态层最终包体体积减少了约40KB初始化时间明显缩短。因为rea不引入复杂的类型系统、装饰器和各种开发工具链支持整个代码只留下最核心的部分对于追求极致包体和可定制性的团队来说收益十分直观。更关键的是它便于调试。因为代码量小你可以直接阅读rea源码几乎不存在“框架行为不可预测”的问题。有一次团队排查一个状态不同步bug最后发现不是rea的问题而是业务里某处直接修改了原始对象绕过代理。由于代码简洁我们很快就在一次代码走查里发现这个路径换成统一的修改入口后就恢复正常了。6.3 给自研状态库的一些建议如果你也想自己实现一个类似模块我有几点建议先不要追求功能多先把reactive、effect、trigger、track这几个核心概念写通跑通一个最基本例子再逐步加computed、watch、调度器、Map/Set支持。每加一个功能都要确保基础例子仍然成立。另外测试用例不要只写主流程边界条件要写全。比如同一effect嵌套触发、effect在依赖变化后主动停止、computed被多个effect依赖、数组splice后依赖重新收集。这些场景都是线上出过问题的重灾区。提前用单元测试钉住行为后面改动才敢放心大胆。还有一点务必记住响应式状态管理不是越复杂越好。很多项目根本不需要响应式系统里所有的高级特性。能让你的同事在5分钟内理解依赖收集模型并在10分钟内定位状态更新问题就是一个非常好的状态库。rea走到现在我一直控制它的API数量和代码规模目的就是保住这个底线。最后分享一个小细节每次执行react代理之前判断传入对象是否已经被代理过防止Proxy套Proxy。这个问题一开始很容易忽略但一旦出现会表现为依赖收集时targetMap里永远存的是代理对象而不是原始对象导致不同代理共享的依赖失效。加上缓存判断后这个隐患就从根上消掉了。如果未来你需要扩展rea的插件能力不妨从调度器入手给它提供自定义的调度策略入口这会带来很强的灵活性。
返回列表