☰
前端请求层设计实践:用 useRequest 收拢竞态、缓存与轮询
2026/10/21 13:09:49 网站建设 项目流程

项目代号定了“rea”,全称是 Request Easy Async,没那么多讲究,就是“请求应该更简单一点”。前几年我参与一个后台管理系统的重构,页面里到处是手动管理 loading、错误提示、竞态条件,一个列表页能写出两百行请求相关代码。那段时间我一直在想,能不能把这堆重复劳动收敛成一个统一的请求层,于是有了 rea。这篇文章不是项目文档,是把踩过的坑、做过的取舍、还有实际迁移过程中的细节全部过一遍,希望能给正打算自研请求层或者想优化前端数据流的朋友一点参考。

1. 为什么用 rea:把散落的请求状态收拢到一处

1.1 项目背景:被手写请求代码支配的日常

我先还原一下当时遇到的实际场景。一个典型的中后台列表页,通常要处理这些事:请求数据、管理 loading、处理错误、响应后更新表格、处理分页参数、筛选参数变化时重新请求、用户快速切换筛选时丢弃过期响应。在没有统一封装之前,每个页面都是各自为政:

const [data, setData] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const fetchList = async (params) => { setLoading(true); try { const res = await api.getList(params); setData(res.data.list); } catch (e) { setError(e); } finally { setLoading(false); } }; useEffect(() => { fetchList(currentParams); }, [currentParams]);

这段代码看着没什么问题,但页面一多,问题就来了。每个页面都要复制粘贴这堆逻辑,而且很容易漏掉某一步。比如有的页面忘记处理竞态,快速切换筛选时旧响应把新数据覆盖了;有的页面忘记在组件卸载时取消请求,控制台全是 setState 警告;还有的页面轮询、防抖、重试全是自己临时写,逻辑各不相同。我用一个词总结当时的感受:失控。这也是 rea 出现的直接原因。

1.2 设计目标:只做请求生命周期,不碰业务状态

立项之前,我们调研过几个现成方案,功能确实很强大,有缓存、有预取、有服务端状态管理那一整套。但我们最后没有直接抄进来,而是选择自己做一个更轻量的库,代号 rea。原因很简单:我们的项目里大部分页面并不需要那么重的服务端状态管理,需要的只是把请求过程中的 loading、error、data、竞态处理、缓存这些琐事标准化。

当时我对团队说了一句话:请求层和全局状态管理不是一回事。请求层负责“数据怎么来、什么时候来、来的时候怎么通知”,状态管理负责“数据到了之后放哪里、怎么共享”。你如果把这些都塞进全局 store,很快就会发现 store 里堆满了各种 requestId、loadingMap、errorMap,调试起来非常痛苦。这就好比厨房里水龙头和储水罐的关系,储水罐决定着各家各户的水压水量,但水龙头该什么时候开、开多久、水满了怎么停,这是龙头自己应该管好的事情。

所以 rea 的定位从一开始就很明确:一个轻量化的请求生命周期管理工具。它暴露给业务方的核心 API 就是 useRequest,内部则通过请求实例、缓存模块、调度模块三个核心组件协同工作。因为不做业务状态,它天然和任何状态管理库都可以共存,不会侵入你的现有架构。

1.3 与主流方案的取舍对比

这里放一张我们当时做调研时的对比表,可以帮助理解为什么最后选择了自研轻量方案:

能力维度现成重量级方案rea 轻量方案
服务端状态缓存完整内置,支持持久化内置基础 Map 缓存,支持自定义存储
与状态管理库耦合无耦合,独立使用无耦合,独立使用
包体积较大极小,gzip 后约 3KB
学习成本需要理解一整套概念只有一个核心 hook
二次开发成本内部抽象较深,改起来费劲代码结构简单,按需修改

我不是说重量级方案不好,在复杂中后台项目里它确实能省很多事。但对于我们这种以中台页面为主、又明确不希望引入额外全局状态的团队来说,自研一个恰好够用的请求层,性价比明显更高。决策的关键不是“谁更强”,而是“谁更合适”。

2. 核心细节解析:useRequest 里的六个关键设计

2.1 核心 Hook:从参数设计到返回值设计

rea 的核心入口是 useRequest,参数和返回值都经过了好几轮真实页面需求的倒推。先看参数,挑几个关键项说明:

interface UseRequestOptions<TParams extends any[], TData> { service: (...params: TParams) => Promise<TData>; manual?: boolean; // 是否手动触发,默认 false defaultParams?: TParams; // 自动执行时的参数 debounceWait?: number; // 防抖等待时间 pollingInterval?: number; // 轮询间隔,0 表示不轮询 retryCount?: number; // 失败重试次数 retryInterval?: (retry: number) => number; onSuccess?: (data: TData, params: TParams) => void; onError?: (error: Error, params: TParams) => void; cacheKey?: (params: TParams) => string; // 自定义缓存key staleTime?: number; // 缓存新鲜期,ms }

这些参数不是凭空想出来的,每一个都对应着真实页面里的具体需求。manual 对应的是“用户点击查询才请求”的场景;debounceWait 对应的是搜索框边输入边查询的场景;pollingInterval 对应的是大屏和看板需要定时刷新的场景;staleTime 对应的是切换 Tab 回来之后希望不闪 loading 的场景。

返回值的设计上也踩过一些坑。最早我们只返回 data、loading、error、run,后来在实际使用中发现,单单 run 是不够的。因为在列表页里经常需要重新拉取当前参数下的数据,比如提交一个弹窗表单后要刷新列表,这时候开发者并不知道内部最后存的是哪套参数。所以我们在返回值里增加了 refresh,它表示“用最近一次的参数重新执行 service”。这个 API 在实际业务里的使用频率非常高,几乎每个列表页都会用到。

2.2 竞态处理:用过期请求覆盖新数据是最隐蔽的 bug

竞态处理是请求层里最容易出错、也最不容易被发现的逻辑。典型场景是这样的:用户在订单列表页选择了一个筛选条件“状态=已支付”,请求发出;紧接着又把筛选条件改成“状态=已退款”,第二个请求发出。如果第一个请求响应比较慢,后返回的响应反而覆盖了第二个请求的结果,表格数据就会错乱。

rea 的做法很简单也很直接:每次执行请求时生成一个自增的请求序号,响应返回时先判断这个序号是不是当前请求序号,如果不是,直接丢弃。代码核心是这样:

let requestId = 0; const handleRequest = async (params) => { const currentId = ++requestId; setLoading(true); try { const result = await service(...params); if (currentId !== requestId) return; // 过期响应直接丢弃 setData(result); } finally { if (currentId === requestId) setLoading(false); } };

这个逻辑看着简单,但实际上非常关键。我接手过一个老项目,见过了这个判断的页面在搜索框里连续输入关键字,最后显示的数据和输入框里的关键字完全不一致,用户当时第一反应是系统有 bug,差点酿成线上问题。所以后续我把竞态处理写进了 rea 的默认行为里,业务方不需要自己做任何额外配置。

2.3 缓存策略:切换页面不闪 loading 的秘密

缓存模块是 rea 里另一个核心亮点。做过中后台的都知道,从列表页点进详情页再返回,如果列表数据又重新加载一遍,还闪一下 loading,体感是非常差的。rea 默认会以 Key 为单位缓存请求结果,相同 Key 的请求在 staleTime 内再次触发时,不会重复发请求,而是直接返回缓存数据,同时会在后台静默刷新一次,保证数据最终新鲜。

Key 的默认生成规则是 JSON.stringify(service + 参数),但也支持通过 cacheKey 自定义。缓存的数据保存在一个 Map 结构里,你可以传入自己的存储实现,比如 localStorage 或者 sessionStorage,只需要实现 get、set、delete 三个方法。这样刷新页面后缓存也能复用,适合一些改动频率很低的字典数据。

这里有一个细节很多人容易忽略:服务端返回的数据往往是一个对象数组,直接 JSON.stringify 整个 payload 作为缓存值是可以的,但如果数据量非常大,会占用大量内存。我们实测过一个 10000 条数据的列表页,单次缓存序列化后的字符串接近 1MB,如果开了 LocalStorage 持久化,很容易触达存储上限。所以后来在 rea 的默认实现里加了一个 maxCacheSize 参数来控制最大缓存条目数,默认 50 条,超出后按最近最少使用策略淘汰。这个参数业务方不需要感知,但它避免了缓存模块成为新的内存泄漏源头。

2.4 轮询与重试:这两块机制的细节远比你想象的多

轮询的需求在中后台里很常见,看板、监控大屏、待办数量角标都要用。rea 的轮询设计上有两个细节我花了不少心思。第一个是轮询和并发冲突的问题。如果某次轮询请求还没返回,下一个定时器又触发了,就会出现请求堆积,响应乱序。我的解法是:每一次轮询都等待上一次请求完成后再安排下一次,而不是简单的 setInterval。伪代码如下:

const startPolling = async () => { while (pollingActive) { await run(); await sleep(pollingInterval); } };

第二个细节是页面不可见时自动暂停轮询。如果用户切到了其他 Tab,页面在后台仍然高频请求接口,既浪费网络资源,又增加服务器压力。rea 内部监听了 document.visibilitychange 事件,页面隐藏时暂停轮询,重新可见时立即触发一次刷新。这个机制上线后,我们某个监控页面的日均请求量直接下降了六成,效果非常明显。

重试的逻辑相比之下更直接。我们采用指数退避策略,第一次失败等待 500ms,第二次 1000ms,第三次 2000ms,默认最大重试次数是 3。这里我只对网络错误和超时错误启用自动重试,业务错误(比如 HTTP 4xx)一律不重试,因为没有意义。这个判断逻辑用一句话概括就是:重试解决的是“请求没送达到服务器”或者“服务器暂时处理不过来”这类临时性问题,而不是“参数送错了”这类确定性问题。

2.5 取消与防抖:两个高频但常被忽略的能力

先说防抖。在搜索框场景中,用户每输入一个字都触发一次请求,既浪费资源又容易造成响应乱序,rea 内置了 debounceWait 参数,实现上就是标准按时间防抖的思想,但只依赖原生 setTimeout。这个能力有真实的数据支撑:我们一个规格参数很多的筛选表单页面,没有防抖时一次完整的筛选操作会触发 5 到 8 次请求,开启 300ms 防抖之后,请求次数稳定在 1 到 2 次,响应时长也缩短了一半。

再说到取消。组件卸载时自动取消所有未完成的请求,这个行为 rea 默认就会做。实现上我们同时兼容了两种方式:如果浏览器支持 AbortController,就调用它的 abort 方法,从根源上终止请求;否则通过请求序号的标记让过期响应不再触发任何状态更新。前者是真正的网络层取消,后者是 UI 层丢弃,两者配合起来可以覆盖绝大多数场景。

2.6 与现有状态管理库的配合使用

这里要说一个很多人在自研请求层时会纠结的问题:useRequest 返回的数据到底放哪里?我的建议是,默认情况下哪里用就在哪里调用 useRequest,数据就存在于调用它的组件里。只有当同一个数据被多个跨级组件共享时,才需要考虑放全局 store 的问题。

以我们项目为例,订单列表页的表格组件和筛选组件是两个平级的独立组件,它们的公共数据是“查询参数”。我们的做法很简单:查询参数放在组件树的高层,useRequest 放在持有查询参数的那一层,把 data 和 loading 通过 props 传给子组件。这样 store 里只需要放查询参数,不用放请求状态,职责清晰很多。

如果你已经用了全局状态管理库,也不用担心。rea 的 onSuccess 回调可以自由地把数据同步进 store,比如 dispatch 一个 action,骨架不变。后续我计划在 rea 里内置一个兼容模式,方便地和常用的全局状态方案连接,但核心思路是不让请求层去依赖任何具体的状态管理实现。

3. 一个真实迁移案例:订单列表页的从0到1落地

3.1 迁移前的代码长什么样

为了让你更直观地看到 rea 带来的变化,我拿当时改造的一个订单列表页来举例。这个页面支持按订单号搜索、按状态筛选、分页浏览,还有一个“刷新”按钮。改造前的代码里,光请求相关的逻辑就有这么一坨:

const [list, setList] = useState([]); const [page, setPage] = useState(1); const [keyword, setKeyword] = useState(''); const [status, setStatus] = useState(''); const [loading, setLoading] = useState(false); const fetchOrders = async () => { setLoading(true); try { const res = await api.getOrders({ page, keyword, status }); setList(res.list); } catch { message.error('加载失败'); } finally { setLoading(false); } }; useEffect(() => { fetchOrders(); }, [page, keyword, status]);

这段逻辑本身没有问题,但有几个隐患:第一,页面卸载时没有取消请求,快速切换页面时可能有 setState 警告;第二,如果用户在短时间内连续修改 keyword 和 status,会触发两次请求,后一次未返回时前一次结果可能被覆盖;第三,每次进入页面都重新请求,没有缓存也没有 loading 优化。这些问题加在一起,让这个看起来很简单的页面在实际使用中频繁出现表格闪现旧数据的情况。

3.2 迁移后的完整代码与逐行说明

迁移后,这段逻辑被压缩成了一个 hook 调用:

const { data, loading, run, refresh } = useRequest(api.getOrders, { defaultParams: [{ page: 1, keyword: '', status: '' }], staleTime: 30 * 1000, onError: () => message.error('加载失败'), }); const handleSearch = (nextParams) => { run([nextParams]); }; const handleRefresh = () => refresh();

逐行解释一下。useRequest 第一个参数是请求函数 api.getOrders,第二个参数是配置项。defaultParams 表示页面初始化时自动执行的参数,这里默认第一页、空关键字、空状态。staleTime 设置 30 秒,表示 30 秒内重复进入页面时不会重新发请求,直接使用缓存数据。onError 统一处理错误提示,不用每个页面都写一遍 catch。handleSearch 里的 run 会把新的查询参数传给请求函数,内部自动处理竞态和 loading 状态;handleRefresh 则用最近一次成功的参数重新请求,适合用户点击刷新按钮的场景。

这个改造带来的效果是立竿见影的。我印象最深的是当时一个同事反馈说,以前点完筛选条件之后表格偶尔会闪一下旧数据,现在完全不会了,因为竞态处理、loading 状态乃至卸载时的取消逻辑全部由 rea 兜底,页面代码只需要关心“数据变了我要做什么”,而不需要操心“网络层怎么处理”。

3.3 迁移过程中遇到的三个拦路虎

第一个拦路虎是接口请求函数的 this 指针问题。我们后端的请求实例在某种封装方式下,this 指向会丢失。当时排查了半天,最终定位到问题在于 service 函数的调用上下文。rea 在内部执行 service 时,默认会以 undefined 作为 this 调用,如果业务方的请求函数内部引用了 this,就会报错。解决方案是在 useRequest 的配置里增加一个 context 参数,允许指定 service 执行时的 this 上下文。

第二个拦路虎是缓存 Key 的粒度问题。最开始我们默认用整个参数对象生成缓存 Key,结果发现一个订单详情弹窗,传入的是同一个订单 ID,但因为参数里携带了一个毫秒级的时间戳字段,导致缓存 key 每次都不同,缓存形同虚设。这个问题的解法是让缓存 key 生成支持自定义规则,比如只取订单 ID 和分页字段,忽略干扰字段。

第三个拦路虎是老页面和新封装的兼容问题。有些老页面还在自己管理 loading 状态,它们的代码里用了很多 setState 直接操作数据,迁移到 useRequest 后需要把 setState 改成 onSuccess 回调。我们花了大概两周的时间做灰度迁移,每天晚上把一两个页面切过来,跑一遍完整流程。这个过程很磨人,但也给了我们足够的信心去验证 rea 的稳定性。

4. 常见问题与排查技巧实录

4.1 组件卸载后仍然触发 setState 警告

这是迁移过程中反馈最多的一个问题。出现场景通常是在详情页请求还没返回时,用户快速返回列表页,控制台出现警告。根因是旧页面代码里 useEffect 的清理函数没有取消正在进行的请求。

排查技巧是先看警告里涉及的 setState 代码是否在业务组件里,如果是在业务组件里,多半是组件自己没清理;如果确认组件清理了但警告还在,那就可能是 rea 内部某个回调触发了外部状态更新。最终我们统一了规范:组件里所有异步执行路径必须走 useRequest,禁止在业务侧裸调用异步函数后直接 setState。这个规范执行之后,这类警告就基本消失了。

4.2 轮询请求出现堆积与乱序

这个问题的症状是看板页面数据跳动,一会儿显示当前值,一会儿显示几秒前的旧值。调查后发现原因是轮询使用的是 setInterval 固定间隔触发,如果某一次请求耗时长于间隔时间,下一次触发时上一次请求还没返回,两次响应就会交叉回写,导致数据忽旧忽新。

rea 的解法是等待上一次请求完成后再安排下一次轮询,也就是串行轮询。此外还有一个优化:如果用户切到后台导致轮询暂停,重新回到页面时只触发一次刷新,避免后台积压的一堆定时器同时执行。这里我建议你在自己的实现里也要注意这两个细节,很多人写轮询都用 setInterval,逻辑看似简单,但在网络不稳定的环境下很容易踩坑。

4.3 缓存命中但不更新,表格一直是旧数据

这个问题的典型场景是列表数据在另一个页面被修改后,回到列表页时 staleTime 还没过期,表格一直显示缓存数据。表面上是缓存机制的问题,实际上是缓存失效时机的问题。

rea 提供了手动失效的 API,业务方可以在修改数据的页面提交成功回调里调用 invalidate(cacheKey),显式标记数据过期。更稳妥的做法是把 staleTime 设置得短一些,或者采用“缓存 + 静默刷新”的策略:先展示缓存数据,同时在后台请求一次新数据,数据返回后更新 UI,这样用户既感知不到加载过程,又不会看到过期数据。这个策略我们在 rea 里是默认开启的,也是我比较推荐的一个形态。

4.4 常见问题速查表

症状可能原因排查思路
列表数据被旧响应覆盖缺少竞态处理确认是否启用了 rea 或自行维护请求序号
频繁切换 Tab 页面卡顿轮询没有随页面可见性暂停监听 visibilitychange 暂停轮询
缓存一直不命中参数里包含变化字段自定义 cacheKey 忽略干扰字段
接口已成功但 UI 不更新service 引用 this 值错误配置 context 参数
组件卸载警告异步操作未清理禁止裸 setState 异步路径

这个表是我从当时的工单记录里整理出来的,其实很多问题最后定位下来都不是多复杂的原理,大多是在几个常见的边界条件上栽了跟头。

5. 一些真实的使用体会与后续计划

在 rea 这个项目从零到一再到逐步稳定的过程中,我最大的体会是:做一个工具库,最难的不是写代码,而是克制。最初我也动过念头,要把预取、离线缓存、服务端状态同步这些能力全部做进去,后来真正落到业务里测试,发现大部分页面用得最多的还是那三四个 API。功能做得越多,文档越长,学习成本越高,最后反而没人敢用。

另一个体会是命名和文档会影响一个项目能不能活下来。rea 内部最早叫 fetch-manager,听着就很工具化,后来改成 rea 之后,大家说起“这个列表页用 rea 跑一遍”反而顺口了。虽然名字是小事,但对内推广时带来的感受完全不同。

后面我计划做两件事。第一件是给 rea 写一个基于浏览器开发者工具的调试面板,可以直观看到当前有哪些请求在飞行中、哪些走了缓存、缓存 key 是什么,调试请求问题的时候能省不少时间。第二件是支持按需细粒度订阅,比如一个页面同时用多个 useRequest,目前任意一个请求变化都会触发整个组件重新渲染,后续可以通过返回值里增加 selector 来减少无关渲染。

如果你也在规划自研请求层,我的建议是:先找一个真实的页面做试点,把核心 API 打磨出来之后,再决定要不要继续扩展。很多功能你觉得会用到,其实在真实业务里并不会。用一个最小可用的版本跑通业务,比一开始就设计一个庞大的完整框架要靠谱得多。

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

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

立即咨询