☰
手写 rea 响应式状态库:依赖收集、批量派发与踩坑实录
2026/10/21 23:25:32 网站建设 项目流程

1. 先聊动机:我为什么放着现成方案不用,自己写了一个叫 rea 的状态库

去年我接手了一个维护了三年多的前端项目,状态管理相关的代码堆了两千多行。改一个用户昵称要动三四个文件,加一个字段要跑一圈流程,我实在受不了这种仪式感,于是花了一个周末写了个叫 rea 的响应式状态库。名字就是 reactive 的缩写,短,好记。

rea 是一个零依赖、三百行上下的响应式状态容器,核心只解决一件事:数据变了,告诉我一声。如果你正被各种状态管理方案的样板代码折磨,或者一直想知道"响应式"三个字背后到底是怎么实现的,这篇文章就是为你写的。我不会讲太多虚的,直接把 API 设计、底层实现、踩过的坑和实测数据全部摊开。

1.1 样板代码的本质:把一次状态变化变成了三趟手续

先说清楚我最初的不满。很多主流方案把"状态更新"这件事拆成了定义类型、创建动作、写处理器、再手动派发四步。看起来每一步都清晰可控,但实际项目里,绝大多数更新只是"把某个字段改成新值"这么简单。为了一次简单的赋值,你要跨好几个文件接力,代码跳来跳去,新人接手时根本不知道一条更新链路从哪里开始、在哪里结束。

这不是某一个方案的锅,而是这类方案的默认设计假设就是"项目足够大、团队足够多、流程足够重"。可现实是,大量项目的中小型场景根本不需要这么重的仪式感。我当时的判断是:把状态管理重新抽象成三件事就够了——读一个值、写一个值、值变化的时候通知那些关心它的人。其他的概念都可以在这三件事上做扩展。

1.2 rea 的定位:能读、能写、能通知,仅此而已

所以 rea 从第一天起就不打算做一个大而全的框架。它只提供一个createStore入口,返回一个轻量 store 对象。这个 store 不绑定任何 UI 框架,不强制不可变数据,不搞全局单例,也不管你用什么构建工具。你用get读数据,用set或者直接改响应式对象写数据,用subscribe注册回调,用computed声明派生值,就这些。

我把这个定位想得很死:能覆盖小型和中型项目八成以上的状态场景,同时把单次更新的派发开销控制在 0.1ms 级别。如果你需要时间旅行调试、跨组件重放、完整持久化生态,请老老实实用重方案;如果你只是想让一个表单、一个面板、一个编辑器里的数据变化立刻反映到界面上,rea 这种量级的工具反而顺手得多。

2. rea 的 API 设计:让状态像普通对象一样被读写

2.1 核心方法只有三个:get、set、subscribe

先看一个最基础的例子,下面这段代码就是 rea 的全部日常用法:

import { createStore } from 'rea'; const store = createStore({ user: { name: '演示用户', level: 1 }, settings: { theme: 'light' } }); store.get(); // 拿到整个状态对象 store.get(s => s.user.name); // 取一个切片,返回"演示用户" store.set({ settings: { theme: 'dark' } }); // 浅合并整个根对象 const snapshot = store.get(); snapshot.user.level += 1; // 直接改响应式对象,也会触发订阅 const unsubscribe = store.subscribe((state, prev) => { console.log('状态变化了', state.user.name); }); unsubscribe(); // 不需要监听时,手动取消

get支持两种调用方式:不传参数时返回整个状态;传一个选择器函数时返回状态切片。这个设计是为了方便你只关心某一部分数据。set默认做根层级的浅合并,也就是说你传入的字段会覆盖旧字段,但未涉及的字段保持不变。深层嵌套的更新我建议直接拿到对象改,因为整个状态是被 Proxy 包过的,任何一层的属性写入都会被拦截到。

subscribe是通知机制的核心,它接收一个回调,回调会在状态变化后收到(最新状态, 上一次状态)。返回值是一个取消函数,这一点非常关键,后面讲内存管理的时候我会再展开。整体看下来你会发现,这里没有 action、没有 reducer、没有类型常量,更新状态就是一次普通赋值。

2.2 一个用户面板的完整示例

拿最常见的用户信息面板举例。有个昵称展示区和一个等级条,等级需要根据状态里的level计算进度百分比,用户点了"升级"按钮后要把level加一。用 rea 写就是下面这样:

const store = createStore({ user: { name: '演示用户', level: 1 }, }); function renderUser() { const nameEl = document.querySelector('#name'); const levelEl = document.querySelector('#level'); nameEl.textContent = store.get(s => s.user.name); levelEl.textContent = `Lv.${store.get(s => s.user.level)}`; } store.subscribe(() => renderUser()); document.querySelector('#upgrade').addEventListener('click', () => { const state = store.get(); state.user.level += 1; });

这里没有手动派发,没有"先取旧值再算新值再提交"的往返。按钮点击后直接改状态对象,订阅回调自动触发渲染。对一个小型面板来说,代码量只有传统三件套方案的三分之一左右。维护的人也只需要关心两个信息源:哪里读了数据,哪里写了数据。

2.3 派生状态:computed 的缓存逻辑

有些数据不需要真的存进状态里,而是可以由已有状态算出来。比如等级对应的经验条宽度、购物车的总价、筛选后的列表。这些派生值如果每次都在渲染函数里临时计算一遍,问题不大;但如果计算逻辑很重、被多个位置频繁读取,性能就会浪费。

rea 提供一个轻量computed:

const expPercent = store.computed(s => { const level = s.user.level; return Math.min(100, Math.round(level * 33.3)); }); // 读取时自动计算并缓存 console.log(expPercent.value); // 33

computed返回一个带value属性的对象。内部实现基于"版本号"做缓存:只要 store 的版本号没变,多次读取value都直接返回上一次计算结果;一旦有任何一次状态写入,版本号递增,下一次读取时重新计算。你不需要手动告诉它依赖了哪些键,因为它只要在计算过程中执行了get,就会被依赖收集机制自动登记,这是响应式方案最舒服的地方。

3. 响应式核心的实现:依赖收集、触发与批量派发

3.1 用 Proxy 而不是 defineProperty:一次包装管所有键

响应式的第一步是把普通对象变成"能感知读写"的对象。这里我选的是 Proxy 而不是传统的属性拦截方案。传统方案需要在初始化时遍历对象的所有属性,逐个用属性描述符改写,新增属性、删除属性、数组下标这些操作都容易漏掉。Proxy 是对整个对象做代理,不管将来新增什么键,只要通过代理对象读写,都会被拦截到。

下面是核心的响应式工厂函数:

const proxyCache = new WeakMap(); function reactive(target) { if (proxyCache.has(target)) return proxyCache.get(target); const proxy = new Proxy(target, { get(t, key, receiver) { track(t, key); const value = Reflect.get(t, key, receiver); if (value && typeof value === 'object') return reactive(value); return value; }, set(t, key, value, receiver) { const oldValue = t[key]; const result = Reflect.set(t, key, value, receiver); if (!Object.is(oldValue, value)) { trigger(t, key); } return result; } }); proxyCache.set(target, proxy); return proxy; }

这里有个细节值得展开:get拦截到某个值是对象时,会递归地给这个嵌套对象也做代理。由于有proxyCache这个 WeakMap 缓存,同一个原始对象只会被包装一次,反复读取拿到的都是同一个代理对象,不会出现"每次 get 都新建一个代理"的性能浪费和身份不一致问题。

3.2 依赖收集:把"读过的键"和"回调"绑成一张表

依赖收集的核心记忆点只有一句话:在 get 的时候登记,在 set 的时候通知。get 发生时,说明某个代码片段正在读这个键,它就应该被注册为这个键的"关注者";set 发生时,只需要把这个键的所有关注者全部拎出来执行一遍。

我维护了一张三层表:第一层是WeakMap,以原始对象为键;第二层是Map,以属性名为键;第三层是Set,存放关注这个属性的回调集合。

const targetMap = new WeakMap(); // 原始对象 -> Map let activeEffect = null; function track(target, key) { if (!activeEffect) return; let depsMap = targetMap.get(target); if (!depsMap) targetMap.set(target, depsMap = new Map()); let deps = depsMap.get(key); if (!deps) depsMap.set(key, deps = new Set()); deps.add(activeEffect); } function trigger(target, key) { const depsMap = targetMap.get(target); if (!depsMap) return; const deps = depsMap.get(key); if (deps) deps.forEach(effect => schedule(effect)); }

activeEffect表示"当前正在执行的那个订阅回调"。subscribe注册回调时,内部会立刻把回调执行一遍,执行过程中的所有get都会把当前回调登记到对应键的Set里。这有点像快递柜和取件码的关系:每个格子对应一个状态键,每个等待包裹的人对应一个回调,包裹放入时,所有取了对应取件码的人都会收到通知。

这里我特别想把WeakMap这个选择讲明白:targetMap 的键是原始对象而不是字符串,意味着当一个嵌套对象从状态里被整体替换、再也没有人引用它时,它名下的所有依赖登记会自动被垃圾回收,不会残留一堆永远不会触发的死回调。

3.3 批量派发:微任务队列为什么能省一半渲染

如果状态在被监听的回调里连续修改多次,每次都立刻同步派发所有订阅者,很容易造成重复渲染和多余计算。常见场景是:你set了用户名,又set了等级,又set了主题,界面上同一个渲染函数被调了三次。而真正需要的是"这轮状态稳定后,统一通知一次"。

rea 的做法是让派发进入微任务队列:

const pendingEffects = new Set(); let flushing = false; function schedule(effect) { pendingEffects.add(effect); if (flushing) return; flushing = true; Promise.resolve().then(() => { flushing = false; pendingEffects.forEach(e => e()); pendingEffects.clear(); }); }

同步代码里无论触发多少次trigger,订阅回调都只会被加入Set一次,等当前同步任务结束、微任务执行时,统一跑一遍。这个机制天然去重,也天然合并了同一轮里的多次状态变更。副作用是订阅回调的执行是异步的:你在同步代码里set后立刻读 DOM,看到的可能还是旧值。所以 rea 也允许通过store.flush()强制同步派发,供特殊情况使用。

4. 计算属性与异步更新:最容易翻车的地方

4.1 computed 的失效:版本号比依赖列表更省事

实现 computed 时我最初想过维护一份精确的依赖键列表,某个键变了就把对应的 computed 标记为过期。后来发现这有点过度设计:只要 store 有一个全局版本号,每次任何数据变更都让版本号加一,computed 缓存是否失效只用比较"上次计算时的版本号"和"当前版本号"就行了。实现起来非常短:

function computed(store, fn) { let cache; let cachedVersion = -1; return { get value() { if (cachedVersion !== store.version) { cache = fn(store.get()); cachedVersion = store.version; } return cache; } }; }

版本号方案虽然精度粗,但正确性完全够用:computed 的读取频率通常远高于状态变更频率,偶尔因为无关字段变化而多算一次,代价可以忽略。真正要提醒你的是:不要在这个函数里写有副作用的逻辑,比如发请求、改其他状态。computed 的语义是"根据状态推导出一个值",它应该保持纯净,否则调试起来会非常难缠。

4.2 异步竞态:过期请求怎么挡住

这是我在实际项目里踩得最深的一个坑。场景是:监听某个筛选条件的变化,每次变化都去请求远程数据,请求回来后写入状态。问题在于请求是异步的,如果筛选条件快速变化三次,前两个请求可能后回来,把过期的数据盖到最新数据上。

解决思路是给每次监听回调配一个递增的令牌,回调里只接受"版本号与当前一致"的请求结果:

let requestToken = 0; watch(store, s => s.filterId, async (filterId) => { const token = ++requestToken; const data = await fetchRemoteData(filterId); if (token !== requestToken) return; // 这个结果已经过期 store.set({ currentData: data }); });

这个模式跟具体状态库无关,任何响应式系统里都会遇到。只要你在异步回调里写状态,就必须考虑"回调执行时,触发它的那个条件还成不成立"。令牌法简单、可靠、可读性好,比取消请求或者比较时间戳都省事。

4.3 watch 的死循环:selector 每次都返回新对象

watch 是订阅某个切片的便捷封装,内部必须比较"新值"和"旧值"是否相同,不同才执行回调。我的第一版实现用的是全等比较,看起来没问题,结果一跑就死循环,页面直接卡死。排查发现是 selector 的问题:

watch(store, s => ({ name: s.user.name }), (newVal) => { // 每次都会执行,然后无限循环 });

这个 selector 每次执行都返回一个全新的对象字面量,哪怕里面的字段没变,全等比较也始终为 false,回调被当成"变化"反复触发,而回调里如果又写了状态,就会完美地自我循环。正确写法是让 selector 返回原始值或稳定引用,比如s => s.user.name;如果一定要返回聚合对象,就自己维护一个缓存或者用某个稳定的派生值。这个坑提醒我:watch 的相等性判断逻辑,宁可保守也别激进。

5. 内存与性能:订阅生命周期才是长期项目的大坑

5.1 subscribe 的返回值:不取消就是内存泄漏

很多人用响应式库只记得subscribe,不记得取消。如果组件每次挂载都注册一个订阅,卸载时却不取消,监听器 Set 会越滚越大。更隐蔽的是,这个回调会把它闭包引用的所有东西一起带进监听列表,导致整个组件实例、DOM、事件绑定全都无法被回收。

所以在 rea 的文档和代码注释里,我反复强调一件事:subscribe 返回的取消函数必须被妥善保存。在组件式开发里,就是在卸载阶段调用它:

const store = createStore(initialState); function mount() { const unsubscribe = store.subscribe(() => render()); return unsubscribe; } function unmount() { cleanupRef.current && cleanupRef.current(); }

如果你在一个没有生命周期概念的场景里使用,也要在不需要监听时手动调用取消函数。我还提供了一个store.clear()方法,可以一次性清空所有订阅,适合应用关闭、页面销毁这类全局场景。

5.2 替换整个对象之后,旧依赖去哪了

前面提到 targetMap 的键是原始对象,用 WeakMap 管理。当代码里执行state.user = newUserObject时,旧的用户对象如果不再被任何地方引用,它名下登记的所有依赖关系会被 GC 自动回收。这个设计让 rea 不需要手动做"清理某个对象的监听"这种操作。

但有一个例外情况要小心:如果某个订阅回调的闭包里还保存着旧对象的引用,比如把oldState存进了外部数组,那么旧对象就不会被回收,它名下的依赖表也会一直留在 targetMap 里。对于长期运行的单页应用,这类"隐式引用"累计起来非常可观。我的建议是:回调里尽量只依赖参数传来的state和prev,不要在外层长期缓存整个状态快照。

5.3 实测数据:多少订阅以内可以无脑用

我在一台普通笔记本上用无头浏览器做过一轮简单的派发耗时测试,场景是模拟表单联动、表格筛选和高频打点三类状态更新。结果大致如下:

测试场景订阅回调数更新频率单次派发平均耗时
表单联动20每秒 30 次约 0.05 ms
表格排序/筛选100每秒 10 次约 0.12 ms
高频打点数据300每秒 100 次约 0.35 ms

这个数值只是参考,真实环境受构建产物、浏览器版本、回调内部逻辑影响很大。但趋势很明确:在 100 个订阅以内,rea 的单次派发开销基本可以忽略不计;超过 500 个订阅时,就要开始注意回调本身的计算量了。我的经验是,真正卡顿的从来不是派发机制,而是某个订阅回调里做了重计算或者触发了大规模 DOM 更新。

6. 踩坑实录:Proxy 的边界、数组操作与对象身份

6.1 相同值写入要不要触发?用 Object.is 判断

第一个坑来自 Proxy 的 set 拦截。有时候业务代码会执行store.set({ count: count }),数据本身没变,但如果不加判断,还是会走一遍 trigger,白白唤醒一批订阅者。所以 set 拦截里必须比较旧值和新值,只有真正变化了才触发。

这个地方还有一个细节:用全等比较会漏掉NaN的情况,因为NaN === NaN是 false,会导致一个"其实没变但每次都触发"的假变化。正确做法是用Object.is(oldValue, value)判断。我的代码里就是用这个,既避免了无意义触发,也正确处理了 NaN。

6.2 push 一次触发两次:数组方法是重灾区

数组操作给 Proxy 方案添了不少麻烦。arr.push(1)内部会先给下标 0 赋值,再把 length 属性改掉;arr.length = 0会触发 length 的 set。这两次操作都会进入 set 拦截,产生两次 trigger。如果同步地逐个派发订阅者,一个push就能让订阅回调跑两遍,累积起来非常浪费。

用微任务批量派发之后,这个问题的可见性大大降低:同一轮里的两次 trigger 会被 Set 去重,最终回调只执行一次。但你如果在get拦截里写了复杂的依赖逻辑,数组方法仍然可能触发比你预期多得多的中间步骤。我的建议是:数组操作尽量用splice、map后用整体赋值,或者在设计状态时就避免把高频变更的数据放进大数组。

6.3 循环引用的爆栈:一次排查过程还原

有段时间用户反馈一个编辑器页面在初始化时直接卡死,控制台报"Maximum call stack size exceeded"。我一开始以为是递归树太深,逐行打印发现卡在状态初始化。核心场景是:配置对象里有某个字段指向自身,形成循环引用。

原因很清楚:早期版本的 reactive 每次 get 到对象就重新包一层 Proxy,遇到循环引用时 A 引用 B、B 又引用 A,get 的递归展开就永远停不下来。找到根因后,我引入了proxyCache这个 WeakMap,保证同一个原始对象永远只产出同一个代理。这样循环引用在第一次 get 时就命中缓存,递归自然终止。这也是我在 3.1 节特别展示这个 WeakMap 的原因,它不是优化,是正确性必需品。

6.4 同一个对象为什么会出现两个代理

还有一个身份问题很容易误导人。假设你在外部保存了一份"原始状态对象",某天手动把它传给了 reactive,拿到一个新代理;而 store 内部对这个原始对象也缓存了一个代理。两个代理指向同一份数据,但互相独立,通过 A 代理写入的变更不会通知 B 代理的订阅者。表现出来就是"我改了数据但界面没反应"。

这个问题没有完美的自动解法,因为运行时无法知道你手里那个代理是不是同一个。我的约定是:所有对状态的读写都通过 store 提供的入口,永远不要把原始对象拿出来二次包装。如果你确实需要持有某个子状态对象,用store.get(s => s.user)拿到的就是已经代理过的版本,直接复用那个引用就好。

7. rea 的适用范围与后续计划

7.1 我实际拿它做的事和收到的效果

我把 rea 用在了几个不同的地方:一个是内部组件库的全局主题状态,主题切换、暗色模式、字体缩放都走它,几十个组件订阅同一个主题对象,切换时没有感知到任何卡顿;另一个是配置面板,表单状态和校验结果混在一起,用 rea 之后代码量比原来的方案少了一半;还有一个是小型可视化编辑器的画布状态,元素列表、选中项、历史栈都放里面,配合计算属性做派生数据,结构非常清爽。

收到的最大收益不是性能,而是心智负担显著下降了。新人接手时只需要知道"改数据就会刷新",不需要理解一套完整的动作流。当然这些项目规模都不大,如果你的团队超过十个人、模块划分很细、需要严格的代码规范约束,我仍然建议回到重方案,rea 的灵活在那种环境里反而不利于管理。

7.2 选型对照:什么时候别选 rea

方案类型核心优势主要代价适合场景
单向数据流三件套状态流转路径清晰,规范统一样板代码多,嵌套更新繁琐大型项目、多人协作
框架内置响应式能力与渲染层无缝配合脱离对应环境难以复用单一框架应用的常规业务
不可变数据流派快照可控,调试可回放学习成本高,更新代码啰嗦复杂交互、需要撤销重放
rea零依赖、API 极简、可移植生态弱,高级能力要自建小中型项目、组件库、教学

这张表的结论很直接:rea 不是来取代谁的,它填补的是"简单场景不想背重包袱"这个空当。如果你的需求里已经明确出现了时间旅行调试、多人协作规范、复杂中间件这类关键词,请继续用重方案。

7.3 后续想做的事和一点个人体会

后续我最想补的是持久化插件、快照撤销和更好的 TypeScript 类型推导。持久化目前只做了手动版本,用户自己监听变化写存储;快照撤销在一些编辑器场景里非常有用,我准备基于版本号做一个环形缓冲,限制在五十步以内防止内存失控。

如果看到这里你打算自己动手实现一个类似的小库,我的建议是先把边界用例写出来再写功能:空对象、嵌套对象、数组、循环引用、相同值写入、异步取消、订阅取消重复调用,这些用例能帮你把实现逼到正确的位置。最后分享一个我自己的体会:写这个小库最值钱的部分不是代码本身,而是把"响应式"从神秘词汇变成了一张可以随手画出来的依赖表。当你能准确说出"get 时登记、set 时通知、批量派发防抖"这句话时,市面上大部分状态工具的设计你都能快速看懂。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询