如果你做过中大型 React 项目,应该对状态管理选型这件事深有体会。从早期的手动层层传参,到后来各种全局状态库轮番上阵,方案看似很多,但真正落到业务里,总会有一些别扭的地方。最近我们团队把一个内部状态管理模块收敛成了独立项目,代号就叫rea。它不是要再造一个全家桶,而是想解决一个很具体的问题:能不能让状态管理保持“单向数据流”的清晰感,同时又把 React 组件的更新粒度控制到最小,让代码读起来像在写普通函数一样自然。
这篇文章就把 rea 从设计动机到核心实现、再到落地和踩坑的过程完整拆一遍。如果你是正在做状态管理选型,或者想自己写一个轻量级状态库的开发者,这里面的取舍思路和实现细节应该能帮上忙。先说明,rea 当前定位是面向中大型业务场景的基础库,不依赖任何第三方运行时,只依赖 React 本身。
1. 项目定位:rea 到底想解决什么问题
1.1 现有状态管理方案的几个别扭点
用了一段时间各种主流状态管理库之后,我发现一个共性现象:越是功能强大的方案,越容易把业务代码“拽”进它的范式里。比如某个全局状态库要求所有副作用都走特定中间件,时间旅行调试确实漂亮,但业务逻辑要拆成一大堆 action 和 reducer 样板代码;又比如某些原子化状态方案,用起来很灵活,但团队协作时容易把状态写散,最后在组件树里到处挂着零散的 atom。
还有一个很隐蔽的痛点,就是状态更新与组件渲染之间的联动成本。React 本身有setState和useReducer,但跨组件共享状态时,传统做法往往靠 Context。Context 有个众所周知的问题:只要 Context 的值变化,所有消费该 Context 的组件都会重新渲染,不管它们实际用到的是哪一段数据。在低频更新场景下问题不大,但像实时表格、图表联动这类场景,很容易出现看似无关的列也跟着闪烁。
rea 想解决的问题可以总结成三点:第一,提供全局状态但不强加复杂的业务范式;第二,把更新通知精确到“用到哪段数据就只更新哪个组件”;第三,让 action 的写法尽可能接近普通异步函数,而不是层层包裹。
1.2 rea 的命名含义与目标用户
项目代号 rea 取自 React Easy Action 的首字母组合,意思很直白:让 React 里的状态操作变得简单直接。它不是一个激进的新框架,更像一个主张克制的工具库。核心 API 数量极少,理论上半天就能上手。
适合用它的场景有几类:一是中后台系统中常见的表单联动、列表筛选、多 Tab 页签数据同步;二是需要把一块复杂业务状态拆给多个组件但同时想保持可预测性的模块;三是在现有项目里逐步替换 Context 方案,又不希望引入太重的基础设施。如果你正在维护一个状态已经被 Context 搅得频繁闪动的老项目,rea 的接入成本会非常低,可以按模块渐进式改造。
1.3 与现有主流方案的取舍对比
这里不否定现有方案的优点,只是把选型逻辑摊开来看。传统方案把状态流转约束得很严格,适合大型多人协作且需要强规范的项目,但这种强度的约束需要付出心智成本。原子化方案胜在灵活,但状态分散之后,调试时要在文件之间反复跳。rea 选择的路径是保留一个集中的 store,用依赖追踪来精细地分发更新,同时让 action 层直接支持 async/await。
| 特性 | rea | 传统全局状态库 | 原子化状态方案 |
|---|---|---|---|
| 数据流方向 | 单向,显式派发 | 单向,强约束 | 双向或自由订阅 |
| 业务范式要求 | 低,action 接近普通函数 | 高,需遵循固定写法 | 低 |
| 组件更新粒度 | 按依赖自动精确更新 | 按容器组件分发,较粗 | 按原子订阅,细粒度 |
| 调试体验 | 提供派发日志与快照回放 | 完整时间旅行 | 依赖各 atom 日志 |
| 接入成本 | 低,可局部使用 | 中高,通常需要改造结构 | 低,但规范需团队自行约束 |
这张表不是说哪个方案绝对好,而是每次选型都要对团队业务形态做判断。rea 更偏向那种“希望有全局状态,但又不想被框架淹没”的团队。
2. 核心设计:为什么把 store 做成可订阅的普通对象
2.1 核心抽象:信号、派发与订阅
rea 的核心模型没有采用 Redux 式的 reducer 组合,而是把 store 设计成一个可被观察的普通对象。每个状态字段都是一个“信号”,组件在 render 阶段访问哪个信号,就自动注册对那个信号的依赖。当该信号变化时,只有依赖它的组件会被标记并重新渲染。这避免了 Context 的传播路径过长,也避免了自己手动做 selector 时对引用相等性判断的繁琐。
举个例子,你用 rea 创建一个 store:
import { createStore } from 'rea'; const store = createStore({ user: { name: '张三', age: 28 }, settings: { theme: 'light' }, pending: false, });这里的store.user、store.settings不是普通的冻结对象,而是内部带有依赖收集能力的代理对象。在组件里这样读取:
function UserName() { const name = store.user.name; // 这里自动订阅了 user.name return <span>{name}</span>; }当执行store.user.name = '李四'时,rea 会把user下的相关信号标记为变化,然后精准触发UserName组件更新,而旁边只用到settings.theme的组件不会被无关渲染。
2.2 为什么不做字符串版的 action 常量
传统状态管理方案里大量使用字符串常量来定义 action,好处是调试时能直观看到操作类型,代价是每加一个功能都要在多个文件里重复维护类型名。rea 选择直接暴露内部方法,在内部维护一个派发记录,但对外暴露的是可调用的函数。我觉得这更接近“在组件里调函数”的心智模型。
实际用起来大概是这样的:
// 定义一个 action,内部可以读取和修改 store const updateUser = async (id, patch) => { const data = await requestUserDetail(id); store.user = { ...store.user, ...patch, ...data }; };这个 action 和普通异步函数没有太大区别,没有模板字符串,没有 switch case 的分发逻辑。调试时可以在 rea 开发者工具里看到一次完整的调用栈和 store 变更快照,不需要通过字符串常量反查代码位置。
2.3 数据流约束与避免状态不可控
状态管理最怕的是“哪里都能写,哪里都能改”。rea 没有完全开放式地放开 store 属性,而是提供了commit语义:外部可以在组件里读取 store 属性,但修改操作统一走内部的 set 方法,并且所有 set 操作都会生成一条变更日志。这样做的意图很明确:读取路径尽量自然,写入路径保留唯一的入口,方便排查数据被改乱的问题。
commit方法支持一次提交多个字段,内部会做批量合并,而不是每改一个属性就触发一次渲染。比如你在 action 里同时改了三个状态字段,React 只会收到一次更新请求。这个批处理机制对渲染性能影响很大,尤其是高频表单联动场景。
3. 从零实现:rea 的核心模块拆解
3.1 模块总览与目录规划
整个 rea 源码结构不算复杂,核心就几个文件。我建议任何想自己实现状态库的人,都按这个思路先做最小闭环,再逐步加能力。
rea/ src/ core/ store.js // 创建可观察 store signal.js // 信号单元,负责依赖收集与派发 batch.js // 批量更新调度 react/ useStore.js // React 绑定:让组件感知 store 变化 useAction.js // 绑定 action,自动处理 loading 状态 Provider.js // 可选,用于配置调试工具与默认状态 tools/ devtools.js // 派发日志与状态快照回放 tests/ store.spec.js react.spec.js先看signal.js。每个字段在内部会被包装成信号对象,它维护两个列表:一是依赖订阅者的集合,二是当前值。读取时,如果当前处于组件渲染上下文,就会收集读取者;写入时,把值更新后进入批量调度器。
// signal.js 核心逻辑示意 export class Signal { constructor(initialValue) { this.value = initialValue; this.subscribers = new Set(); } get() { if (currentRenderContext) { this.subscribers.add(currentRenderContext); } return this.value; } set(nextValue) { if (this.value !== nextValue) { this.value = nextValue; batchSchedule(() => { this.subscribers.forEach((ctx) => ctx.notify()); }); } } }这里有个关键点:subscribers为什么要设计成 Set。因为同一个组件在多次渲染中可能重复订阅同一个信号,如果用普通数组就会积累重复回调,导致更新次数指数级增长。用 Set 天然去重之后,同一渲染周期内重复访问同一个字段只会注册一次通知回调。
3.2 store 的代理实现与依赖收集
单纯用 Signal 对象还不够,业务代码里更自然的写法是store.user.name,而不是store.get('user.name')。所以 store 本身用 Proxy 做了一层字段代理。当访问store.user时,Proxy 会拦截到get操作,返回对应的信号代理;当继续访问.name时,继续返回嵌套的信号值。
// store.js 的 Proxy 代理核心 function createProxy(target) { return new Proxy(target, { get(obj, prop) { if (prop === '__isReaProxy') return true; if (typeof obj[prop] === 'object' && obj[prop] !== null) { if (obj[prop].__isReaProxy) return obj[prop]; return createProxy(obj[prop]); } return obj[prop].get(); }, set(obj, prop, value) { // 所有写入都走统一记录 commit(obj, prop, value); return true; }, }); }这个实现要注意避免在每次get时都新建代理,否则会导致依赖追踪失效。我在首次实现时踩过这个坑,每次读store.user.name都添加了一层新代理,结果更新时误判了依赖关系。正确的做法是在对象内部缓存代理实例,保证同一个对象访问多次返回同一个代理对象。
3.3 React 绑定层的实现要点
React 绑定层的核心任务是解决一个看似简单的问题:组件是如何知道自己需要重新渲染的?答案是订阅一次、多次唤醒。useStore首次执行时,会创建一个渲染上下文实例,把自己注册到 rea 的全局当前上下文中。此后组件每次 render 读取信号时,信号会把该上下文加进订阅列表。
function useStore(selector) { const [, forceUpdate] = useReducer((c) => c + 1, 0); const contextRef = useRef(null); if (!contextRef.current) { contextRef.current = createRenderContext(forceUpdate); } // 进入依赖收集状态 beginCollect(contextRef.current); const value = selector(store); endCollect(); useEffect(() => { return () => { contextRef.current.unsubscribeAll(); }; }, []); return value; }这里有一个曾经的坑:如果selector内部访问了多个信号,而组件卸载时没有清理订阅,订阅集合就会一直持有卸载组件的引用,造成内存泄漏。曾经在一个长时间运行的后台页面上,这个问题导致刷新频率逐渐下降。后来我在这个绑定层里严格要求每个渲染上下文持有订阅信号列表,并在卸载时统一解绑。
useAction绑定则处理了另一个常见需求:把 action 包装成带 loading 状态的方法。早期的业务代码里每次发请求都要手写pending状态,于是我在绑定层直接支持:
const { run: saveUser, loading } = useAction(updateUser); return <Button loading={loading} onClick={() => run(...args)}>保存</Button>;这样组件代码里就不需要再维护一个localLoading状态了。
4. 性能考量与压力实测:为什么精确订阅能省钱
4.1 测试场景设计
我用一个模拟业务来验证 rea 的实际表现。场景是这样的:页面上有一个 200 行的表格,每行包含 20 个字段;表格上方有一个筛选面板,筛选条件变化时需要重新计算部分单元格的显示状态;同时页面右下角还有一个监控面板,实时显示当前筛选条件的组合数和一个与表格无关的时钟。
用传统 Context 方案时,筛选条件一变化,整个表格组件和监控面板都会刷新。用 rea 之后,筛选条件只通知真正读取了筛选结果的表头计算组件和单元格格式化组件。监控面板因为只订阅了时钟信号,完全不会触发额外渲染。
4.2 实测数据对比
在一个普通的开发机上,我用 React 的Profiler记录渲染耗时。表头联动场景每次产生 80 个状态字段变化时,Context 方案的平均刷新时间大约是 46ms,rea 是 9ms 左右。监控面板的更新次数,Context 方案因为共享了整个状态对象,每次筛选变化都会跟着刷新,rea 方案则稳定维持每秒一次。
| 场景 | 传统 Context 方案 | rea 方案 |
|---|---|---|
| 筛选联动耗时(80 字段变更) | 约 46ms | 约 9ms |
| 无关组件是否刷新 | 是,跟随整体刷新 | 否,完全不刷新 |
| 200 行表格全量重渲染次数 | 每次筛选变化都重排 | 仅依赖单元格变化部分重排 |
| 内存占用(运行 1 小时后) | 平稳但更新累积耗时增大 | 稳定,无累积 |
注意,这里不是要论证“所有项目都必须换 rea”。如果一个页面的状态结构非常简单,状态变化的频率也很低,那 Context 的简单性反而更重要。rea 的优势在“高频更新 + 大范围共享 + 局部无关组件共存”的场景里才足够明显。
4.3 批量调度机制的必要性
rea 的更新不是同步派发的,而是通过一个微任务队列做批量合并。比如一个 action 里连续执行了store.filter = ...、store.page = ...、store.selectedRow = ...,这些变更会先进入队列,在下一个微任务里统一处理。这样 React 收到通知时是一次性的,不会因为三次赋值触发三次渲染。
这个机制和 React 18 的自动批处理有些重叠,但 rea 的批处理更靠近数据层,因为它还顺带做了前后快照的对比。如果在一个批量周期内,某个信号先被改成 A 又被改成 B,最终值是 B,那么真正触发渲染时依赖该信号的组件只会感知到 B,不会经历中间的 A 状态。这在表格联动中能避免大量无效的中间帧渲染。
5. 落地实践:一个跨模块联动的实际案例
5.1 场景描述与拆分思路
我们在一个后台项目里做了一个订单查询页,页面包含筛选条件区、结果表格区、批量操作抽屉和统计摘要卡片。最初版本用几个 Context 分别管理筛选和表格数据,结果筛选一变化,筛选面板自身因为使用了某个总状态字段,也会重新渲染一遍,导致输入框光标抖动。
重构到 rea 之后,状态按业务边界拆成三个 store:筛选条件 store、结果数据 store、界面交互 store。筛选条件变化时,只有筛选条件 store 里相关的字段发生变更;结果数据 store 里负责请求的 action 会监听筛选变化,重新请求数据。这是通过一个 watch 方法实现的:
store.watch('filter', () => { loadOrderList(store.filter); });注意watch也是异步批处理的,如果多个筛选字段连续变化,只会在最后一个变更之后触发一次loadOrderList,不会发一连串请求。
5.2 组件如何做到只关心自己的数据
在表格行组件里,我把每行需要的数据通过一个最小化的 selector 提取出来。比如只读取row.id、row.status、row.amount这三个字段。当批量操作修改了整个列表的某个字段时,只有真正读取了该字段的行组件会更新,其它行的 DOM 不会重绘。
const OrderRow = ({ rowId }) => { const { status, amount } = useStore((s) => { const row = s.rowsById[rowId]; return { status: row?.status, amount: row?.amount }; }); return <tr>{/* ... */}</tr>; };这里一定要避免返回新对象导致每次渲染都触发重绘,所以useStore内部会对 selector 返回的结果做浅比较,如果引用没有变化,就不会触发组件更新。我在实现这个浅比较时特别处理了对象字面量情况:每次 selector 都返回{ status, amount },但内部比较的是这两个字段的值,而非外层的对象引用。
5.3 请求缓存与服务端状态的联动
rea 虽然主要是状态库,但配合简单的数据请求层,可以实现一个可复用的资源状态管理。比如订单详情,用一个 Map 结构缓存orderId -> data。第一次请求时设置 loading,成功后写入缓存并自动触发详情组件更新。
const loadDetail = async (orderId) => { store.detailLoading[orderId] = true; try { const data = await fetchOrderDetail(orderId); store.detailCache[orderId] = data; } finally { store.detailLoading[orderId] = false; } };请求逻辑写在 action 里,组件里只需要useAction(loadDetail)。这样做的好处是,如果多个组件同时请求同一个订单,不会重复发请求,因为可以在 action 开头做一个缓存命中判断。
6. 常见问题与排查技巧实录
6.1 高频踩坑点速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 页面频繁刷新但业务并没有变化 | 订阅集合未清理,组件卸载后仍在通知 | 检查 useStore 绑定的卸载清理逻辑 |
| 某个组件读取状态后不更新 | selector 返回了新的非原始值引用 | 确认 useStore 返回值浅比较逻辑是否按字段计算 |
| 连续修改多个字段触发了多次渲染 | 批量调度没有生效 | 检查 batch 队列是否存在同步刷新分支 |
| 调试工具里的变更记录顺序错乱 | 嵌套的 action 修改了同一字段 | 在 commit 日志里记录调用栈来源 |
| Proxy 代理对象被序列化后报错 | 代理实例混入了业务数据结构 | 对外暴露一个 toJSON 方法,序列化时做解包 |
6.2 几个值得注意的实现细节
第一个细节是订阅解绑的时机。useStore绑定的 effect 回调只在组件卸载时执行,但 rea 内部还会监听 React 的 render phase 通知。如果一个组件因为父组件更新而重新渲染,它的旧订阅集合不应该被清除,这一点在实现时不要误触发。
第二个细节是 store 嵌套对象的变更检测。如果你直接写store.filter.keywords = 'abc',rea 的 Proxy 是可以拦截到的,因为这实际上是对store.filter这个代理对象执行 set 操作。但你千万不能把代理对象直接赋值给一个普通变量再修改,那样会绕过拦截,导致变更没有记录。
第三个细节和性能有关:在 action 里频繁访问 store 属性时,如果 store 结构很深,代理访问会有少量开销。我在一次处理 5000 条数据的循环里,每循环一次读 $store.userMap[id]$.name 和 $.avatar$,总共耗时从原生对象的 12ms 涨到了 18ms。这在绝大多数业务场景中可以忽略,但如果你的数据循环量极大,建议把数据先拉到普通局部变量里处理,最后一次性提交回 store。
6.3 调试工具的原生实现思路
调试工具虽然不直接属于 rea 的核心 API,但对开发体验影响很大。我在 devtools 里做了一项能力:每次 commit 时记录变更前后的路径和值,并把 action 的调用栈一并记录。这样排查问题时,不仅能知道状态变成了什么,还能回溯出是哪个 action 在哪个调用链里修改了它。
// devtools 中变更记录的数据结构示意 { actionName: 'loadDetail', timestamp: 1234567890, changes: [ { path: 'detailCache.10001', prev: null, next: { ... } }, { path: 'detailLoading.10001', prev: true, next: false }, ], stack: 'at loadDetail (orderAction.js:24)...' }在开发环境开启这个记录功能会带来少量的性能开销,我只建议在联调阶段开启,生产环境默认关闭。时间旅行回放可以让你将 store 恢复到任意一次变更后的状态,这个能力在排查复杂联动 bug 时的价值极高。
7. 扩展方向:rea 后续还能怎么用
rea 当前状态已经可以在中小型项目中稳定落地,后续我打算从几个方向扩展。第一是支持服务端状态缓存与请求去重的融合方案,让 action 层直接内置缓存时间、失效策略;第二是增加更完善的 devtools 界面,支持多 store 之间的事件时序图;第三是想尝试把 rea 接入非 React 项目中,毕竟它内部状态核心本质上不依赖 React,只是绑定层依赖。
在我个人实际使用中,最舒服的一点是写 action 时终于可以像写普通业务函数那样平铺直叙了,而不是时刻去想自己要派发什么类型、要怎么写 reducer。对团队新成员来说,他们只需要理解两个核心 API:store和useStore,就能快速切入开发。如果你也想在自己项目里尝试这种“极简状态层 + 精确订阅”的模式,不妨拿一个单独的功能模块先做试点,感受一下数据流和渲染行为的变化,再决定要不要大规模铺开。