☰
轻量级前端资源管理模块 rea:状态、依赖与请求调度的工程实践
2026/10/20 17:25:32 网站建设 项目流程

做过带列表页的项目的人,大概率经历过这种场景:页面一打开,七八个接口同时发出请求,每个区块各自维护 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 两个状态?因为缺少“取消”和“待重试”这两个中间态,很难精确表达真实场景。

状态流转规则如下表所示:

当前状态可流转到状态触发条件
pendingrunning加载器开始执行
pendingcancelled注册后被取消,尚未开始请求
runningsuccess加载器成功返回
runningfailed加载器抛出错误或运行超时
runningcancelled外部主动取消
failedpending触发了重试逻辑,重置等待队列
cancelledpending重新激活资源

这张表是整个 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,随着尝试次数的增加,等待时间如下:

尝试次数计算公式计算后延迟实际延迟区间
01000 * 2^01000ms500-1000ms
11000 * 2^12000ms1000-2000ms
21000 * 2^24000ms2000-4000ms
31000 * 2^38000ms4000-8000ms
41000 * 2^416000ms,被限制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 从最初的几十行状态机,演进到现在的资源调度模块,我在这个过程中最深的感受是:资源管理的问题,本质上不是“代码怎么写”的问题,而是“状态怎么收敛”和“通知怎么去重”的问题。只要把状态流转图画清楚,把事件广播的去重逻辑做扎实,剩下的依赖调度和重试机制都只是往骨架上填充肌肉。

如果你也想在自己的项目里做类似的东西,我的建议是不要急着把功能做到大而全。先手工维护一张状态流转表,实现最简单的状态机和订阅通知,用真实业务数据跑通一个页面,再逐步加入请求合并、依赖调度和退避重试。每一步加上去之前,都用单元测试覆盖最核心的场景,这样后续任何一次重构都敢放心操作。这一套方法到现在为止,在我不同的项目里反复验证过,都稳定可靠。

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

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

立即咨询