手写响应式状态管理核心:基于Proxy的依赖收集与派发更新 1. 从三个字母说起Rea 到底想解决什么问题如果你在某个交流群里扔出rea三个字母大概率会得到一屏问号。名字确实起得很随意坦白说当时项目文件夹里躺着几十个类似 rs、fx、demo_web 这样的命名rea 是从一个更长的词里截出来的全称是 Reactive Architecture 的缩写后来用着用着团队里的人干脆就喊它 rea 了。rea 本质上是一套轻量级的前端状态管理方案核心思路是响应式数据流。你要问它解决了什么问题最直接的答案是让数据变了界面跟着变这件事不再靠手动去管而是由框架底层自动完成。我见过太多的业务代码组件一多状态就乱了。A 页面改了某个字段B 组件的按钮还得手动调一下刷新方法列表数组 push 了一条数据表格没反应排查半天发现是引用没变视图监听不到多人协作时每个人都往全局 store 里塞自己的状态到最后没人敢动那块共享数据。这些问题本质上不是开发者的错而是手写状态同步这件事本身就违反直觉——人的大脑不擅长维护两个地方的数据要保持一致这种隐式约定。rea 的思路很简单把状态集中起来用响应式机制去订阅和派发变化。你只管改数据至于谁依赖了这份数据、什么时候该重新渲染都由 rea 负责通知。听起来和现有的某些状态库很像对吧区别在于rea 更轻内核只有几百行代码不引入任何额外的运行时框架依赖而且它的核心是用原生 Proxy 实现的底层的响应式原理可以完全掌控遇到奇怪的问题不至于被第三方黑盒卡住。这篇文章适合两类人看一类是写前端写了两年左右、被各种状态管理方案搞得不太自信的开发者另一类是喜欢研究源码、想亲手搭建一套响应式系统的同学。我会把 rea 从设计思路到具体实现完整拆开包括踩过的坑、调过的参以及它后续还能怎么扩展。2. 整体设计与核心思路拆解2.1 为什么放弃手动同步而选择响应式先回到一个基础问题前端状态管理到底在管什么从本质上讲前端应用就是一个函数UI f(state)。state 是数据UI 是你看到的界面f 是渲染逻辑。理想情况下state 变了UI 自动重新计算这就完事了。但实际工程里f 不是纯粹的函数它包含很多副作用请求数据要改 loading 标识、用户操作要同步到服务端、组件之间还要共享登录信息或筛选条件。于是我们搞出了各种状态管理工具它们大致分两类命令式同步和响应式同步。命令式的代表是老式的 event bus、手动 setState 广播。它的典型写法是// 命令式数据变更后手动通知所有关联方 data.name 张三; updateUserNameLabel(data.name); updateHeaderInfo(data.name); updateSearchList(data.name);这种写法的问题一眼就能看出来每当数据变化你都得记得把所有依赖它的地方找出来手动更新。可人的记忆是会出错的而且随着业务增长这个手动更新清单会越来越长最终变成没人敢碰的雷区。响应式则完全不同。它的核心思想是声明依赖关系让系统自动维护更新。你用的时候声明我依赖了 name 这个字段内部会自动记录下来下次 name 一变依赖它的地方自动收到通知。用自己的话说命令式是数据改了你得自己去喊所有人开会响应式是数据改了它自己召开广播谁关注谁回应。后者的心智负担小太多尤其适合数据联动复杂的场景。2.2 rea 的架构分层与模块规划rea 在设计时把整体拆成了三个独立层每一层只做一件事彼此通过接口通信避免耦合后难以维护。第一层是响应式核心层reactivity。这一层不关心业务只负责三件事把普通对象变成响应式对象、收集依赖、触发更新。它对应的是 rea 最底层的 reactive、effect 这些函数。你可以把它理解成响应式系统的心脏所有上层能力都构建在这颗心脏上。第二层是状态容器层store。这一层在响应式核心之上提供 createStore 之类的 API让业务开发者可以定义全局状态、派生状态和修改动作。它解决的是数据放在哪、怎么改的问题同时支持模块划分避免所有状态都挤在一个全局对象里。第三层是视图绑定层bindings。这一层负责把响应式状态接入到组件生命周期中。在 rea 的 React 适配里核心是一个叫 useRea 的 Hook它让组件可以订阅 store 中的某一部分数据并在数据变化时自动触发组件重渲染。三层依赖方向是单向的视图绑定层依赖状态容器层状态容器层依赖响应式核心层。这样设计的好处是如果某天你需要换一个 UI 框架比如从 React 换到 Vue只需要重写第三层核心逻辑完全不用动。团队里后来做小程序适配时确实只改了这一层省了大量时间。2.3 对比主流方案为什么 rea 选择 Proxy 路线在响应式实现上现在主流的路线无非三种defineProperty 拦截、Proxy 代理、以及主动脏值检查类似 React 的 setState。React 的 setState 方案本质上是一种手动触发加载你调用 setStateReact 就重新执行组件函数然后通过虚拟 DOM 对比找出差异去更新真实 DOM。它的优点是思维模型简单缺点是粒度太粗——你把整个组件函数跑一遍虽然最终只改了一个文本节点但中间的计算开销并没有省掉。defineProperty 是 Vue 2 时代的选择它能拦截属性的读取和赋值但有两个先天缺陷新增属性需要额外调用 set 方法才能触发响应数组索引操作也比较难拦截必须通过改写数组方法来实现。这些问题不算致命但确实让实现包袱变重。Proxy 则完全不同。它是 ES2015 提供的原生能力直接代理整个对象不管是新增属性、删除属性、还是读取不存在的 key都能被捕获。代码写起来更干净也不用为数组专门做一套特殊处理。rea 选择 Proxy 的另一个原因是前瞻性现代浏览器对 Proxy 的支持已经很稳定而且它背后有持续的性能优化长期看路更宽。为了验证这一点我专门用一个小页面测过几万条数据的数组Proxy 的读写开销在可接受范围内相对 defineProperty 没有任何劣势。3. 核心细节解析与实操要点3.1 响应式核心依赖收集与派发更新的完整链路响应式系统里最核心的机制是依赖收集和派发更新圈内习惯把它们合称为依赖追踪。理解了这个机制你就理解了 rea 的核心原理。先说依赖收集。假设你有这么一段代码const obj reactive({ count: 0 }); effect(() { console.log(count 的值是, obj.count); });在 effect 执行时它内部访问了 obj.count触发了 Proxy 的 get 拦截。此时get 拦截器会记录当前正在运行的 effect 函数依赖了 obj 的 count 属性。这个当前正在运行的 effect是通过一个全局变量来标记的在执行 effect 之前把它设置好执行完之后再清空。原理就像一个聚光灯灯照到哪里哪里就被登记为依赖。再说派发更新。当你执行obj.count时触发了 Proxy 的 set 拦截。set 拦截器会查一下 count 这个属性身上挂了哪些 effect 函数然后一个个重新执行。这就是最基础的响应式闭环。不过仅做到这一步还不够。如果 set 的是旧值呢比如 count 本来就是 0你再赋值为 0其实没有变化不应该触发更新。所以 set 拦截器里必须做一次值比较。比较的时候还有一个细节容易踩坑如果旧值是新值都是 NaN直接oldVal ! newVal会认为二者不同因为 NaN ! NaN 恒成立导致多余的更新。我们当时做了一层特殊的 NaN 判断这个细节在测试里很容易漏掉但在生产环境确实会遇到。用一个生活类比来理解响应式链路reactive 对象是广播电台effect 函数是收音机。get 拦截时收音机在某个频道上登记了自己的收听频率set 拦截时电台向这个频率发射新信号收音机自动播报新内容。你不必手动去按收音机的按钮只要信号变了它自然会响。3.2 深层对象与数组的响应式陷阱处理理论讲完直接上实践中最容易出问题的两块嵌套对象和数组。嵌套对象指的是这样的场景const state reactive({ user: { name: 张三, settings: { theme: dark } } }); effect(() { console.log(state.user.settings.theme); }); state.user.settings.theme light; // 应该触发更新注意第一次读取 theme 时访问链路是state-user-settings-theme。在 get 拦截中当你返回state.user时它本来是个普通对象如果直接把它抛出去后续的.settings.theme读取就绕过了 Proxy 拦截依赖收集自然断了。解决办法很经典懒代理。在 get 拦截器中如果被读取的属性值是一个对象就把这个对象也转换成 reactive 代理并缓存起来。下次再读取同一属性时直接返回缓存的代理结果。这样无论层级多深每次访问都会经过 Proxy依赖就永远不会断。数组的处理相对简单因为 Proxy 天然能拦截数组的索引访问和 length 变化。存取、push、pop、splice 等方法也都走 Proxy 的 get/set 拦截。唯一要注意的是性能细节像arr.map(x x * 2)这种操作会读取 length 和每一个索引如果数组非常庞大频繁的 get 拦截会带来一定的开销。rea 的做法是提供一个标记位在遍历这类纯读操作时跳过不必要的依赖收集实测对大数据列表有明显改善。另一个容易忽略的问题不要在 effect 里改自己依赖的数据。例如effect(() { state.count; });这段代码执行逻辑是读取 count 触发依赖收集 - 自增触发 set - set 发现 count 有依赖重新执行 effect - 又读取 count又自增……无限循环直到浏览器崩溃。rea 对此做了同一次派发更新的 effect 去重机制确保同一个 effect 在一次更新中只执行一次但即便如此这个例子本身也是一种逻辑死循环。团队里的 A 同学曾经在业务代码里不小心写了类似的结构排查了很久才发现。这属于数据更新链路里的经典自旋陷阱不是 rea 特有所有响应式库都会遇到。3.3 批量更新与调度机制性能的关键拐点如果你有一个 effect 同时依赖了三个数据字段然后在同一帧里连续修改这三个字段你肯定不希望 effect 被执行三次。理想的情况是无论数据改了多少次effect 只跑最后一次。这就是批量更新机制的用武之地。rea 的思路是当 set 拦截触发更新时不立刻执行 effect而是先把它丢进一个微任务队列里。在同一轮事件循环中即使触发了十次 seteffect 也只被推入队列一次最后统一执行。具体实现上需要一个待执行队列和一个去重集合// 伪代码调度器核心思路 const pendingEffects new Set(); let isFlushing false; function queueEffect(effectFn) { pendingEffects.add(effectFn); if (isFlushing) return; isFlushing true; Promise.resolve().then(() { pendingEffects.forEach((fn) fn()); pendingEffects.clear(); isFlushing false; }); }Promise 的微任务特性让宏任务先执行完、所有 effect 堆积完再统一处理变成可能。实际开发中你连续修改十个状态字段界面只会重渲染一次而不是可怜地闪十下。React 18 的自动批处理也类似但 rea 是在自己的调度层面完成这步不依赖 React 的实现。还有一点需要注意微任务和浏览器的渲染时机是有先后关系的。微任务会在当前宏任务结束后、渲染之前执行。这意味着 effect 中读取状态并操作 DOM是发生在浏览器重新绘制之前的。这个时序保证了我们在 effect 里做的 DOM 修改变更不会闪屏。4. 实操过程与核心环节实现4.1 从零搭建 rea 的最小可运行版本这部分是干货中的干货。我直接贴一段 rea 响应式核心的精简实现——不要把它当成完整源码它更像一个能跑通全流程的最小骨架。但理解了这段骨架你再看任何响应式库的源码都会轻松很多。首先是最底层的基础结构// 全局标记当前正在运行的 effect let activeEffect null; // 依赖存储结构target - key - effect 集合 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 deps depsMap.get(key); if (!deps) { deps new Set(); depsMap.set(key, deps); } // 当前 effect 被登记为 key 的依赖 deps.add(activeEffect); } function trigger(target, key) { const depsMap targetMap.get(target); if (!depsMap) return; const deps depsMap.get(key); if (!deps) return; // 拷贝一份再遍历避免迭代过程中修改 Set 导致行为异常 [...deps].forEach((effectFn) queueEffect(effectFn)); }这段代码里有两个容易理解的点WeakMap 的键是原始对象值是这个对象内部各属性对应的依赖集合。用 WeakMap 的好处是当原始对象不再被引用时整个依赖表也会被垃圾回收不会造成内存泄漏。Set 用来存储 effect 函数天然去重同一个 effect 不会重复登记。然后是 reactive 和 effect 的核心逻辑function reactive(target) { const proxy new Proxy(target, { get(obj, key, receiver) { const result Reflect.get(obj, key, receiver); track(obj, key); // 懒代理如果读取到的值本身是对象继续递归代理 if (result ! null typeof result object) { return reactive(result); } return result; }, set(obj, key, value, receiver) { const oldValue obj[key]; const result Reflect.set(obj, key, value, receiver); // 只在值真正变化时触发更新 if (hasChanged(oldValue, value)) { trigger(obj, key); } return result; } }); return proxy; } function effect(fn) { // 包装一层让收集到的 effect 是可控的 wrapper const effectFn () { try { activeEffect effectFn; return fn(); } finally { activeEffect null; } }; effectFn(); return effectFn; } function hasChanged(oldVal, newVal) { return !Object.is(oldVal, newVal); }这里用 Object.is 做比较而不是原因前面提过Object.is 能正确处理 NaN 的情况。这是团队踩坑后改的以前用!的时候某个值被赋 NaN 时会触发多余更新调试半天才发现问在值比较上。写到这里你已经有了一个能跑通的响应式核心试试这段代码const state reactive({ count: 0 }); effect(() { console.log(count 变了, state.count); }); state.count 1; // 控制台输出count 变了 1 state.count; // 控制台输出count 变了 2能打出这两行日志说明 track 和 trigger 已经串起来了。4.2 store 容器层的设计模块化与派生状态有了响应式核心接下来要做的是状态容器层。单纯的 reactive 对象不够工程化因为项目中不可能只有一个全局对象。团队项目里一般按模块分文件每个模块有自己的状态和操作所以 store 层的核心是 createStore。设计 createStore 时我主要考虑了几个问题状态应该可以直接读写但修改动作最好是集中的。如果任何地方都能随手改全局状态出问题了跟踪不到是谁改的。所以 rea 的 store 提供了 actions 的概念业务代码通过调用 action 来修改状态action 内部做数据变更的封装。派生状态computed应该具备缓存能力。比如购物车里总价是各商品价格的加总如果每次读取都重新算一遍列表一长性能就受影响。rea 在实现 computed 字段时内部用 effect 追踪其依赖依赖不变时直接走缓存只有依赖变化时才重算。createStore 的使用形态大概是这样const cartStore createStore({ name: cart, state: { items: [], couponCode: }, computed: { totalPrice(state) { return state.items.reduce( (sum, item) sum item.price * item.quantity, 0 ); }, totalCount(state) { return state.items.reduce((sum, item) sum item.quantity, 0); } }, actions: { addItem(state, payload) { const existing state.items.find( (item) item.id payload.id ); if (existing) { existing.quantity payload.quantity; } else { state.items.push({ ...payload }); } }, clearCart(state) { state.items []; } } });这里有个细节值得注意addItem 里直接往里 push 对象并没有把新对象转换成响应式。但在 reactive 的懒代理机制下当后续渲染读取 items 数组时数组内每个对象作为属性值被读取会被自动代理。换句话说新插入的数据不需要额外的响应式转换步骤这一点在 Proxy 方案下天然成立也是它比 defineProperty 方案省事的地方。4.3 接入 React 的 useRea让组件订阅数据变化状态容器做好了下一步是把状态接进 React 组件。这层的核心是一个名为 useRea 的 Hook。大概的思路是这样组件里每读取一次 store 的属性就会建立组件依赖了这块数据的连接。如果数据变了react 做了什么其实 Hook 本身不感知变化我们需要一个机制强制组件重新渲染。实现方式看起来并不复杂用 useSyncExternalStore 或者手动 useReducer 触发重渲染。核心代码类似import { useSyncExternalStore } from react; function useRea(store, selector) { const getSnapshot () selector(store.state); return useSyncExternalStore( store.subscribe, getSnapshot, getSnapshot ); }这里的 store.subscribe 不是 store 状态本身的订阅而是在响应式核心层注册了一个订阅回调。selector 函数里读取了 store.state 的哪些字段依赖关系就会自动记录。数据一变订阅回调触发useSyncExternalStore 检查 snapshot 是否变化变了就重新渲染组件。使用起来效果是这样function CartTotal() { const totalPrice useRea(cartStore, (state) state.totalPrice); return div合计{totalPrice} 元/div; }组件里没有 setState没有手动刷新totalPrice 变了CartTotal 自动重新渲染。其他不依赖 totalPrice 的组件完全不受影响渲染粒度被控制得很精准。不过用 useSyncExternalStore 之前需要先了解一下 React 18 的并发特性下渲染时序稍有不同如果不依赖这个 Hook自己手动订阅很容易出现渲染不一致的警告。建议直接使用官方提供的同步方案不要自己造轮子这块水比较深。5. 常见问题与排查技巧实录5.1 数组更新却不触发视图更新的三大原因在实际使用 rea 的过程中团队反馈最多的问题居然是数组不触发更新。排查下来无非这三种原因我整理成了一个速查表表现直接原因解决办法push 了新元素视图没变组件没有读取 length 或索引确保 selector 里返回了数组本身或其 length而不是只操作它修改某个索引对象属性视图没变对新插入对象未建立响应依赖确认读取链路经过了 Proxy 的 get 拦截最好直接返回整个新对象给数组整体赋新值可能 Batch 调度还未执行完确认数据更新发生在同一事件循环的同步代码中await 后的修改需要检查时序第一个原因是最容易踩的。如果组件里这样写cartStore.items.push(newItem)但 selector 只返回了state.items.length理论上应该触发更新。但如果 push 的时候依赖收集没建立没有 activeEffect 在运行那自然更新不了。排查方式很简单在 push 之前先用 effect 包裹依赖读取确认有没有依赖被登记。如果没有说明追踪链断了。第二个原因是懒代理引起的陷阱。你 push 进去一个普通对象读取这个对象内部属性时它必须经过一次 get 拦截才能被转换。如果你的 selector 返回的是整个 items 数组渲染函数里再去循环读 item.name这一层读取是发生在渲染期间的此时 activeEffect 已经设置好了依赖能正常收集。但如果把 item 存到一个普通变量里、渲染期外去修改它那改的其实还是原始对象Proxy 拦截不到。解决办法是拿到 item 时先执行一次 reactive(item)或者干脆在渲染期内完成读取。5.2 死循环与重复执行问题的定位思路响应式系统最常见的崩溃现场是死循环。前面提过一个例子——在一个 effect 里改自己依赖的数据。定位这种问题有一个很实用的套路给 effect 打个标记在函数入口和出口分别输出当前调用栈。如果发现同一个 effect 在日志里连续出现几十次基本可以确定是自更新循环。另一个常见场景是依赖借由原型链读取。比如state.obj.hasOwnProperty(xxx)在 Proxy 的 get 拦截里访问到 hasOwnProperty 这个方法时Reflect.get会沿原型链取到函数这个函数本身不是响应式对象track 依然会执行导致一个实际上不依赖数据的 effect 被绑定到了某些 key 上。数据变化时触发了一堆无关 effect 的执行副作用看起来像是更新的组件不对。处理方式是在 track 阶段加一个判断只对叶子属性进行依赖收集遇到函数或原型链上的值直接跳过。这样做能减少不少无效的重复执行。5.3 调试工具与依赖可视化如何看清谁依赖了谁排错的时候最想知道的是一句话某个字段变化到底会触发哪些组件重渲染如果凭肉眼在组件树里逐个猜太累了。rea 在开发模式里内置了一个依赖可视化调试器。它会拦截 track 和 trigger 两个环节记录哪个组件依赖了哪个字段。在控制台输入window.__reaDebug__就能看到一张多维表格横轴是数据字段纵轴是组件交叉点显示依赖关系。调用trigger(state, items)后面板上会用高亮标注即将重渲染的组件。这里有个使用技巧当你要排查我改了数据但没触发更新的时候先打开调试面板看目标字段的依赖有没有登记上。如果依赖列表是空的说明组件根本没有订阅这个字段问题出在 selector 的返回结果上不是响应式系统的问题。如果依赖列表有数据但不更新那就要继续看调度器有没有执行、有没有被批量合并掉。依赖可视化的另一个好处是可以提前发现过度订阅的组件。面板上如果一个字段的依赖清单刷了整整两页说明这个字段被太多组件共享可能出现改一个值半个页面重渲染的性能隐患。实际项目里遇到这种状况通常会把大对象拆分成细粒度的子 store或者让 selector 只返回字段级片段。6. 进阶扩展与个人体会6.1 从 rea 到通用响应式引擎适配其他框架的路径rea 三层架构带来的最大红利是第三层可以被替换。团队在一个小程序项目里需要复用状态管理逻辑第一层和第二层完全没动只有视图绑定层从 React Hook 改写成了小程序页面级的订阅器。具体做法是在小程序的 Page 构造函数里注册一个数据订阅然后通过 setData 将变化同步到视图层。核心依旧是把 store 的状态和页面字段做映射。这个改造大概只花了一个下午的时间但节省了之前预计的三天工作量。如果你的业务里也有跨端需求建议一开始就保持严格的层级边界。最忌讳的是在核心层里直接写document.getElementById或 React 的 API——一旦耦合了具体平台后面就再也拆不开了。rea 的底层实现到现在也没有任何 DOM 相关的代码这是刻意保持的干净边界。6.2 性能调优的三个实测经验第一个经验是懒代理真的好用。初始化一个大的对象树时并不会递归地把所有子对象全部代理一遍只有被真正访问到的子对象才会被代理。我之前对比过一个两百多个字段的大配置对象尽力预代理全部子对象大约耗时 30ms而懒代理的首次响应时间不足 1ms差异非常明显。第二个经验是避免在渲染函数里做非必要的大对象读取。如果你在组件里直接 selector 返回整个 store 对象那么任何一个字段变化都会导致组件重渲染响应式的细粒度优势瞬间没了。正确的做法是返回你需要的具体字段或派生结果。这和前面提到的过度订阅是同一件事但实际工程中非常容易出现需要定期用调试面板检查依赖清单。第三个经验是批量更新的调度器要结合请求时序来考量。假设你在一个异步请求的回调里修改了两个字段如果它们分布在同一次微任务里调度器会合并执行如果中间隔了一个 await可能会分成两批。这不一定是坏事但如果你希望多字段变更只渲染一次尽量把赋值操作放在同一个同步块里。团队在实践后甚至约定了一个代码规范异步回调里的状态变更先收集到本地变量最后一次性赋给 store。6.3 写在最后分享几个让你少踩坑的细节习惯最后分享几个我在实际使用 rea 过程中沉淀下来的习惯不算系统方法论但确确实实帮我在排错时省了不少时间。第一个习惯是给 store 的字段起名时尽量避免含义模糊的缩写。响应式系统的优势之一是数据流透明如果字段名本身无法自解释调试时看依赖面板会觉得信息量巨大但没法定位。团队后来为此做了命名规范打磨状态字段全部用完整的名词短语如userProfile而不是up派生状态全部以total、sorted这类前缀开头一眼就能看出是计算出来的。第二个习惯是写 action 时尽量让副作用外置。也就是说action 内部只做数据变更像日志上报、页面跳转、额外请求这类操作放在 action 调用方那边。这样做的原因很实际action 被复用的时候不会被无关行为绑死排错时也不需要为了理解一个 action 去翻调用方的代码。第三个习惯是大数据量场景下给 effect 加上手动停止的机制。rea 的 effect 默认会一直存活直到页面卸载。当你在组件里创建了一个长循环的数据监听卸载时最好显式调用 stop 函数。虽然 React 版本里 useRea 已经帮你处理了这一层但如果你在业务中直接使用 effect这一条是绕不开的。我对 rea 的期待是它能够成为一个能看懂、能改、能扩展的响应式基础设施。不是每个项目都需要引入一个庞大的状态库很多场景下一个几百行、原理透明的小工具反而能发挥最大价值。这篇文章里给出的代码、经验、坑点都是团队用实际项目喂出来的如果它能帮你少走一次弯路那就是它最大的价值。