做过带列表页的项目的人,大概率经历过这种场景:页面一打开,七八个接口同时发出请求,每个区块各自维护 loading、success、failed 这些状态,结果谁先返回、谁后返回、谁失败了,最后根本说不清。更头疼的是,某些区块依赖另外几个接口的数据,其中一个失败时,整条链路就会卡在一个“永远转圈”的状态里。我们内部解决这个问题时,做了一个代号叫 rea 的小模块,全称按我们的理解是 Resource Event Actions,把资源、事件、动作这三件事统一收口。rea 本身只有几百行,不依赖任何第三方库,却稳定解决了资源状态收敛、依赖调度、请求合并、失败重试这四个问题。这篇文章把 rea 的思路、实现细节和踩坑记录完整写出来,给正在为前端资源管理头疼的朋友一个可落地的参考方案。
1. 内容整体设计与思路拆解
1.1 先回答最核心的问题:为什么需要这样一层资源管理
很多项目一开始并不是没有状态管理,而是状态管理得太“散”。最常见的写法是在每个页面组件里定义几个 Boolean 变量:isLoading、hasError、data,然后在接口回调里分别 set 一下。功能简单的时候没问题,但一旦出现两个接口共享同一个 loading、或者一个区块要等待三个接口全部完成才展示,代码就会变得很难维护。
rea 要解决的关键问题,不是“数据存在哪”,而是“资源处于什么状态、下一步应该做什么”。它不关心数据具体长什么样,也不负责渲染任何 UI,只做三件事:登记状态、调度依赖、通知订阅者。把这三件事集中到一个模块里,业务代码就不需要到处散落状态判断了。
1.2 确定功能边界:rea 不是什么
设计 rea 的第一件事,是明确它“不做什么”。
第一,它不拦截网络请求。真正发起请求的仍然是业务方自己的函数,rea 只接收一个返回 Promise 的加载器,并在状态变化时把结果广播给订阅者。
第二,它不缓存业务数据。rea 知道一个资源加载成功,但它不保存响应体,数据仍然由业务方自行持有。因为不同的使用场景对数据缓存策略的要求差别很大,强行做缓存只会让模块变得臃肿。
第三,它不做 UI 渲染。状态变化之后,UI 怎么更新由订阅者决定,rea 只保证“该通知的都会通知到,而且不重复通知”。
这个边界带来的直接好处是:rea 可以嵌入任何技术栈。无论你用的是组件框架、原生 JavaScript,还是轻量级的模板引擎,都能把它当作一个独立的协调者使用。我当时的做法是把它写成一个工厂函数,传入不同的 loader 适配器就能接上业务代码。
1.3 选型思考:为什么是发布订阅而不是轮询或全局状态存储
资源状态发生变化时,最直观的做法是提供一个全局对象,所有组件每帧轮询检查一遍。但轮询的缺点是大量无意义的检查,而且状态更新有延迟。另一个做法是引入完整的全局状态管理方案,但对于一个资源协调模块来说,那套时间旅行、中间件机制又显得过重。
rea 最终选择了发布订阅模式,原因是这个场景非常符合“推模型”的特征:状态只在特定时间点变化,订阅者希望在变化发生的瞬间收到通知,而不是自己反复去查。它就像一个多人协作的群里,某个成员完成工作后只需要在群里说一声“我做完了”,而不需要挨个敲门通知,同时其他人也不需要每隔几分钟就去问一次“你做完了吗”。
1.4 取舍:为什么选择自己写而不是依赖现成框架
也许你会问,市面上已经有成熟的整包方案,为什么还要自己写一个 rea?我在做技术选型时认真考虑过这个问题。
现成方案强在通用性,但通用性意味着接口复杂,学习成本高。而 rea 的场景很聚焦:需要状态机、需要依赖关系、需要失败重试,并用不了那些大方案里 80% 的功能。自己实现的最大好处是可控——每个状态流转都是我亲手写的,出了问题时能直接打开源码定位,不需要翻一层层封装。同时,几百行代码的调试和测试成本,远远低于引入一个大依赖的成本。
这不是说现成方案不好,而是当你的资源管理需求只有三个明确动作时,自己写一个收口模块反而更干净。如果你也想复刻类似方案,建议在自己的真实业务里先跑一个原型,感受一下“状态可预测”带来的安全感,再做最终决定。
2. 核心细节解析与实操要点
2.1 资源状态的完整定义与合法流转规则
rea 把每个资源实例的状态定义为五个:pending、running、success、failed、cancelled。为什么不用简单的 loading 和 error 两个状态?因为缺少“取消”和“待重试”这两个中间态,很难精确表达真实场景。
状态流转规则如下表所示:
| 当前状态 | 可流转到状态 | 触发条件 |
|---|---|---|
| pending | running | 加载器开始执行 |
| pending | cancelled | 注册后被取消,尚未开始请求 |
| running | success | 加载器成功返回 |
| running | failed | 加载器抛出错误或运行超时 |
| running | cancelled | 外部主动取消 |
| failed | pending | 触发了重试逻辑,重置等待队列 |
| cancelled | pending | 重新激活资源 |
这张表是整个 rea 的骨架。所有对外方法都围绕这些流转规则设计,任何不合法的状态迁移都会被拒绝并给出告警。我强烈建议你在实现自己的版本时,先把这张表打印出来贴在屏幕上,因为绝大多数状态混乱问题都源于“某个状态被漏掉了”。
2.2 订阅与通知机制:防止重复通知和遍历陷阱
rea 内部维护了一个轻量事件总线,核心只有on、off、emit三个方法。订阅机制是整个模块的基础,但越基础越容易出错。
一个我踩过的坑是:在emit过程中遍历监听器列表,同时某个监听器内部又执行了off注销了自己,导致数组被修改,进而出现“本来该收到的通知被跳过”的情况。解决办法很简单,在遍历前先把监听器数组slice()拷贝一份快照,然后基于快照遍历。
function createBus() { const listeners = new Map(); return { on(type, handler, token) { if (!listeners.has(type)) { listeners.set(type, []); } const item = { handler, token }; listeners.get(type).push(item); return () => this.off(type, handler); }, off(type, handler) { const list = listeners.get(type); if (!list) return; const index = list.findIndex(item => item.handler === handler); if (index !== -1) list.splice(index, 1); }, emit(type, payload) { const list = listeners.get(type); if (!list) return; list.slice().forEach(item => { try { item.handler(payload); } catch (error) { console.error(`[rea] listener error on ${type}:`, error); } }); }, }; }另外,监听器本身也应该支持传入一个token,这样在页面销毁时可以批量清理。后面会讲内存泄漏的排查,这正是关键点之一。
2.3 请求合并:避免同一资源被重复加载
在实际业务里,多个区块可能同时需要同一份数据。例如页面头部需要用户信息,侧边栏也需要用户信息,如果两者各自触发一次加载,就会发出两个一样的请求。rea 维护了一个inflightMap,记录正在执行中的资源加载 Promise。当新的订阅者请求同一个资源 ID 时,如果发现已经有加载任务在执行,就直接复用同一个 Promise,而不是再次调用 loader。
function load(resourceId, loader) { if (inflightMap.has(resourceId)) { return inflightMap.get(resourceId); } const promise = executeLoader(resourceId, loader).finally(() => { inflightMap.delete(resourceId); }); inflightMap.set(resourceId, promise); return promise; }这里有一个容易被忽略的操作细节:inflightMap里的记录一定要在 Promise 结束时删除,无论成功还是失败,否则资源虽然加载完了,map 中还残留引用,内存会被无谓地占用,后续的重试也会被错误地合并成“已经在加载中”。
2.4 超时与重试:指数退避的计算与抖动策略
网络请求永远存在不确定性,rea 需要为加载器设置沙场规则。每个资源可以单独配置超时时间,默认值是 6 秒。超过这个时间还没返回,rea 就强制把资源标记为failed。
不过失败不能一失败就立刻重试,否则异常接口会在短时间内被反复击打。我用的是指数退避策略,重试延迟计算公式为:
delay = min(baseDelay * 2^attempt, maxDelay)假设baseDelay = 1000ms,maxDelay = 8000ms,随着尝试次数的增加,等待时间如下:
| 尝试次数 | 计算公式 | 计算后延迟 | 实际延迟区间 |
|---|---|---|---|
| 0 | 1000 * 2^0 | 1000ms | 500-1000ms |
| 1 | 1000 * 2^1 | 2000ms | 1000-2000ms |
| 2 | 1000 * 2^2 | 4000ms | 2000-4000ms |
| 3 | 1000 * 2^3 | 8000ms | 4000-8000ms |
| 4 | 1000 * 2^4 | 16000ms,被限制 | 4000-8000ms |
为什么要引入抖动?想象一个场景:某个后端服务在 1 秒内出现了短暂故障,导致 50 个并发请求全部超时。如果这些请求都使用固定的指数退避,它们会在第 2 秒、第 4 秒、第 8 秒同时发起重试,形成一波接一波的重试高峰,反而把后端打垮。加入随机抖动之后,每个请求的延迟分散在一个区间内,重试请求就不会整整齐齐地撞在一起。
rea 最终实现的重试延迟是“基础退避 + 随机抖动”的组合,也就是在上表的“实际延迟区间”内随机取值。这种策略在实际测试中,对后端的压力曲线要平滑得多。
2.5 依赖调度:父资源没就绪,子资源绝不执行
有些资源不是孤立的。比如一个商品详情页,先要拿到商品 ID 列表,才能继续请求每个商品的库存信息。rea 用依赖注册机制解决这个问题。
实现思路是为每个资源维护依赖集合,资源启动前先检查自身依赖的父级资源是否都已处于success状态。只要有一个父级还没成功,当前资源就停留在pending,不触发加载器。当某个资源状态变为success时,rea 会扫描所有依赖它的子资源,检查子资源是否满足执行条件,然后递归触发。
function checkDependencies(resourceId) { const deps = dependencyGraph.get(resourceId) || []; return deps.every(depId => { const state = resources.get(depId).state; return state === 'success'; }); }需要注意的坑是“循环依赖”。我测试时就用了一个 A 依赖 B、B 依赖 A 的例子,执行结果直接死锁。处理办法是依赖扫描时维护一个访问集合,遍历路径上如果重复出现同一个资源,立即抛异常并打印依赖链路。
3. 实操过程与核心环节实现
3.1 最小可运行版本:先搭出状态容器与订阅基础
我一开始没有直接实现依赖调度和重试,而是先做了一个最小版本,验证状态机是否跑得通。整个工厂函数只有三部分:资源状态 Map、事件总线、对外 API。
function createRea() { const resources = new Map(); const bus = createBus(); function setState(resourceId, newState) { const record = resources.get(resourceId); if (!record) return; const oldState = record.state; if (oldState === newState) return; record.state = newState; bus.emit('change', { resourceId, from: oldState, to: newState, attempts: record.attempts, }); } return { register(resourceId, loader, options) { const record = { state: 'pending', loader, attempts: 0, timeout: (options && options.timeout) || 6000, maxRetries: (options && options.maxRetries) || 2, }; resources.set(resourceId, record); return record; }, when(resourceId, handler) { return bus.on('change', payload => { if (payload.resourceId !== resourceId) return; handler(payload); }); }, off() { // 配合 createBus 提供的 off }, }; }这个版本还不能真正发请求,但已经可以注册资源、修改状态、通知订阅者。我用了一个简单的 Node 脚本验证状态迁移,确认pending -> running -> success的链路没问题,才开始加后面的调度逻辑。
3.2 加入加载器执行与请求合并
接下来把真正的 loader 接进来。执行加载器时,state 要先置为running,然后调用 loader 返回的 Promise。成功则置为success,失败则根据重试次数决定是进入failed还是重置为pending继续重试。
function executeLoader(resourceId, loader) { const record = resources.get(resourceId); setState(resourceId, 'running'); return loader() .then(result => { record.data = result; setState(resourceId, 'success'); return result; }) .catch(error => { record.lastError = error; if (record.attempts < record.maxRetries) { record.attempts += 1; setState(resourceId, 'failed'); const delay = computeBackoffDelay(record.attempts); return new Promise(resolve => { setTimeout(() => { setState(resourceId, 'pending'); resolve(executeLoader(resourceId, loader)); }, delay); }); } setState(resourceId, 'failed'); throw error; }); }注意这里重试时把状态重新置为pending,然后再走一遍running,这样每次重试都会发出两次变更事件,订阅者就能根据attempts字段区分是初次加载还是重试行为。
3.3 依赖调度的完整实现
依赖调度的核心在于两个时刻:一是资源被触发加载时检查父级;二是父级成功时触发子级。
function trigger(resourceId) { const record = resources.get(resourceId); if (!record) return; if (!checkDependencies(resourceId)) { return; } if (record.state === 'running' || record.state === 'success') { return; } executeLoader(resourceId, record.loader); } function resolveTrigger(resourceId) { const children = reverseGraph.get(resourceId) || []; children.forEach(childId => { if (checkDependencies(childId)) { trigger(childId); } }); }在trigger里依赖检查通过之后,还要判断状态是否是running或success,避免同一个资源被重复执行。父级成功时,遍历反向依赖图,逐个触发子级。这个递归逻辑一开始我写得比较乱,后来加了“已就绪资源集合”和“正在执行资源集合”两个标记之后,逻辑才清晰起来。
3.4 接入业务场景:一个可复现的例子
下面用一个伪接口的例子,展示 rea 是如何被接进业务代码的。假设我们有一个用户列表页,需要先获得当前登录用户,再根据这个用户的权限拉取订单列表。
const rea = createRea(); rea.register('currentUser', () => fetchCurrentUser(), { timeout: 5000, maxRetries: 1, }); rea.register('orderList', () => fetchOrderList(), { timeout: 8000, maxRetries: 2, dependsOn: ['currentUser'], }); rea.when('orderList', payload => { if (payload.state === 'success') { renderOrderList(); } }); rea.trigger('orderList');在这个例子里,我们永远不会看到订单列表先于用户信息出现。如果 currentUser 失败,超过重试次数后 orderList 会自动进入 failed,订阅者就能在页面上展示一个明确的错误入口,而不是一直转圈。
3.5 调试面板:状态时间线是你的第一排查工具
rea 开发到一半时,我发现很难判断某个资源到底卡在哪个环节,于是增加了一个调试模式。调试模式下,每次状态变化都会写入一个环形缓冲区,最多保留最近 200 条记录。
function pushTimeline(record) { timeline.push({ timestamp: Date.now(), ...record, }); if (timeline.length > 200) { timeline.shift(); } }排查问题时,只需要把这个时间线打印出来,就能清晰看到某个资源从 pending 到 running 再到 failed 的完整路径,包括重试了几次、每次间隔了多长时间。很多看似诡异的问题,在这张时间线面前都会现出原形。
3.6 单元测试的要点与一个最小测试用例
rea 这种纯逻辑模块非常适合做单元测试。我测试的重点是四类:状态迁移合法性、请求合并是否只加载一次、超时重试延迟是否符合退避公式、依赖调度是否能正确处理先后顺序。
一个比较关键的测试技巧是使用假时钟替换setTimeout。如果使用真实定时器,测试执行时间会非常长,而且不稳定。以下是一个示意性的测试用例:
test('loading the same resource twice only invokes loader once', () => { let count = 0; rea.register('item', () => { count += 1; return Promise.resolve('data'); }); rea.trigger('item'); rea.trigger('item'); return Promise.resolve().then(() => { expect(count).toBe(1); }); });这类测试的价值在于,它能在后续改动时立刻提醒你“请求合并被破坏了”,避免回归问题悄悄出现。
4. 常见问题与排查技巧实录
4.1 循环依赖:A 等 B,B 等 A
这是依赖调度场景最容易出现的问题。表现形式是资源一直停留在pending,没有任何报错,时间线里看不到任何动作。原因是 A 检查依赖时发现 B 未就绪,B 检查依赖时发现 A 未就绪,两者互相等待。
排查方法是实现依赖检查时带上访问路径,一旦检测到路径中重复出现某个资源,立即抛出异常并打印完整依赖链表。这个方法后来救了我一次,我在配置一个复杂页面时,不小心把两个资源的依赖写反了,异常提示直接指出了 A 和 B 之间的循环。
4.2 订阅者收到两次重复通知
当资源被多个父级触发时,可能出现子资源的trigger被调用了多次。虽然内部有running状态保护,不容易重复执行,但在事件广播环节,如果父级状态成功事件发送了不止一次,子资源的订阅者就会收到重复通知。
解决这个问题的思路不是去查“到底谁多发了一次”,而是在订阅器发出通知时遵循“同一轮数据变化最多通知一次”的约束。我给每个状态变更事件增加了一个全局序号,只有序号唯一的事件才被广播。如果两个事件序号相同,后者直接丢弃。
4.3 资源失败后整个页面永远转圈
这是重试逻辑最容易出问题的地方。现象是资源状态一直是running,时间线里既没有 success 也没有 failed。排查后发现是加载器内部抛出的错误被某个 promise 的 catch 吞掉了,导致 rea 既收不到成功也收不到失败。
解决思路是给 rea 增加一个“看门狗”机制。每次running状态开始时记录一个计时器,超过配置的超时时间后强制触发一次失败。即便外部加载器完全不回调,rea 也不会被卡死。
4.4 无限重试导致后端压力过大
无限重试通常是由尝试次数被意外清零引起的。我在某个版本里写的清理逻辑是在每次重试前先把 attempts 加一,但又一次在状态迁移的回调里把 attempts 重置了,结果形成了一个“失败-重置-再失败”的循环。
正确的做法是明确规定:只有两种情况下允许 attempts 清零,一是资源加载成功,二是外部显式调用重置方法。重试过程中绝对不允许其他逻辑修改 attempts。如果你遇到类似的重试风暴,第一步先看看代码里是不是有地方悄悄改了这个字段。
4.5 页面卸载后订阅者仍然被通知
组件已销毁、数据状态还在变化时,回调仍然会执行,轻则产生多余的渲染,重则导致内存泄漏。排查方法是保证每个when调用都返回一个注销函数,并在组件销毁时调用它。
我在 rea 里统一用 token 管理订阅。页面初始化时注册一个 token,卸载时调用 releaseToken,把该 token 下的所有监听器一次清干净。这个方法虽然没有太多技术含量,但确实省掉了非常多隐蔽的内存问题。
4.6 竞态条件:旧请求返回后覆盖新请求结果
我在做一个筛选功能时,用户快速切换筛选条件,两个请求先后发出。慢的旧请求后返回,把它对应的旧数据渲染到了页面上,覆盖了新数据。rea 本身不负责数据存储,所以这个问题需要业务层配合。
建议方案是给每次触发加载操作分配一个递增的版本号,订阅者收到结果后,先判断该结果的版本号是否等于自己当前最新版本,如果不是就直接忽略。这个判断放在 rea 内部不太合适,因为 rea 不知道业务层是否关心版本。我最终只在文档里明确给出了推荐写法,并提供了对应的辅助函数。
4.7 排查速查表
| 现象 | 可能原因 | 处理手段 |
|---|---|---|
| 资源长期处于 pending | 依赖未成功或循环依赖 | 检查时间线,开启依赖检测告警 |
| 重复触发加载器 | inflightMap 未正确复用 | 确认 Promise 的 finally 清理逻辑 |
| 收到重复通知 | 同一事件被广播多次 | 引入全局事件序号去重 |
| 永远转圈 | loader 既不 resolve 也不 reject | 增加看门狗超时强制失败 |
| 无限重试 | attempts 被意外重置 | 限制 attempts 唯一修改入口 |
| 卸载后仍收到回调 | 订阅未注销 | 使用 token 统一清理 |
| 旧数据覆盖新数据 | 竞态条件 | 引入版本号过滤过期结果 |
这张表是我后续维护 rea 时的自查清单。每次收到测试提的疑难 bug,我第一件事不是打开代码业务逻辑,而是把时间线拉出来对表,通常都能很快定位到问题大类。
写在最后的一些个人体会
rea 从最初的几十行状态机,演进到现在的资源调度模块,我在这个过程中最深的感受是:资源管理的问题,本质上不是“代码怎么写”的问题,而是“状态怎么收敛”和“通知怎么去重”的问题。只要把状态流转图画清楚,把事件广播的去重逻辑做扎实,剩下的依赖调度和重试机制都只是往骨架上填充肌肉。
如果你也想在自己的项目里做类似的东西,我的建议是不要急着把功能做到大而全。先手工维护一张状态流转表,实现最简单的状态机和订阅通知,用真实业务数据跑通一个页面,再逐步加入请求合并、依赖调度和退避重试。每一步加上去之前,都用单元测试覆盖最核心的场景,这样后续任何一次重构都敢放心操作。这一套方法到现在为止,在我不同的项目里反复验证过,都稳定可靠。