200行TypeScript实现极简响应式状态库:自动依赖追踪与细粒度更新

📅 发布时间:2026/10/21 6:30:19
200行TypeScript实现极简响应式状态库:自动依赖追踪与细粒度更新
最近手头项目多状态管理这块来回折腾了几趟后来干脆自己写了一个极简的响应式状态库名字就叫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 树强耦合 ReactReact 自带Redux整个 store 切片手动写 selector shallowEqual可用中间件适配但心智重约 12KB传统 MobX属性级observable自动收集心智较轻但装饰器历史包袱重约 20KBrea字段级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 derivedT(compute: () T): DerivedT { let cached: T; let cachedKeys: SetSubscriber; 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(() 用户输入长度)会自动推导为Derivednumber。在 React 的useAtom包装里类型也能顺着泛型流过去组件里取出来的值不需要as断言。另一个类型体验的细节atomT(init)可以根据初始值推导类型像atom(0)就是Derivednumberatom([] as string[])则得到Derivedstring[]。这个在写复杂对象时尤其重要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 useStaterea筛选输入每次击键35ms4ms切换单个行展开15ms2ms初始渲染120ms115ms初始渲染差距不大因为数据绑定本身没有太多要计算的但后续交互上的差距非常明显。优化建议只有一条把“频繁变化的原子”拆细。宁可多创建 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 时间旅行调试的扩展文章不过那是后话了。