我自己都没想到会在一个加班到凌晨两点的晚上给团队写下一个名叫 rea 的状态管理库。起因是一个订单模块的改造客户要求实时展示物流状态而后端只给了每 30 秒一次的轮询接口。项目里所有状态都散落在组件内部筛选项变了要通知列表列表变了要通知角标角标变了还要通知详情页——改一处状态要动七八个组件。那天我把代码提交之后坐在工位上问了自己一个问题如果“状态修改”能自动通知所有关心它的组件而不是靠人肉把 props 层层传下去是不是就不会这么累于是 rea 诞生了。rea 这个名字取自 Reactive Everything Architecture意思是“让一切状态变化可响应”。简单说rea 是一个 3KB 左右的响应式状态管理核心核心 API 只有三个createStore、useRea、defineAction。组件用到哪个字段就只订阅哪个字段字段一变用到它的组件自动重新渲染。这篇文章会写清楚三件事为什么我会放弃直接引入成熟状态库而选择自研、rea 的响应式原理到底怎么实现、以及我实际接入购物车模块后踩到的五个边界坑。适合对 React 状态管理有一定了解、想搞懂“响应式订阅”底层逻辑的读者也能给想自研小库的团队当个参考。1. 为什么放着现成的状态库不用偏要自己写一个1.1 从一次订单模块改造说起先还原当时的场景。某商城项目的订单列表页原本是这样的每个组件各自 useState后端返回的订单数组被复制到三个地方——列表组件维护一份、顶部筛选 tab 维护一份、订单详情页再维护一份。需求变更后物流状态每 30 秒轮询一次轮询结果需要同时更新列表行的高亮、筛选 tab 的待处理数量、以及详情页的实时状态。问题立刻就暴露了。第一数据源不统一。列表更新了筛选组件不知道待处理数量经常和列表行数对不上。第二轮询请求的清理依赖 useEffect 的 cleanup但列表组件和详情页各写了一份轮询代码有一处忘了清理页面切走之后请求还在发某次线上就出现了已取消订单的物流状态在后台页面反复刷新的现象。第三改 A 忘 B。为了让“订单已取消”的状态在三个地方表现一致我改了列表组件忘了详情页也有同样的判断逻辑结果用户从列表点进详情看到两种状态。这种问题用 useState props 不是不能解但解起来很别扭要么把状态提升到页面级然后一路传 props要么把数据塞进 Context再忍受整棵树重渲染。我在当时那个项目里两种都试过最后还是选择写一套自己的状态层——也就是 rea 的第一个版本把一个订单数组放进全局 store列表组件只订阅“当前提示的订单 id 列表”筛选组件只订阅“各类别数量”详情页只订阅“当前选中的订单详情”。后端轮询结果一到调用 action 更新 store三个组件各自收到通知自动重渲染。1.2 市面方案到底差在哪肯定有人问Redux、Zustand、Jotai 不都是现成的你折腾啥我当时也列过一张对比表带着这张表去找组长说明白为什么值得自己做一版。方案优点缺点最适合Redux生态完善、DevTools 强、中间件丰富模板代码多、概念多、小型模块用起来沉中大型多人协作Zustand轻量、API 友好、外部 store底层还是发布订阅深度字段更新要配合中间件或额外处理中小型项目Jotai原子粒度细、组合灵活学习曲线陡、心智模型偏新状态依赖复杂的场景Context内置、零依赖任何 value 变化都让所有 Consumer 重渲染低频主题、登录态看完表组长问我一句那你自研的 rea 比 Zustand 强在哪我的真实回答是对“业务状态同步”这个最常见场景rea 想把使用成本压到最低同时通过 Proxy 把订阅粒度精确到字段。Zustand 也能做选择器但底层的依赖收集、批量调度、数组方法处理这些细节需要自己踩rea 从一开始就把这些内建了。当然我也承认如果团队没有折腾的时间直接选 Zustand 完全没问题。自研库的隐性成本我会在最后一节展开讲。1.3 rea 的设计目标所以 rea 的目标不是“替代 Redux”而是“让 90% 的状态同步场景用最低的心智负担解决”。三个设计目标一是最小 API。整个库对外只有 createStore、useRea、defineAction 三件套没有 reducer、没有 dispatch、没有独立的选择器缓存库。学完整套 API 不会超过十分钟。二是精确订阅。组件从 store 里读哪个字段就只订阅那个字段。状态变更时只有真正用到该字段的组件会收到通知。这是靠 Proxy 自动完成的不需要手动声明依赖数组。三是可控变更。所有状态修改都收敛到 action 里组件通过 action 改状态而不是直接给 store.state 赋值。好处是日志、埋点、持久化都集中在 action 层排查线上问题时能直接看 action 调用链。有了这三个目标后面的原理实现就有了明确方向。2. 响应式核心原理从事件总线到 Proxy 依赖追踪2.1 事件总线为什么不够任何响应式系统骨子里都是发布订阅。最朴素的事件总线大概长这样function createBus() { const listeners new Set(); return { on(fn) { listeners.add(fn); return () listeners.delete(fn); }, emit() { listeners.forEach(fn fn()); } }; }store 维护一个 bus每次状态变更后执行 emit。这个模型跑 demo 没问题但一上真实业务就露馅订阅粒度是整个 store。任何字段变化所有订阅者都被通知组件重新执行选择器、重新渲染。这和 Context 的缺点一模一样数据小的时候无感数据一大一次筛选操作可能触发几十个组件重算。rea 要做的是把“整个 store 变更”拆成“某个字段变更”只通知关心这个字段的组件。这一步靠事件总线做不到需要引入 Proxy 做依赖收集。2.2 Proxy 依赖收集从“广播”到“定向通知”Proxy 的思路是这样的组件读取状态时内部把当前正在执行的 effect 记为一个 activeEffectProxy 的 get 拦截器发现有人读属性就把 activeEffect 登记到“属性 - 依赖该属性的 effect 集合”里。当状态被修改时set 拦截器找到这个集合把 effect 放入待执行队列。这就是很多前端框架响应式系统的核心骨架rea 沿用这套思路用 WeakMap 存依赖关系const targetMap new WeakMap(); function getDeps(target, key) { 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); } return deps; } function reactive(target) { return new Proxy(target, { get(t, key, receiver) { if (activeEffect) getDeps(t, key).add(activeEffect); return Reflect.get(t, key, receiver); }, set(t, key, value, receiver) { const oldValue t[key]; const result Reflect.set(t, key, value, receiver); if (oldValue ! value || key length) { const effects new Set(getDeps(t, key)); effects.forEach(effect queueEffect(effect)); } return result; } }); }这段代码里有两个细节值得注意。第一为什么 get 里要用 activeEffect。activeEffect 是一个临时变量在组件选择器执行之前被设置为“当前要渲染的组件对应的 effect”选择器执行完毕后清空。只有在这个窗口期里发生的属性读取才算“依赖”。组件函数体里读了多少个字段这些字段就都挂到当前 effect 名下。第二set 里为什么用 Reflect。Reflect.set 会正确处理 getter/setter 和 receiver避免直接 t[key]value 在某些继承场景下行为不一致。另外targetMap 用 WeakMap 而不是 Map是有意的。WeakMap 的 key 是原对象 target如果 store 实例不再被任何地方引用target 也随之失去强引用内存天然被回收不需要手动调用一堆 unsubscribe。这比在数组里存 listener、然后等销毁时逐个清理要省心得多。2.3 effect 调度把多次修改合并成一次渲染有了依赖收集下一个问题是调度。假设一个 action 里同时改了 items 和 couponId 两个字段两个字段都触发同一个组件的 effect组件要渲染几次如果同步执行就渲染两次纯浪费。rea 的调度器用一个微任务批处理let flushPending false; const dirtyEffects new Set(); function queueEffect(effect) { dirtyEffects.add(effect); if (!flushPending) { flushPending true; Promise.resolve().then(() { dirtyEffects.forEach(effect effect()); dirtyEffects.clear(); flushPending false; }); } }所有 effect 会在当前同步任务结束后统一执行一次。用微任务而不是 setTimeout是因为微任务紧跟在当前代码之后执行视觉上没有延迟也符合“状态变更后立即更新视图”的直觉。结合 React 的自动批处理实际渲染次数能压缩到一次。到这里rea 的核心循环就完整了读属性时收集依赖改属性时把相关 effect 排进微任务队列统一刷新。3. rea 的 API 设计三个接口扛起全局状态3.1 createStore一个容器搞定全局状态createStore 接受一个初始状态对象返回一个响应式 store。我不打算支持多个 store 嵌套合并一个业务域一个 store简单直接。function createStore(initialState) { const bus createBus(); const state reactive(structuredClone(initialState)); const actions {}; const store { state, actions, subscribe(fn) { return bus.on(fn); }, getState() { return state; }, defineAction(name, handler) { actions[name] (payload) { handler(state, payload); }; } }; return store; }看起来是不是特别简单确实是。但有几个细节值得展开。initialState 会被 structuredClone 深拷贝一次避免多个 store 实例之间共享嵌套对象引用这个坑我会在第五节详细讲。state 是整个 store 唯一的响应式对象。组件通过 getState() 拿到它再通过选择器读取需要的字段action 通过 handler 直接改 state。为什么不把方法直接塞进 state因为方法如果放在 state 上会被 Proxy 当成普通属性追踪。一旦 action 被重新定义依赖该属性的组件都会重新渲染完全没必要。所以 action 和 state 保持分离state 只存数据不存行为。3.2 useRea用 useSyncExternalStore 连接 React组件侧最核心的是 useRea。React 18 提供了 useSyncExternalStore这个 Hook 几乎是外部 store 接 React 的最好方式。function useRea(store, selector) { return useSyncExternalStore( store.subscribe, () selector(store.getState()) ); }就这么三行。为什么用它而不是 useReducer我早期版本用过 useReducer useEffect 来模拟 store 更新遇到几个问题手动触发更新容易丢失 React 18 的并发渲染优化startTransition 和优先级调度都用不上而且订阅和组件卸载的时机容易写错。useSyncExternalStore 内部处理了 store 变更到组件重渲染的全链路同时支持 React 的并发特性是官方推荐的接法。使用方式const items useRea(cartStore, s s.items);注意选择器返回的必须是“组件真正用到的数据”。如果组件只关心 items 数组的长度就写 s s.items.length不要写 s s.items 然后自己在组件里取 length——那样数组内容一变组件就会被通知即使长度没变也一样订阅粒度就变粗了。3.3 defineAction把写操作收口组件不能直接改 store.state.xxx yyy虽然 Proxy set 也能触发更新但那样写操作散落各处排查问题全靠翻组件代码。rea 用 defineAction 收口cartStore.defineAction(addItem, (state, payload) { state.items.push(payload); });action 内部拿到的 state 就是响应式对象直接修改即可。defineAction 返回 store 自身方便链式定义。真到了要接日志、埋点时在 defineAction 里包一层就能统一处理defineAction(name, handler) { actions[name] (payload) { handler(state, payload); emitLog({ name, payload, snapshot: toPlain(state) }); }; }这套设计相比 Redux 的 reducer省掉了 action type、action creator、case 分支这些样板代码。代价是 action 名和逻辑之间的约束少了所以团队内约定 action 命名用动词开头的状态短语比如 addItem、setCoupon、submitOrder。约定这东西虽然靠自觉但比框架强塞规则要灵活得多。4. 实战购物车模块从 useState 迁移到 rea4.1 先定义 store 结构购物车模块是典型的多组件共享状态场景列表、总价、角标、结算按钮都要读同一份数据。我先定义了 storeconst cartStore createStore({ items: [], couponId: null, shipping: 0, loading: false, error: null, orderId: null });为什么把 coupon 和 shipping 也放进购物车 store而不是单独建优惠券 store、运费 store因为它们共同决定最终金额拆开会导致跨 store 联动处理起来相当别扭。状态划分的第一原则是“一起变化的数据放一起”而不是“看起来属于哪个模块就放哪里”。4.2 从组件到 action 的接线步骤接入分四步。第一步定义 action。cartStore .defineAction(addItem, (state, item) { const found state.items.find(i i.id item.id); if (found) { found.count item.count; } else { state.items.push({ ...item, count: item.count }); } }) .defineAction(removeItem, (state, id) { state.items state.items.filter(i i.id ! id); }) .defineAction(setCoupon, (state, couponId) { state.couponId couponId; }) .defineAction(submitOrder, async (state, payload) { state.loading true; state.error null; try { const res await request(/api/order, payload); state.orderId res.orderId; } catch (e) { state.error e.message; } finally { state.loading false; } });这里有个和直觉不太一样的地方action 里可以写异步逻辑。handler 拿到的 state 在整个异步过程中都是同一个响应式对象所以 loading、error、orderId 的修改都会自动通知订阅者不需要额外做状态拷贝。第二步在组件里读取。function CartList() { const items useRea(cartStore, s s.items); const loading useRea(cartStore, s s.loading); // 渲染列表 }如果组件同时依赖多个字段可以连续调用 useRea每个选择器独立订阅互不干扰。互相之间也不用担心产生额外渲染因为调度器会做微任务批处理。第三步在事件处理里调用 action。function AddButton({ item }) { return button onClick{() cartStore.actions.addItem(item)}加入购物车/button; }组件层面不再关心“如何更新状态”只关心“用户触发了什么意图”。状态怎么写进 store是 action 内部的事。第四步处理异步请求。轮询那类逻辑也从组件里挪出来放到独立模块里只负责调用 actionlet timer null; function startPolling() { stopPolling(); timer setInterval(async () { const list await fetchOrderStatus(); cartStore.actions.syncOrders(list); }, 30000); } function stopPolling() { if (timer) clearInterval(timer); timer null; }组件卸载时调用 stopPolling 就行不再需要 useEffect 里写一堆清理逻辑。4.3 迁移前后的真实差异改造完成后我做了个简单统计维度改造前改造后数据同步方式props 层层传递 多份 useEffect组件选择器直接订阅字段状态修改点散落在约 8 个组件中集中在 6 个 action 里轮询逻辑列表页和详情页各一份独立模块一份列表重渲染范围筛选变化时列表和角标一起刷仅订阅对应字段的组件更新合计代码行数约 400 行约 220 行最明显的变化是排查问题的方式。以前遇到“订单状态为什么没更新”要在组件里打断点、追 props 链路现在直接看 action 调用记录谁调用了 syncOrders、传了什么数据、写入后哪些组件被通知链路一目了然。5. 五个边界 case容易被 Proxy 骗过的场景5.1 数组方法触发不了依赖第一次写完我把 items 丢进组件后给数组 push 数据结果角标纹丝不动。排查半天根因是组件选择器读的是 s.items依赖记录在 items 这个 key 上而 push 操作走的是数组内部逻辑触发的是 length 和索引的 setitems 属性本身的引用没变。依赖关系对不上自然不会通知。解决思路是在 set 拦截器里做增强当 target 是数组且 key 是索引或 length 时额外触发 target 自身也就是 items 这个 key的依赖。set(t, key, value, receiver) { const oldValue t[key]; const result Reflect.set(t, key, value, receiver); const effects new Set(getDeps(t, key)); if (Array.isArray(t) (key length || isArrayIndex(key))) { getDeps(t, ).forEach(effect effects.add(effect)); } effects.forEach(effect queueEffect(effect)); return result; }其中 getDeps(t, ) 表示“整个数组对象”本身的依赖组件选择器读 s.items 时就应该记录到这个 key 上。这样一来push、splice、pop 都会触发 items 依赖的 effect。这个坑是所有基于 Proxy 的响应式系统都绕不开的Vue 3 内部也专门处理了数组方法。5.2 effect 里改依赖直接死循环第二个坑比第一个更隐蔽。我在某个 effect 里写了“读取 loading 然后再把 loading 置为 false”的代码表面看没什么问题实际运行后页面直接卡死。原因很好理解读取 loading 时收集了当前 effect修改 loading 时又把这个 effect 排进队列effect 执行时又读取、又修改无限循环。rea 在调度器里加了防御如果某个 effect 已经在 dirtyEffects 中再次入队直接忽略。但这只能防同一轮里的重复入队不能防“执行完又再次触发”的下一轮循环。真正有效的方案是约定effect 只读不写。组件和派生计算里永远只读 store 状态所有写操作一律通过 action。我把这条约定写进了团队代码规范之后半年再没出现过死循环。5.3 异步竞态旧请求覆盖新请求购物车结算和订单筛选都遇到过这个问题。请求返回顺序不稳定时先发出的慢请求可能比后发出的快请求更晚返回旧数据覆盖新数据。在 action 里处理let requestSeq 0; cartStore.defineAction(fetchOrders, async (state, payload) { const seq requestSeq; state.loading true; const result await fetchOrders(payload.keyword); if (seq ! requestSeq) return; // 过期响应直接丢弃 state.orders result; state.loading false; });用一个自增序号判断响应是否过期比 AbortController 简单得多。如果项目环境支持也可以两者结合请求返回后发现过期同时调用 AbortController 取消后续网络传输节省带宽。5.4 Proxy 对象的三个“看起来正常”的操作响应式对象在日常代码里有几个容易踩的坑单独列一下。一是解构。const { items } cartStore.state 之后items 就是一份脱离依赖收集的普通引用。后续对 items 的修改不会被任何组件感知。所以组件里永远通过 useRea 的选择器读取不要在 store.state 上解构。二是 JSON.stringify。JSON.stringify(cartStore.state) 会触发 state 上每个属性的 get相当于瞬间收集了一堆不必要的依赖。如果这个调用发生在组件函数体内组件会变成“所有字段都订阅”。解决方法是提供 snapshot() 方法先把状态复制成普通对象再交给 JSON 序列化用于日志和持久化场景。三是 instanceof。如果代理的是 class 实例某些 typeof 或 instanceof 判断可能和预期不一致。rea 只用纯对象所以没这个问题但如果你要代理 class记得处理 Symbol.hasInstance。5.5 createStore 初始值的浅拷贝陷阱最后一个坑来自 createStore 的实现。早期版本对 initialState 做浅拷贝然后在多个实例之间共享同一个默认对象const defaultState { items: [], config: { threshold: 3 } }; const a createStore(defaultState); const b createStore(defaultState); a.state.config.threshold 10; console.log(b.state.config.threshold); // 10问题就出在浅拷贝只克隆了顶层items 和 config 还是同一个引用。用默认参数合并时嵌套对象也会踩类似问题。后来统一用 structuredClone 深拷贝这个坑才算彻底解决。注意 structuredClone 需要较新的运行环境旧环境要退回到 JSON 深拷贝或自定义 clone 函数。6. 说点大实话什么时候不该自己写状态库写到这里肯定有读者跃跃欲试。但我得说几句不太中听但确实有用的经验。自研库最大的成本不是写是维护和交接。rea 目前只在团队内部三个项目里跑代码不到 300 行没有发布到公共仓库也没有文档站点。新同事入职要读源码、要理解 Proxy 的边界、要接受大家默认的约定。这些成本在项目周期紧张的时候会被无限放大。如果你只是想要一个靠谱的状态管理方案直接用现成的成熟方案是更稳妥的选择。那什么情况才值得自研我的判断标准有三个团队里有人真的想把响应式原理弄透而不是抄一份代码就跑业务里确实遇到了现成方案解决得不顺手的问题比如订阅粒度太粗、模板代码太重以及有足够的时间预算去写边界测试。三条缺一条都不建议。如果你真的决定要写质量保障不能省。我后来给 rea 补了一组单元测试覆盖了订阅精确性、数组方法触发、死循环防护、异步竞态处理这几个最容易出问题的点。每次改动之前先跑测试比靠直觉重构稳得多。实际使用中我还建议加一条性能采样在 store 更新后统计一下本次渲染的组件数量如果明显大于订阅该字段的组件数量说明依赖收集有漏洞。至于 rea 的未来我不打算给它加更多功能。维持三个 API把边界情况处理好让团队里每个人都能在一分钟内说清楚它的工作原理这就够了。做一个“小而完整”的工具比做一个“大而全”的框架难得多但收益也实在得多。最后分享一个小技巧如果你打算自研类似的小库把 API 数量压在三个以下然后把文档写在源码注释里。越少的接口越难设计但也越容易让别人记住。rea 的 createStore、useRea、defineAction 这三个名字团队里至今没人问过第二遍。
阅读完成 · 觉得有帮助?