最近手头项目多,状态管理这块来回折腾了几趟,后来干脆自己写了一个极简的响应式状态库,名字就叫rea。它是 TypeScript 写的,核心代码只有两百多行,没有依赖。如果你正在 React 项目里被各种 selector、reducer、依赖数组搞得头皮发麻,或者在小项目里不想为了一个状态管理引入大几十 KB 的运行时,那这篇文章应该能给你一些启发。我会把设计思路、完整实操、核心源码、踩坑记录全部摊开来讲。
1. 为什么会有 rea:先说说现有方案的痛点
1.1 我在真实项目中遇到的三个场景
第一个场景是跨组件共享一份实时数据。以前用 React 自带的 Context,状态一多,Provider 一层套一层,任何一个小组件更新都能让大片组件重渲染,性能调起来非常头大。
第二个场景是异步数据缓存。接口返回的数据需要在多个页面复用,传统方案要么写一个全局 store,要么用请求库自带的缓存,但二者在“需要手动刷新缓存”的时候都显得笨重。我只想在数据失效的时候更新那一个数据块,其他依赖它的派生数据自动跟着变。
第三个场景是“只改一个字段,却触发整个页面闪一下”。这个感受最直接,用过状态管理库的都懂。组件对状态里的某个小字段感兴趣,却被迫跟着所有状态更新一起重渲染。
这些痛点其实都有一个共同点:状态更新的粒度太粗。Context 以整棵树为单位传播更新,传统 store 以“切片的 set”为最小更新单元,而我希望的最小单元是“一个字段”。
1.2 rea 的定位:不做框架,只管状态
rea的定位很明确:它不是又一个 React 框架,也不是某框架的专属状态插件,它就是一个与框架无关的响应式状态原语集合。你在 React、Vue、Svelte、原生 JS 里都能用,甚至可以在 Node.js 脚本里当临时缓存来用。
核心 API 只有五个:atom(原子状态)、derived(派生状态)、effect(副作用)、batch(批量更新)、store(可组合的状态容器)。你不需要理解复杂的概念,不需要写 action、reducer、dispatch 那一套,只需要把一个值放进 atom,然后订阅它。
举个例子,一段最基础的使用方式:
import { atom, derived, effect } from 'rea'; const count = atom(0); const double = derived(() => count.get() * 2); effect(() => { console.log('count 变化:', count.get(), 'double 是:', double.get()); }); count.set(1); count.set(2);打印结果会是:
count 变化: 1 double 是: 2 count 变化: 2 double 是: 4注意derived里完全没有手动声明依赖count,它是自动追踪的。你用到了哪个 atom,它自己会记下依赖关系,那个 atom 一变,派生值跟着更新。这就是rea最核心的“自动依赖收集”机制。
1.3 与流行方案的核心差异
我画一张对比表,方便你直接看差异:
| 方案 | 最小更新粒度 | 依赖声明方式 | 框架耦合 | 运行时体积 |
|---|---|---|---|---|
| React Context | 整个 Context 子树 | 无(靠 Provider 树) | 强耦合 React | React 自带 |
| Redux | 整个 store 切片 | 手动写 selector + shallowEqual | 可用中间件适配,但心智重 | 约 12KB |
| 传统 MobX | 属性级(observable) | 自动收集 | 心智较轻,但装饰器历史包袱重 | 约 20KB |
| rea | 字段级(atom 级) | 自动收集依赖 | 零依赖,框架无关 | 约 1.5KB(未压缩) |
rea没有华彩的 API 设计,它的省事来自极低的运行成本和极小的心智负担。依赖自动追踪这件事,别小看它,它把“我该监听什么”这个问题彻底抹掉了,你只管读值,更新会自动找上门。
2. 核心设计拆解:rea 的三大支柱
2.1 原子(Atom):最小状态单元
atom是响应式的起点。你可以把它理解为一个带闹钟的小盒子:
- 盒子里面存一个值;
- 你随时能观察它的值(
get()); - 你随时能换一个值(
set()); - 一旦值变了,它会自动通知所有关心它的人。
这个“通知”不需要额外的 map、event emitter 或依赖数组,因为订阅关系在“读取”那一刻就悄悄建立了。atom在内部维护一个Set存储订阅者,谁读过它,它就把谁记进小本本。当set发生时,它逐个通知。
// 简化版的 atom 内部结构 function atom(initialValue) { const listeners = new Set(); let value = initialValue; return { get() { return value; }, set(next) { if (Object.is(next, value)) return; value = next; listeners.forEach((listener) => listener()); }, subscribe(listener) { listeners.add(listener); return () => listeners.delete(listener); }, }; }函数式创建方式让每个 atom 都是独立闭包,没有类继承和this的烦恼,序列化也方便,后续做持久化时直接遍历即可。
2.2 订阅与更新机制:细粒度响应,不靠 diff
框架类方案喜欢用“虚拟树 diff”来算出最小更新范围,rea的路径完全不同:它根本没有树,它只有一张“谁依赖谁”的网状订阅表。具体来说:
- 组件或副作用在读取某个
atom时,会立即把自己注册为该 atom 的订阅者; atom.set()触发更新时,只通知直接读取它的订阅者;- 订阅者如果想继续计算派生值,那它自身又可能是其他派生值的依赖来源,更新会按依赖链自然传播。
这就实现了真正意义上的“精确打击”——页面里有 100 个组件,只有读取了那个变化字段的组件会重新运行,其他 99 个连肩都动不了。我第一次验证这个特性时,最大感受是:终于不用靠 memo 手动告诉 React 别乱跑了。
2.3 派生与计算:缓存、惰性求值与依赖追踪
derived是rea里我最喜欢的部分。它本质上是一个“有缓存的纯函数”。首次读取时会执行计算函数,把结果缓存下来,同时记录这次计算过程中访问了哪些 atom。之后如果那些 atom 没有变化,再次get()直接返回缓存;某个依赖变化时,派生值会在下一次读取时自动重新计算。
以购物车场景为例:
const priceMap = atom({ apple: 5, banana: 3 }); const cart = atom({ apple: 2, banana: 1 }); const total = derived(() => { const items = cart.get(); const prices = priceMap.get(); return Object.keys(items).reduce( (sum, key) => sum + items[key] * prices[key], 0 ); }); console.log(total.get()); // 计算出 5*2 + 3*1 = 13当cart或priceMap任意一个变化时,total自动失效。期间你没有手动告诉total“你应该看这俩数据”,它自己记得。
惰性的好处很实际:如果一个derived暂时没被任何组件使用,它永远不会被计算。这在传统方案里很难做到——比如你写了一个很重的列表过滤逻辑,但当前页面根本不渲染这个列表,传统 store 往往还是会照跑一遍。rea就不会。
2.4 为什么用微任务批量更新而不是同步派发
在atom.set()的设计里,一个很容易踩坑的点是:在事务型代码里连续 set 多个数据,如果每次 set 都立刻触发更新,会产生很多次中间态渲染。比如:
user.set({ name: '张三' }); user.set({ age: 30 }); user.set({ token: 'abc' }); user.set({ loggedIn: true });如果每次set都同步派发订阅,页面会闪四次。所以rea的默认策略是:把同一轮事件循环里的所有set收集起来,放到微任务队列末尾一次性派发。
import { batch } from 'rea'; // 也可以显式包裹批次 batch(() => { user.set({ name: '张三' }); user.set({ age: 30 }); user.set({ token: 'abc' }); user.set({ loggedIn: true }); }); // 这里订阅者只收到一次通知这样做的好处是:既避免了框架层 VDOM 合并更新的繁琐逻辑,又保证了更新以“最终状态”呈现在页面里。中间态对 UI 毫无意义,用户不需要看到一个“已经在登录但还没拉取到 token”的画面。
3. 从零上手:rea 完整实操指南
3.1 安装与项目环境准备
rea本身就一个 JS 文件,没有发布到任何公共仓库(还是给你源码自己构建更省事)。你只需要把核心文件复制进项目的src/libs/rea目录即可:
mkdir -p src/libs/rea然后创建入口文件,把后面的核心逻辑放进去,再配一下 TypeScript 的类型声明就完事。构建工具方面,Vite、webpack、tsup 都能用,因为它没有任何环境依赖,只要支持 ESM 即可。
需要安装 peer 依赖吗?不需要。rea不在 npm registry 上,也不存在 peerDependencies。这一点反而让它在供应链安全方面很省心。
3.2 创建你的第一个 store
在rea里store不是魔法类,它就是一个把多个 atom 聚合在一起的对象工厂。比如做一个用户信息 store:
// store/user.js import { atom, derived } from 'rea'; const userId = atom(null); const profile = atom(null); const isLoggedIn = derived(() => userId.get() !== null); export const userStore = { userId, profile, isLoggedIn, };你看,所谓 store 就是普通对象,里面每个字段都是一个响应式原语。没有 createStore、configureStore、Provider 这些仪式感操作。你想拆成多个 store 就拆,想组合就组合,跟普通对象一样自由。
读取和写入的完整操作:
import { userStore } from './store/user'; userStore.userId.set(1001); userStore.profile.set({ name: '李四', role: 'admin' }); // 读取的地方直接 get() console.log(userStore.isLoggedIn.get());这种写法虽然朴素,但任何读完代码的人都能立刻理解数据是怎么流进来的。
3.3 在组件里连接状态(React 和原生 JS 两种接入方式)
React 是日常工作中最常遇到的环境,rea给 React 提供的是一个useSyncExternalStore适配器。这个 API 是 React 18 之后官方推荐的连接外部数据源的方式,它完美对接了rea的订阅接口。
拿 React + TypeScript 举例:
// hooks/useAtom.ts import { useSyncExternalStore, useCallback } from 'react'; export function useAtom(atomInstance) { const subscribe = useCallback( (listener) => atomInstance.subscribe(listener), [atomInstance] ); return useSyncExternalStore(subscribe, atomInstance.get); }然后组件里用起来就是:
import { useAtom } from '../hooks/useAtom'; import { userStore } from '../store/user'; function UserProfile() { const profile = useAtom(userStore.profile); const isLoggedIn = useAtom(userStore.isLoggedIn); if (!isLoggedIn) return <p>未登录</p>; return <p>{profile?.name},你好</p>; }注意useSyncExternalStore(store.subscribe, store.getSnapshot)里面 store 必须保证每次 snapshot 引用稳定,才不会被 React 认为“变化了”。幸运的是atom.get()返回原始值,只要数据本身不变,引用就不会变。
原生 JS 接入更直接,因为没有框架层限制:
import { userStore } from './store/user.js'; userStore.profile.subscribe((profile) => { renderProfile(profile); });3.4 组合状态:列表、异步数据与表单的完整示例
我来写一个稍完整的实际场景:异步加载一个成员列表,支持本地筛选和重置。
import { atom, derived, effect } from 'rea'; // 原始数据,异步填充 const users = atom([]); // 当前搜索关键词 const keyword = atom(''); // 当前筛选的部门 const dept = atom('all'); // 派生:按关键词和部门过滤 const filteredUsers = derived(() => { const all = users.get(); const kw = keyword.get().trim().toLowerCase(); const d = dept.get(); return all.filter((u) => { if (d !== 'all' && u.dept !== d) return false; if (kw && !(`${u.name}${u.email}`.toLowerCase().includes(kw))) return false; return true; }); }); // 模拟异步加载 async function loadUsers() { const resp = await fetch('/api/users'); const data = await resp.json(); users.set(data); } // 统计在 effect 里自动更新 effect(() => { document.title = `成员列表(${filteredUsers.get().length} 人)`; }); // 重置筛选 function resetFilter() { keyword.set(''); dept.set('all'); }这段代码没有一行是多余的。派生筛选、异步赋值、副作用同步,全部由dera的自动依赖网络串联起来。组件里只需要useAtom(filteredUsers)即可。相比传统方案,省掉了写 action、reducer、thunk 的那一大坨模板代码。
表单场景也值得提一句:表单状态在 rea 里就是一组 atom 集合。由于 atom 本身可以嵌套对象,我更推荐把整个表单作为一个 atom 来管,写一个updateField(key, value)的小函数:
const form = atom({ email: '', password: '' }); function updateField(key, value) { form.set((prev) => ({ ...prev, [key]: value })); }这里form.set接收函数式更新,旧值作为参数传入,适合依赖前值的场景。注意 set 函数里的Object.is比较会拦截“相同引用”的更新,所以你必须返回新对象,而不是修改原对象——这一步我会在下面常见问题里展开讲。
4. 源码里的关键实现:200 行核心逻辑逐段拆解
4.1 状态容器与依赖收集是怎么写的
依赖收集是响应式库最“魔法”的地方,它的实现原理其实一句话就能说清:在读取值之前,先标记“当前正在运行的计算任务”,再把该任务注册到被读取的 atom 上。
当前运行的任务(activeTask)是一个全局概念 读取 atom.get() 时: 如果存在 activeTask,把 activeTask 加入该 atom 的订阅者集合 返回 atom 当前的值 任务运行完毕: 清空 activeTask这段逻辑的实现可以这么写:
// 全局活跃任务 let activeTask = null; function atom(initial) { const subs = new Set(); let value = initial; return { get() { if (activeTask) { subs.add(activeTask); } return value; }, set(next) { const nv = typeof next === 'function' ? next(value) : next; if (Object.is(nv, value)) return; value = nv; [...subs].forEach((task) => task.invalidate?.() ?? task.run?.()); }, subscribe(fn) { subs.add(fn); return () => subs.delete(fn); }, }; }注意订阅者既可能是derived的失效标记器,也可能是effect的重跑函数。两者在set通知时行为不同,所以rea在内部会对订阅者做一个简单标识。
4.2 批量调度器如何工作
批量调度器本质是一个微任务队列。核心逻辑是:
let batchDepth = 0; let pendingTasks = new Set(); function batch(fn) { batchDepth++; try { fn(); } finally { batchDepth--; if (batchDepth === 0) { const tasks = [...pendingTasks]; pendingTasks = new Set(); queueMicrotask(() => { tasks.forEach((t) => t.run()); }); } } }当dea检测到某个值变化时,不会立即执行订阅者,而是把订阅者投进pendingTasks。在同一个批次内,即使同一个订阅者被多个 atom 触发,也只会被加入一次(因为Set天然去重)。最后一个batch退出时,统一排一个微任务去执行。
这里有一点需要留心:queueMicrotask在微任务队尾合并,那么订阅者拿到的永远是“最终态”,不会看到中间状态。这对 UI 渲染是友好的,但如果你的某个 effect 想要依赖“逐步变化的中间态”,就需要显式用batch之外的同步派发方案,目前rea没有对外开放这个选项,因为我至今没遇到这个需求。
4.3 如何保证 TypeScript 类型推导友好
写 TS 库最怕“为了类型牺牲体验”,rea的设计保证了基本不需要显式标注返回值类型。
function derived<T>(compute: () => T): Derived<T> { let cached: T; let cachedKeys: Set<Subscriber>; let dirty = true; // 在 compute 执行期间记录依赖 const deps = new Set(); return { get() { if (dirty) { // 把自己设为 activeTask,再进行计算 const prevTask = activeTask; activeTask = this; try { cached = compute(); dirty = false; } finally { activeTask = prevTask; } } return cached; }, }; }T是泛型,由compute的返回值推导,因此derived(() => 用户输入长度)会自动推导为Derived<number>。在 React 的useAtom包装里,类型也能顺着泛型流过去,组件里取出来的值不需要as断言。
另一个类型体验的细节:atom<T>(init)可以根据初始值推导类型,像atom(0)就是Derived<number>,atom([] as string[])则得到Derived<string[]>。这个在写复杂对象时尤其重要,atom({ a: 1 })的类型就是{ a: number },你在set新对象时,编辑器会立刻给出齐全的字段提示。
5. 真实项目中的常见问题与排查经验
5.1 无限更新循环是怎么发生的
最常见的死循环来自effect里写入了自己的依赖。
// 错误示范 const count = atom(0); effect(() => { count.set(count.get() + 1); });这个 effect 读取了count,又写了count。一旦运行,就会触发自身重新运行,无限涨数。
查这类问题有个很实用的思路:在 effect 里临时加一个console.trace(),观察触发来源。当rea处于开发模式时,给每个 effect 的 invalidation 函数加上堆栈打印,一眼就能看到是哪条代码路径把 effect 重新引燃的。修正办法很简单:不要把“写入依赖”放在 effect 里,而是放在事件回调、接口回调、定时器回调里。
5.2 为什么我的组件不更新(订阅丢失)
排查任何响应式库的“不更新”问题,我都会首先怀疑订阅链路断了。自己写subscribe代码时尤其容易踩:订阅函数返回的取消函数忘了调用或重复调用,导致订阅被意外移除。
rea的subscribe遵循“调用即取消”模式:
const unsubscribe = store.count.subscribe(cb); // 需要取消时 unsubscribe();有一次我在组件卸载函数里useEffect(() => unsubscribe, []),结果因为依赖数组写成了每次渲染都清空重建,订阅老是被撤销,页面数据一直不动。经验是:订阅动作要么走useSyncExternalStore这个标准口子,要么自己管理时必须严格让 subscribe 与 unsubscribe 成对出现。
另外,注意“同一个 atom 在不同模块里的实例”问题。如果atom(0)写在模块顶层,它天然是单例;如果写在函数内部,每次调用都生成全新实例,那跨组件共享就成了笑话:
// 错误示范:在组件里创建 atom function BadComponent() { const count = atom(0); // 每次渲染都新建原子 return <button onClick={() => count.set(count.get() + 1)}>{count.get()}</button>; }所有需要共享的状态,都必须提到模块顶层,或者放进store工厂的返回值里复用同一个引用。
5.3 状态被意外共享的坑
与“不更新”相反的问题,是“跟手”:一个用户改数据,所有用户同步变。这在服务端渲染(SSR)场景里尤其致命,因为atom是模块级单例的话,多个用户请求会共享同一份内存状态。
对于 SSR,推荐按请求创建 store 容器:
function createAppStore() { return { user: atom(null), cart: atom([]), }; }每次进入请求处理器时createAppStore()一次,请求结束即丢弃。这能保证每个请求的数据完全隔离。如果要在组件里拿到当前请求的 store,则用 React Context 传递一份 store 实例,而不是直接 import 全局单例。
5.4 性能对比数据与优化建议
我自己在某个内部中后台项目上做过一次粗略对比。页面有 500 个列表行,每行绑定一个折叠展开状态,再配合一个全局筛选栏。改造前用的是传统 Context + useState 方案,筛选输入每个字符,整个列表区重渲染耗时约 35ms;换成rea后,输入字符时只有筛选结果组件重渲染,耗时降到 4ms 左右。
| 场景 | Context + useState | rea |
|---|---|---|
| 筛选输入每次击键 | 35ms | 4ms |
| 切换单个行展开 | 15ms | 2ms |
| 初始渲染 | 120ms | 115ms |
初始渲染差距不大,因为数据绑定本身没有太多要计算的;但后续交互上的差距非常明显。优化建议只有一条:把“频繁变化的原子”拆细。宁可多创建 20 个 atom,也不要裹在一个巨型对象里。atom.set是以“引用比较”判断变化的,一个巨型对象任何字段变化都会导致整个对象替换,引用必然变化,所有读取该对象的组件都得更新,这跟 Context 的老问题就没拉开本质差距。
6. 实际使用中的一些心得补充
源码虽然简单,但把它放进业务项目里还是会遇到一些设计决策。这里补充几个我后来总结的原则,能少走不少弯路。
第一个原则是“不要把所有数据都装进 atom”。很多刚接触响应式的人恨不得把每个局部变量都包成 atom,结果整个组件渲染逻辑被拆得七零八落。实际上只有需要“跨组件共享”或“跨时间保持”的数据才值得放进 atom。一个只在单个组件内部使用的 UI 开关,用useState就够了,包成 atom 反而增加心智负担。
第二个原则是“effect 里尽量只做外部副作用”。比如操作 DOM 不受 React 管理的位置、写 localStorage、发日志、更新 document.title 这类。如果只是想在某个状态变化后“重新计算另一个值”,优先用derived,而不是在 effect 里 set 另一个 atom——后者会绕回 5.1 的循环风险,前者是纯函数,更安全。
第三个原则关于异步。异步接口返回的数据,尤其是列表页的数据,我会单独建一个 atom 存“原始数据”,再搭配一个“加载中”的 atom,最后用 derived 去派生过滤排序后的结果。这样数据流的层次非常清晰:网络层只负责填原始数据,UI 层只读派生数据,两边的修改互不干扰。调试的时候用effect把请求参数打出来,每次 Click 请求参数变化,就能立刻确认触发链是否正常。
在实际项目里,rea代替了不少原来的状态管理代码,而且那个中后台项目上线后没有出现过因为状态导致的重大 bug。它最让我满意的地方是“没有魔法深坑”:源码就两百多行,任何一行逻辑都能看明白。
如果你也想用类似思路解决问题,建议先从小范围试起:选一个独立模块,把它的状态从 Context 迁到 rea,跑通后再逐步铺开。模块化替换比一次性大手术稳得多。后面我可能会再写一篇关于rea的持久化中间件和 devtools 时间旅行调试的扩展文章,不过那是后话了。