资讯动态

手写响应式状态内核:从依赖收集到 React 适配的完整实践

发布时间:2026/10/18 9:06:40 来源:尧图企业网站定制
去年年中我在做一个内部数据产品的过程中被一堆联动筛选和碎片化状态折磨得够呛。表格要跟着十个筛选条件实时刷新筛选器之间还有各种相互依赖组件树里到处都在穿 props改了 A 筛选又要顺手去改 B 组件里的逻辑。那段时间我几乎每天都在给现成状态方案打补丁。后来实在扛不住动手写了一个极简的响应式状态内核代号就叫rea——取自 reactive 的前三个字母也算是我对重新认识前端状态管理这事儿的纪念。这篇文章会把这套东西完整拆开为什么放着成熟方案不用非要自己造轮子核心响应式原理怎么设计最小可运行的代码怎么落地以及接入真实项目后踩过的几个典型坑和完整排查过程。如果你手里只是几个页面跳来跳去直接用现成方案就行没必要折腾如果你的状态流已经变成一团乱麻、改一个需求要动三四层组件那 rea 这套思路值得看到最后。代码我尽量贴最小可运行版本不带无关噪声。1. 为什么放着 Redux 不用自己造一个叫rea的轮子1.1 我遇到的具体场景表格联动筛选与状态碎片最让我崩溃的项目是一个数据看板核心功能是几十个筛选条件联动一张几千行的明细表格。筛选器之间不是独立的比如选了区域会直接影响可选的产品线产品线变化又要清空某些维度的选中值。除此之外表格的分页、排序、列显隐、当前聚焦行都要在多个模块间共享。这种场景最难处理的不是状态变多而是状态之间的反向依赖。用现有的状态库做能实现但代码会被迫绕很多弯。举几个真实感受状态分散在多个 Store 里两个 Store 之间互相读取对方的派生数据初始化顺序一旦不对就出诡异 bug。一个筛选变化可能同时要更新表格查询条件、重置分页、清空部分筛选、修改 URL 参数。这些更新逻辑分散在不同组件里最后变成在 A 组件的 effect 里改 B Store 的数据。想抽一个筛选变化总线出来又发现依赖关系没法用一张表描述清楚最后还是变成手写事件队列。说白了状态库本身没有问题问题是它给我的抽象层级和我要做的事不匹配。我要的是某个值变了依赖它的地方自动重算而不是某个 action 派发了每个 reducer 都跑一遍然后容器组件决定要不要更新。1.2 和主流方案对比后的取舍思路当时我认真比较了几条路大概是这样维度Redux 风格MobXZustand我想要的 rea心智模型单向数据流集中管理可变状态 自动追踪外部 store selector可变状态 自动追踪派生状态需手动用 selector / memo 处理computed自动派生selector 手动订阅未来可加 computed本地组件状态管理需要额外接入偏重可直接用尚可直接 useStore方便开箱即用 useReactive学习成本中等偏高中等低很低依赖追踪粒度组件级属性级属性级需写 selector属性级核心 组件级适配层MobX 其实已经很接近我想要的模型了。但当时有两个顾虑一是它引入的 observable 体系和类装饰器心智比较重我不想为了几十个页面把整个团队的认知成本拉高二是 MobX 的边界比较复杂什么时候用 computed、什么时候用 reaction、什么时候用 autorun需要一定经验才能拿捏团队新人容易写歪。我真正想要的是一个小到能一眼看穿的核心reactive 包装对象effect 注册副作用对象属性变化时自动触发依赖它的 effect。这个模型用 Proxy 实现核心代码只有三百行左右团队里任何人看一遍源码就能完全理解它的行为边界。1.3 rea 的边界只解决一件事所以 rea 从一开始就没打算当一个大而全的方案。它不做路由状态不做异步请求缓存不做持久化也不做跨 Tab 同步。它只做一件事本地响应式状态。一个 rea 状态对象的生命周期是这样的reactive()把一个普通对象变成响应式代理。组件通过useReactive()拿到状态组件渲染期间访问到的属性会被收集为依赖。任何属性变化触发依赖该属性的组件重新渲染同时驱动effect()注册的副作用逻辑。这也是一开始设计时最明确的边界不解决服务端状态那玩意儿用专门的请求方案去管不解决跨模块巨型全局 Store那种情况状态之间耦合太深应该先去优化组件结构而不是再套一层状态库。2. 响应式内核的三大块依赖收集、更新调度、清理机制2.1 一次赋值背后发生了什么先看一个最简单的场景const state reactive({ count: 1 }); effect(() { document.title 当前计数${state.count}; }); state.count 2;这行state.count 2执行时rea 内部会经历三个步骤trigger触发找到count属性对应的依赖集合把里面所有的 effect 都标记为待执行。schedule调度不立即执行这些 effect而是把它们放进一个待执行集合在下一个微任务里统一批量跑。flush冲刷微任务到达时按顺序执行所有待执行的 effect每个 effect 执行前先清掉自己旧的依赖关系执行时再重新收集。这个设计和 Vue 的响应式原理相似但细节上有不少差异。尤其是第三步的清理旧依赖是很多人自己写响应式系统时最容易漏的。2.2 track 与依赖收集为什么用 WeakMap依赖收集的核心数据结构是三层映射WeakMap目标对象, Map属性名, Set副作用。type Key string | symbol; type Dep SetEffect; const targetMap new WeakMapobject, MapKey, Dep(); let activeEffect: Effect | undefined;三个层级的作用分别是第一层WeakMap以目标对象为 keyvalue 是这个对象所有属性的依赖表。用 WeakMap 而不是 Map是因为当原始对象不再被任何地方引用时整个依赖表可以被垃圾回收不需要手动释放。这是 Proxy 响应式设计里最容易被忽视的内存泄漏隐患。第二层Map以属性名为 keyvalue 是依赖这个属性的所有 effect 的集合。第三层Set存 effect 对象天然去重。依赖收集发生在get捕获器里function track(target: object, key: Key) { if (!activeEffect) return; let depsMap targetMap.get(target); if (!depsMap) { depsMap new Map(); targetMap.set(target, depsMap); } let dep depsMap.get(key); if (!dep) { dep new Set(); depsMap.set(key, dep); } dep.add(activeEffect); activeEffect.deps.add(dep); }注意最后一行每个 effect 自己也会记录我依赖了哪些 dep 集合。这个反向关系是后面清理机制的基础。2.3 trigger 与调度器为什么不在赋值时立刻执行最朴素的想法是属性一变化就立刻调用依赖的 effect。但这会带来两个问题重复执行连续两次赋值每次都触发一次 DOM 更新或网络请求浪费性能。循环引用effect 执行时读取了同一个状态可能再次触发自己。所以我在 trigger 里不立即执行只把 effect 加入jobSet然后在微任务里统一冲刷const jobSet new SetEffect(); let pending false; function trigger(target: object, key: Key) { const depsMap targetMap.get(target); if (!depsMap) return; const dep depsMap.get(key); if (!dep) return; dep.forEach(effect { // 防止 effect 触发自己的更新形成死循环 if (effect ! activeEffect) { jobSet.add(effect); } }); scheduleFlush(); } function scheduleFlush() { if (pending) return; pending true; queueMicrotask(() { pending false; const effects Array.from(jobSet); jobSet.clear(); effects.forEach(effect effect.run()); }); }这里用了queueMicrotask比setTimeout(0)更快而且保证在浏览器渲染之前完成。jobSet的天然去重也保证了同一个 effect 在多次属性变化下只执行一次。用生活类比就是多个水龙头同时开你不必每个水龙头滴水都去接一次而是等水池满了统一排一趟水。React 18 的自动批处理、Vue 的 nextTick、Zustand 的批量订阅本质都是这个思路。2.4 cleanup动态依赖必须清理这一步是网上很多简化版响应式实现不会写的但真实场景里逃不掉。看一个场景const state reactive({ page: 1, keyword: }); effect(() { if (state.page 1) { console.log(第一页, state.keyword); } else { console.log(其他页); } }); state.keyword abc; // 如果 page 还是 1这个 effect 应该重新跑 state.page 2; // page 变成 2 之后keyword 变化其实不需要再跑这个 effect如果不在每次执行前清掉旧依赖第二次把page改成 2 之后effect 内部不再访问state.keyword了但旧的依赖集合里仍然存着这个 effect。等到 keyword 再变effect 会被错误地触发这就是经典的幽灵依赖。所以Effect.run()里第一步是cleanup自己class Effect { deps: SetDep new Set(); parent?: Effect; constructor(public fn: () void) {} run() { cleanup(this); this.parent activeEffect; activeEffect this; try { return this.fn(); } finally { activeEffect this.parent; this.parent undefined; } } stop() { cleanup(this); jobSet.delete(this); } } function cleanup(effect: Effect) { effect.deps.forEach(dep { dep.delete(effect); }); effect.deps.clear(); }这里parent指向是为了处理嵌套 effect。如果 effect 里又注册了另一个 effect后者执行期间必须把activeEffect暂存起来等它跑完再恢复外层。否则内层 effect 收集依赖时会错误地把自己归到外层 effect 的依赖集合里。3. 手写最小可运行的 rea 核心从 Proxy 到 React Hook3.1 reactive 函数拦截读、写、遍历、删除构造函数不复杂麻烦的是要处理到哪些捕获器。基础版只需要get和set但真实可用的版本必须把has、ownKeys、deleteProperty也接上否则对象遍历和新增删除属性时响应会失效。const reactiveMap new WeakMapobject, any(); const ITERATE_KEY Symbol(iterate); function reactiveT extends object(target: T): T { if (reactiveMap.has(target)) { return reactiveMap.get(target); } const proxy new Proxy(target, { get(obj, key, receiver) { const res Reflect.get(obj, key, receiver); track(obj, key); return res; }, set(obj, key, value, receiver) { const old Reflect.get(obj, key, receiver); const res Reflect.set(obj, key, value, receiver); if (old ! value) { trigger(obj, key); } return res; }, has(obj, key) { track(obj, key); return Reflect.has(obj, key); }, ownKeys(obj) { // 让 for...in / Object.keys 这类遍历也变成响应式 track(obj, ITERATE_KEY); return Reflect.ownKeys(obj); }, deleteProperty(obj, key) { const had Reflect.has(obj, key); const res Reflect.deleteProperty(obj, key); if (had) { trigger(obj, key); trigger(obj, ITERATE_KEY); } return res; }, }); reactiveMap.set(target, proxy); return proxy; }几个容易忽略的细节先说破get里的track无条件执行是因为 track 内部会判断有没有activeEffect没有就直接返回所以性能开销可以忽略。set里对比新旧值避免给同一个 key 赋相同的值还触发整个依赖链。ownKeys不能像get一样追踪某个具体属性只能追踪一个固定的ITERATE_KEY符号。删除属性时不仅要trigger被删的属性还要trigger ITERATE_KEY因为for...in的键列表变了。3.2 effect 与嵌套 effect 的栈结构上面 Effect 类的run方法里已经写了核心逻辑。实际注册函数是这个function effect(fn: () void) { const e new Effect(fn); e.run(); return () e.stop(); }返回的停止函数在组件卸载、监听器释放时调用防止泄漏。3.3 useReactive面向 React 适配层的对象级订阅核心层的依赖追踪是属性级的但如果直接拿到 React 里用会遇到一个麻烦React 的渲染粒度是组件级它不会因为你访问了某一个属性就精确重绘而是整个函数组件重新执行。如果适配层也做属性级订阅就需要把 React 的渲染函数整体包进 effect 里就像 Vue 的渲染函数那样。这做得到但实现复杂度会高一截初期没必要。所以我在适配层做了一个务实取舍核心保留属性级追踪React 适配层退化为对象级订阅。只要reactive对象上任何一个属性变化就触发组件重渲染。import { useReducer, useRef } from react; type Listener () void; const listeners new WeakMapobject, SetListener(); function addListener(target: object, listener: Listener) { let set listeners.get(target); if (!set) { set new Set(); listeners.set(target, set); } set.add(listener); } // 在 trigger 内部额外广播对象级监听器 function notifyObjectListeners(target: object) { listeners.get(target)?.forEach(fn fn()); } export function useReactiveT extends object(initial: T): T { const [, forceRender] useReducer((c: number) c 1, 0); const ref useRef{ proxy: T } | null(null); if (!ref.current) { ref.current { proxy: reactive(initial) }; // 注意这里要确保每次 set 时都触发对象级订阅 // 实际实现需要在 reactive 的 trigger 里调用 notifyObjectListeners(target) } return ref.current.proxy; }这版代码为了展示原理做了一点简化说明。我在真实项目里其实是在reactive内部维护了一个可选的listeners集合trigger时除了执行属性级依赖也广播对象级监听器。实测效果一个useReactive对象在组件里被使用时不管改了哪个属性组件都会重新渲染。粒度粗一点但和 React 的模式天然匹配没有反模式。3.4 跑通的第一个 Demo两个筛选器的联动核心里程碑是让下面这种代码跑通function FilterBar() { const filters useReactive({ region: 华东, product: 全部, status: active, }); // 产品线可选列表依赖 region const productOptions getProductOptions(filters.region); return ( select value{filters.region} onChange{e { filters.region e.target.value; filters.product 全部; }} {/* options */} /select select value{filters.product} {productOptions.map(...)} /select ); }正是这个 Demo 让我确信方向是对的筛选联动逻辑不需要在 onChange 里手动写一长串重置条件而是在给filters.region赋值时同步把filters.product归位。数据的变化会引起依赖它的所有部分自动更新代码从命令式串联变成了声明式依赖。4. 接入项目后的排错全过程四个典型事故的排查链路4.1 事故一数组 push 导致 effect 连环执行现象是一个表格的筛选条件变化后本该只刷新一次列表控制台里却出现了三到四次重复请求。排查过程很典型。我先在 effect 回调里加了一个计数器确认重复执行然后在trigger里打出日志观察每次触发的是哪个 key。结果发现一个state.list.push(item)操作触发了两次 trigger一次是list数组的length属性变化一次是2新元素的下标属性变化。这两个 trigger 先后把同一个 effect 加入jobSet。由于jobSet是Set同一个 effect 只会被执行一次。到这里我的实现是没问题的。继续往下查发现重复请求不是 effect 重复执行而是 effect 内部自己的逻辑里有两处对响应式变量的读取其中一处读取又写成了一次间接更新……最终结论分两层第一层jobSet去重和微任务批量执行确实挡住了大部分重复执行第二层如果 effect 内部又触发了异步请求链要确认是不是trigger时把activeEffect排除的判断漏了。我最初版本的判断是if (effect ! activeEffect)这个条件在嵌套 effect 场景下不够严谨后来改成在run()里用parent链路判断当前执行链上是否已经包含该 effect彻底避免自触发造成的循环。4.2 事故二for...in 遍历列表不更新现象很诡异表格组件用for...in遍历一个 reactive 对象渲染行手动修改其中一行数据的某个字段时表格内容不变。但是直接用变量读取字段的地方是能更新的。这个问题最开始我怀疑是组件没有重渲染。加了日志发现组件确实没重渲染说明trigger压根没触发。再往上查发现for...in循环并不会对每个 key 都走get捕获器它首先要拿到对象自身可枚举键的列表然后才逐键访问值。而我最初的 Proxy 只实现了get和set没有实现ownKeys和has所以键列表的变化根本不会被追踪。修复就是在ownKeys里track(obj, ITERATE_KEY)在新增或删除属性时trigger(obj, ITERATE_KEY)。这里还有个细节新增一个原本不存在的属性时即使set里的新旧值都是undefined的对比不成立旧值是 undefined新值是具体值也要触发ITERATE_KEY让遍历类副作用重新执行。这个坑后来统一归纳成一条规则只要对象的形状变了必须触发遍历级依赖。4.3 事故三effect 回调里的旧值缓存这个坑严格说不是 rea 的问题而是 React 闭包和响应式状态结合的经典事故。组件里写了这样的代码function TableRow({ rowId }: { rowId: string }) { const table useReactive(globalTableState); // 渲染时取出当前行 const row table.rows[rowId]; const handleSave useCallback(() { // 问题出在闭包捕获的 row 是本次渲染时的快照 saveRow(row); }, [row]); return button onClick{handleSave}保存/button; }当table.rows[rowId]被外部修改后组件重新渲染新的row生成了但handleSave的闭包里如果依赖数组没写对仍然拿着旧row去保存用户就以为保存的是新数据实际上提交的是旧值。排查这类问题的思路是先在保存接口前打印row确认是不是旧对象然后在handleSave依赖数组里补上row。但更根本的解决方法是回调函数内部直接访问响应式对象而不是捕获渲染时的局部变量。const handleSave useCallback(() { saveRow(table.rows[rowId]); }, [rowId, table]);这个习惯在接入响应式状态库后尤其重要。响应式对象的优势本来就是总能拿到最新值如果代码里把值提前复制到闭包里等于主动放弃了响应式的最大红利。4.4 事故四React StrictMode 下订阅重复开发环境一切正常但代码在 React 18 严格模式下一挂载状态更新就出现双倍执行。排查链路先怀疑是effect重复注册。检查useReactive的实现发现useRef初始化的代码严格模式下会被执行两次组件实例被创建两次但订阅函数如果放在useEffect里且清理函数没有正确 stop第一次实例的订阅就会残留。修复方式是确保适配层生命周期和组件生命周期严格对齐useEffect(() { // 注册对象级监听 addListener(ref.current.proxy, listener); return () { removeListener(ref.current.proxy, listener); // 如果该对象没有别处使用整个 reactive 相关的监听也一并释放 }; }, []);这起事故给我最大的教训是写状态库的适配层时框架的生命周期语义比状态库自身的语义更优先。开发模式的严格模式、热更新、组件卸载时的异步回调任何一个时序问题都会表现为状态库行为怪异。5. 性能优化细节从明显卡顿到跟手5.1 第一个版本为什么卡初版跑了一个模拟场景一张 500 行的表格每一行有 5 个单元格依赖全局筛选条件筛选条件每次变化会同时更新 20 个状态字段。整轮更新大概要 120ms在滚动和输入时会明显感到掉帧。卡顿来源主要有两个。第一是set捕获器里每次都触发对象级监听器导致所有订阅了该对象的组件同时重渲染哪怕它们只关心其中一个属性。第二是 effect 执行时cleanup的循环开销属性多了之后依赖集合的清理和重建占总执行时间比例不小。5.2 三个有效的优化手段第一个优化是属性级通知降级为按需对象级。在useReactive适配层增加一个selector参数组件可以声明自己只关心哪些属性只有这些属性变化时才触发重渲染。实现上就是trigger的时候比对这个组件声明的属性列表。实际项目里 80% 的组件不需要这个能力但表格行这种高频组件加上之后收益立竿见影。第二个优化是effect 的依赖清理只在必要时做。默认每次run都全量 cleanup 再重建但如果 effect 的回调函数没有条件分支依赖集合通常是不变的。我加了一个快速路径先记录本次执行前的依赖快照执行后如果新的依赖集合和旧的一致就不触发cleanup和重建。第三个优化是避免在大对象上挂太多 effect。比如表格筛选状态、排序状态、列配置这种变化频率差异很大的状态不要全部塞进一个reactive对象里。拆成三个独立对象粒度小了一次更新影响的范围自然就小了。优化后同样的模拟场景整轮更新从 120ms 降到 40ms 左右体感从卡顿变成跟手。当然这个数字只是我本机开发环境的参考不同设备差异很大但优化的方向是确定的减少不必要重渲染、减少依赖集合的重建、缩小状态对象粒度。5.3 内存泄漏WeakMap 不是万能药WeakMap 解决的是原始对象不再被引用时依赖表可以被回收但解决不了另一个泄漏effect 本身被依赖集合持有如果 effect 从不清理就算原始对象已经被丢弃依赖集合里的 effect 对象也还活着。所以每个effect注册时最好同时返回stop函数组件卸载、页面切换、定时器清理时都要调用。我在项目里统一规定凡是effect()注册的副作用必须在对应生命周期钩子的清理阶段调用返回的stop()函数。这个习惯带来的内存问题几乎绝迹。6. rea 和主流方案的最终对比以及怎么选6.1 一张信息量足够的对比表维度Redux / RTKMobXZustandVue Reactivityrea状态更新模型不可变更新reducer 纯函数可变状态自动追踪外部 storeselector 订阅可变状态自动追踪可变状态自动追踪依赖追踪粒度无内置靠选择器比对属性级属性级需手写选择器属性级核心属性级适配层对象级派生状态Reselect / 手动 memocomputed 自动缓存selectorcomputed后续可扩展批量更新手动或框架自动自动批量自动批量自动批量微任务批量学习曲线中高中低中极低调试工具生态强一般一般强几乎没有适合场景大团队强约束中大型业务中小型组件库框架内部小型自研内核 / 深度定制6.2 什么场景下用 rea 是舒服的实际用下来rea 最适合的是局部、高频、多状态联动的场景。典型是复杂筛选抽屉几十个筛选条件互相有依赖需要联动重置。表格交互状态排序、列显隐、行选中、当前展开项这些状态分散在多个组件里但逻辑上是一个整体。图表联动一个图表上选中某个维度其他图表要同步变化且选中状态本身还要支持反选和清除。这些场景的共同点是状态之间的依赖关系明确更新频率高且基本都是局部使用。用 rea 的useReactive一把梭代码量确实比 Redux 那一套少很多。6.3 什么场景我会劝你别用 rea如果是以下几种情况我会明确建议用成熟方案需要时间旅行调试Redux DevTools 的时旅能力在复杂业务里价值巨大rea 不会去实现也不该实现。团队协作需要强约束团队规模一大Redux 的 action/reducer 结构其实是一种流程约束防止大家随手在组件里改全局状态。rea 的可变模型自由度太高约束全靠自觉人一多必乱。跨模块共享的海量全局状态这种场景首先应该考虑的是模块边界是否合理而不是套状态库。rea 解决不了架构问题它只是把状态联动变得简单。6.4 一个可以继续延伸的方向rea 的内核实际上不绑定 React。它暴露的reactive/effect可以接到任何框架上Vue 的渲染周期、小程序的自定义组件、甚至原生 Canvas 的动画循环。我在项目里已经把它接到一个 Canvas 图表渲染层上数据变化直接驱动图表重绘效果很干净。如果有兴趣下一步可以试着自己写一套computed派生状态的实现那个会更有挑战。最后留一个实际操作的收尾说一个我从这个项目里沉淀下来、在之后其他项目里也反复验证的技巧任何自己写的响应式状态库一定要有一个全局依赖可视化的调试入口。我最初只写了targetMap的数据结构排查问题时全靠手动加日志效率很低。后来我在开发模式暴露了一个debugSnapshot()函数把当前所有响应式对象、属性、依赖的 effect 数量打印出来。遇到某个状态变化后页面没更新的问题直接打开这个快照看一眼目标属性名下有没有依赖记录问题就定位了一半。这个方法帮我把 4.2 那个for...in的问题从猜了半小时缩短到两分钟定位。如果你也打算自己写一套响应式内核强烈建议在一开始就把这个调试能力设计进去。它不复杂但真的能在后面省下大把排查时间。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑