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); // 33computed返回一个带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 时通知、批量派发防抖"这句话时,市面上大部分状态工具的设计你都能快速看懂。