最近在做一个数据密度很高的前端项目时,我发现自己陷入了一种极不健康的重复劳动:每个页面都在写loading、error、data三个状态,再用一个useEffect去发请求,然后处理刷新、竞态、缓存、卸载时防止内存泄漏。这套模板写了几十遍之后,我把这一层逻辑抽了出来,做成一个内部代号叫rea的小工具。它不是什么第三代框架,也不是要取代现有的请求库,而是只做一件事:让业务组件彻底不需要关心“请求该什么时候发、返回结果怎么缓存、重复请求怎么合并、旧响应怎么丢弃”这些底层细节。如果你也在维护一个中后台系统,且被各种异步请求的状态管理折磨过,这篇内容应该能给你一个非常轻量、可落地的替代方案。1. 这个工具解决的问题到底是什么1.1 前端请求状态代码的三处“慢性病”我先把最让人头疼的代码形态摆出来。大多数业务页面里,请求逻辑是长这样的:const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { let canceled = false; setLoading(true); fetchUserList() .then(res => { if (!canceled) { setData(res); } }) .catch(err => { if (!canceled) { setError(err); } }) .finally(() => { if (!canceled) { setLoading(false); } }); return () => { canceled = true; }; }, [params]);这段代码基本是标准模板,但写多了之后,你会看到三个明显的问题。第一,样板代码的量是幻觉般的多。三行状态定义、一个useEffect、一个canceled标志、三段回调处理。这段代码大概 30 行,而它只覆盖了一个请求。当页面有六个请求时,这类代码就占据了一百八十行,且其中真正有业务含义的只有fetchUserList()这一行。剩下的全是疲劳劳动。第二,竞态问题很容易在“看着没问题”的时候爆发。上面的canceled变量只能在组件卸载时阻止更新,它拦不住另一种更常见的场景:用户快速切换筛选条件,前一个请求比后一个请求晚 300 毫秒返回,页面数据被旧响应覆盖。我见过很多线上 bug 都属于这种类型,因为它的触发条件是时间差,不是逻辑错误。第三,同一份资源的请求无法共享。假设列表页和详情页都需要currentUser信息,这两处组件会各自发一次请求。如果再有一个侧边栏也要用到,那就变成三次请求。这种重复请求浪费了带宽,也增加了后端的压力。而且每次刷新都需要手动setData,很容易做成分叉逻辑,久而久之状态就开始乱了。1.2 为什么不直接用一个请求库,而是自己做一版你可能想说,这类问题早就被解决过了,比如知名的swr、react-query或者ahooks的useRequest。确实这些库都很成熟,我在早期调研时也认真对比过。但我在这个项目里最终选择了自己实现一个很小的方案,核心原因有三点。其一,项目里的大部分请求并不需要粗粒度的“服务端状态管理”。我们不需要复杂的缓存标签体系、不需要 e标签不够、不需要分页自动扩展这些能力。我们需要的只是一套规则:请求发出去后,把数据放到一个统一的地方,任何人需要时都能拿到同一份结果,并且能设置一个合理的过期时间。这类需求用一个很小的封装就足够覆盖。其二,生态库虽然强大,但它的概念模型是围绕自己的核心抽象展开的。这在团队协作中会引入额外的学习成本。新同学入职后需要理解缓存 key 的层级结构、失效策略、重渲染时机,这一套东西对于只维护两个后台页面的团队来讲,收益是负的。我更倾向于让代码保持“普通函数 + 普通状态”就能看懂的程度。其三,我需要一个能完全掌控生命周期和内存行为的方案。有些业务流程要求请求结果在特定操作后必须物理丢弃,而不是继续留在全局缓存里给下一次刷新用。这种定制需求在大而全的库里反而要写各种拦截逻辑,倒不如在自己这一亩三分地里直接控制来得干净。所以rea的定位不是“替代 react-query”,而是一个更小、更贴近业务请求层的协调器。它解决的就是从“业务组件主动发起请求”到“请求结果自动同步到所有依赖方”之间的这段空白。2. 核心设计思路与关键取舍2.1 把请求描述成一个“资源对象”rea的第一个核心概念是resource。所谓资源,就是一段配置对象,它描述了一个请求的完整行为:import { defineResource } from 'rea'; const userListResource = defineResource({ key: 'user-list', fetcher: () => fetch('/api/users').then(res => res.json()), staleTime: 30_000, // 30秒内认为数据是新鲜的 maxAge: 5 * 60_000, // 5分钟后无论是否新鲜,都需要重新拉取 retry: 2, });这里的每个字段都有明确含义。key是整个资源共享和缓存的全局唯一标识。也就是说,只要两个地方使用同一个资源对象,它们拿到的就是同一份数据、同一个请求状态。fetcher必须是一个返回 Promise 的函数,它负责真正发起网络请求。staleTime和maxAge是缓存策略的核心,这俩的差别我后面单独讲。retry是失败重试次数,默认 0。把请求描述成配置对象,而不是在组件里直接调用函数,目的是为了让请求成为可复用、可观察、可替换的实体。你想想看,在传统写法里,请求是在组件内联发生的,请求函数和组件的生命周期完全耦合;而一旦抽出成资源对象,这个请求就变成了一个独立的单元,它可以被挂载、被复用、被预取,甚至被测试替换。在rea内部,每个资源对应一个条目的状态机:idle → loading → success | error请求未触发时是idle,触发后变为loading,完成后进入success或error。任意时刻只有这几个状态,组件只需要关心状态机的分支,不需要自己维护布尔组合。这是我认为最划算的一个抽象。2.2 请求去重与竞态取消:两个容易翻车的细节刚开始实现时,我先把去重和竞态分开做了,后来发现它们其实是同一枚硬币的两面:都是为了“请求结果只能有一个胜利者”。去重的实现逻辑很直接。rea内部维护一个inFlight的 Map,key 是资源的全局 key,value 是当前正在执行的 Promise。当发起请求时,先查这个 Map,如果已存在同样的 key 的挂起请求,就不再发起新请求,而是直接复用那个已经存在的 Promise。这就保证了即使页面里三个组件同时挂载并都调用了刷新,也只会有一份真实的网络请求发到后端。我在某个页面上实测过,从三份请求降到一份,网络时间从平均三百毫秒降到一百毫秒,提升非常可观。竞态取消则是用了一个自增序列号。每次发起请求时,给这次请求分配一个递增的seq,请求返回后,检查这个seq是否仍然等于当前资源最新的seq。如果不相等,说明在这段时间内又发起了新的请求,那么旧请求的结果应该被直接丢弃。这个方案的优点是简单可靠,不依赖 AbortController 也能解决大部分业务竞态问题。需要注意的是,释放网络连接这件事仍然应该由AbortController来做,只是数据状态层面不需要因为它而额外引入复杂度。let seq = 0; async function runFetch(entry) { const currentSeq = ++seq; // 分配本次请求的编号 try { const result = await entry.fetcher(); if (currentSeq !== seq) return; // 已经过期,丢弃 entry.data = result; entry.status = 'success'; } catch (err) { if (currentSeq !== seq) return; entry.error = err; entry.status = 'error'; } emitChange(entry); // 通知所有订阅者 }这里有一个我开发时踩过的坑:我刚实现时只判断了成功分支的过期,漏掉了失败分支。结果是在快速切换参数时,旧请求如果失败了,还是会触发一次 error 状态更新,导致页面闪一下错误提示。解决方式就是成功和失败分支都要做同样的seq校验,一个都不能少。2.3 缓存只做“短时快照”,不越界rea的缓存策略是刻意保守的。它把数据保存在运行时内存里,而不是放进localStorage或sessionStorage。为什么不做持久化?因为请求数据很可能包含用户隐私或临时凭证,放进持久化存储后,刷新页面虽然可以读取,但你不得不考虑清理时机、多标签页同步、版本过期这些额外问题。一个最小方案把这些复杂度全部挡在外面,换来的是实现和心智的双重简单。staleTime的含义是:在这段时间内,任何组件来读取资源时,会直接拿到缓存数据,而不会触发新的请求。这个时间适合设置为“业务上能接受的最大滞后时间”,比如用户列表设置成 30 秒,意味着用户不在 30 秒内重复拉取也能看到相对新的数据,如果期间真的有大量更新,用户手动刷新一下也能拿到新数据。maxAge的含义是硬上限。哪怕还在staleTime内,一旦超过maxAge,下次读取就必须触发一次真实请求。这样做的意义是避免数据无限期停留在内存中。举个例子,用户切到别的页面再切回来,距离第一次请求已经过了十分钟,如果只有staleTime没有maxAge,读到的可能是十分钟前的数据,而它早就应该被更新了。这两个参数的组合实际上构成了一种姑且称之为“短时快照”的缓存模型:数据只在他的短暂生命周期内被复用,过期即消失,不会成为长期状态依赖。3. 实操上手:把 rea 接进一个真实页面3.1 初始化工程与安装依赖假设你正在用 Vite 搭建一个 React + TypeScript 项目,接入rea的流程非常简单。先把包装进来:npm install rea然后不用做任何全局配置。rea不需要 Provider、不需要初始化函数,因为你可以在模块级别直接定义资源对象,而这些对象本身就是仓库。这种设计让我们不需要在应用根部插入任何上下文组件,对于想逐步引入到老项目的团队来说,这一点是很大的便利。3.2 定义一个用户资源,并封装一个 hooks我把资源定义放进独立文件resources/user.ts,这样好维护:import { defineResource } from 'rea'; export interface User { id: number; name: string; avatar: string; } export const userResource = defineResource({ key: 'current-user', fetcher: async (params?: {id: number}) => { const res = await fetch(`/api/user/${params?.id ?? 'me'}`); if (!res.ok) throw new Error('加载用户失败'); return res.json() as Promise<User>; }, staleTime: 60_000, });接下来封装一个useUser的 hook,让组件可以直接消费这个资源:import { useRea } from 'rea'; import { userResource, User } from '../resources/user'; export function useUser(id?: number) { const query = useRea(userResource, { params: { id }, deps: [id], // 仅当 id 变化时,才重新拉取 }); return { user: query.data as User | null, loading: query.loading, error: query.error, refresh: query.refresh, }; }初看这段代码,你可能觉着useRea做的事情很像一次useEffect+useState的封装。但关键区别在于:userResource是模块级单例,它背后的数据仓库也是全局唯一的。因此,无论哪个组件调用useUser,只要 key 相同,它们读取到的就是同一份data和同一个loading。这就是资源共享的本质。3.3 三种消费姿势:默认加载、联动刷新、手动操作后失效我在这里整理三种最常见的使用场景,基本可以覆盖中后台项目里八成以上的需求。场景一:页面挂载后自动请求数据。这个最普通,但你不需要写任何useEffect:function Profile() { const { user, loading, error } = useUser(); if (loading) return <Skeleton />; if (error) return <ErrorTip message={error.message} />; return <div>{user?.name}</div>; }场景二:参数变化时自动重新请求。比如用户选择一个角色后,角色 ID 成为请求参数:function RoleMemberList({ roleId }: { roleId: number }) { const { users, loading, refresh } = useUserListByRole(roleId); // useUserListByRole 内部会把 roleId 放进 params 和 deps return <Table loading={loading} dataSource={users} onRefresh={refresh} />; }当roleId变化时,useRea会自动检测到依赖项变化,触发新一轮请求。这一步替代了原本手写的“useEffect 依赖[roleId]”逻辑,同时也自动保证了老请求不会覆盖新结果。场景三:提交一个新增操作后,手动失效缓存并刷新。这个我认为是它最提升体验的地方。以前你需要手写一个refresh函数,把setData暴露出来;现在直接调用refresh即可:const { refresh } = useUserList(); const handleCreate = async () => { await api.createUser(input); refresh(); // 触发重新拉取,所有依赖此资源的组件都会同步更新 };即便有十个组件同时使用了同一个资源,refresh()一次,也会通知到所有订阅者,它们会同时进入loading状态并拿到新数据。这种响应式扩散能力是用手写状态很难实现的,我觉得它是整套方案里最有代入感的一个特性。4. 踩过的坑与排查实录4.1 无限请求:依赖项里的对象引用陷阱我遇到的最严重的 bug,是接入useRea后页面出现无限循环请求。排查到最后发现原因出在deps数组写了一个对象引用:useRea(resource, { params: { keyword }, deps: [keyword], });这看起来没问题,但随后我改成了这样:useRea(resource, { params: { filters }, // filters 是对象 deps: [filters], // 每次 render 都是新的引用 });因为filters在父组件里是通过重组创建的新对象,导致rea认为依赖每次都在变化,于是不断触发请求。这其实是 React hook 依赖比对的老问题暴露在了自己的封装上。解决方式有两种:一是传基础类型作为资源 key 的一部分,二是使用稳定引用(比如useMemo或useRef)。我建议优先调整依赖结构,保证deps里只放原始值。4.2 刷新时数据短暂消失的问题第一次体验rea时,我发现每次手动刷新都会让页面先回到loading状态,之前展示的数据列表瞬间消失,然后整个表格变成骨架屏,最后再重新渲染。视觉上很割裂,尤其是用户只点了列表上一个刷新按钮,看到全屏闪烁会非常困惑。为了解决这个问题,我给useRea增加了keepPreviousData参数。当设置为true时,请求刷新期间不会清空已有数据,只是把loading置为 true,并用旧的data继续渲染。这种做法在数据密集型页面里几乎成了标配,它让刷新动作变得无感,用户体验好了很多。4.3 组件卸载后请求返回引发告警在最初的原型版本里,我在请求完成后直接调用emitChange(entry),而并没有检查资源是否还有活跃订阅者。这导致一个问题:某些场景下,组件已经卸载,但请求的回调仍然触发了状态更新,虽然 React 18 之后这个不算致命错误,但控制台会输出告警,而且无谓的重渲染也应避免。排查后的修复方案是:每个资源在内部维护一个订阅者 Set,emitChange前检查订阅者数量,如果没有订阅者,就不触发通知。同时,资源对象本身仍然保存着最新状态,后续有新的订阅者到来时,可以立刻读取到上次请求的结果。这个改动保证了内存使用和数据一致性之间的平衡。4.4 一份快速问题排查表我把实际项目中几个人最容易踩的问题整理成表格,方便你对照定位:现象可能原因解法页面无限请求、网络面板请求堆积deps中包含对象/数组等易变引用改为基础类型、稳定化引用或直接省略 deps快速筛选时数据偶尔被旧值覆盖竞态取消逻辑只覆盖了成功分支或完全未处理为每次请求分配 seq,成功与失败分支都做过期校验刷新时页面出现闪空默认行为是清空旧数据再加载给useRea加keepPreviousData: true多个页面各发各的请求、后端重复请求量很大未使用同一个资源实例,或资源互相不共享确保使用模块级单例资源,key 保持全局唯一切回页面时数据太旧、未自动更新staleTime设置过长且没有maxAge兜底引入maxAge作为硬过期时间,或在后台静默刷新某操作后所有页面数据不同步只刷新了组件局部状态,没有调用资源级 refresh手动操作后统一调用资源的refresh方法卸载组件后控制台出现 setState 告警没有判断订阅者是否还存在升级到带订阅者检查的版本,确保卸载后不派发更新这张表不是万能药,但它覆盖了我在这类场景里遇到的大部分异常。排查时先看网络面板里的请求次数,再检查依赖项和控制台告警,基本就能锁定问题。最后再分享一个我自己很受益的小习惯:像rea这种工具,不要期望它第一天就做成完美框架,我的建议是从一个真实项目里的一个高频场景出发,先抽取最小可用的 API,然后让同事试用一星期,集中收集反馈再迭代。我最初设计时没有keepPreviousData,也没有maxAge,这些都是在实际使用中被逼出来的。工具最终长成什么样,应该由它需要服务的真实代码形态决定,而不是由一开始的架构幻想决定。如果你也遇到类似的请求状态管理问题,不妨试着从小封装开始,你会发现收益比想象中来得快得多。
最近在做一个数据密度很高的前端项目时,我发现自己陷入了一种极不健康的重复劳动:每个页面都在写loading、error、data三个状态,再用一个useEffect去发请求,然后处理刷新、竞态、缓存、卸载时防止内存泄漏。这套模板写了几十遍之后,我把这一层逻辑抽了出来,做成一个内部代号叫rea的小工具。它不是什么第三代框架,也不是要取代现有的请求库,而是只做一件事:让业务组件彻底不需要关心“请求该什么时候发、返回结果怎么缓存、重复请求怎么合并、旧响应怎么丢弃”这些底层细节。如果你也在维护一个中后台系统,且被各种异步请求的状态管理折磨过,这篇内容应该能给你一个非常轻量、可落地的替代方案。1. 这个工具解决的问题到底是什么1.1 前端请求状态代码的三处“慢性病”我先把最让人头疼的代码形态摆出来。大多数业务页面里,请求逻辑是长这样的:const [data, setData] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); useEffect(() => { let canceled = false; setLoading(true); fetchUserList() .then(res => { if (!canceled) { setData(res); } }) .catch(err => { if (!canceled) { setError(err); } }) .finally(() => { if (!canceled) { setLoading(false); } }); return () => { canceled = true; }; }, [params]);这段代码基本是标准模板,但写多了之后,你会看到三个明显的问题。第一,样板代码的量是幻觉般的多。三行状态定义、一个useEffect、一个canceled标志、三段回调处理。这段代码大概 30 行,而它只覆盖了一个请求。当页面有六个请求时,这类代码就占据了一百八十行,且其中真正有业务含义的只有fetchUserList()这一行。剩下的全是疲劳劳动。第二,竞态问题很容易在“看着没问题”的时候爆发。上面的canceled变量只能在组件卸载时阻止更新,它拦不住另一种更常见的场景:用户快速切换筛选条件,前一个请求比后一个请求晚 300 毫秒返回,页面数据被旧响应覆盖。我见过很多线上 bug 都属于这种类型,因为它的触发条件是时间差,不是逻辑错误。第三,同一份资源的请求无法共享。假设列表页和详情页都需要currentUser信息,这两处组件会各自发一次请求。如果再有一个侧边栏也要用到,那就变成三次请求。这种重复请求浪费了带宽,也增加了后端的压力。而且每次刷新都需要手动setData,很容易做成分叉逻辑,久而久之状态就开始乱了。1.2 为什么不直接用一个请求库,而是自己做一版你可能想说,这类问题早就被解决过了,比如知名的swr、react-query或者ahooks的useRequest。确实这些库都很成熟,我在早期调研时也认真对比过。但我在这个项目里最终选择了自己实现一个很小的方案,核心原因有三点。其一,项目里的大部分请求并不需要粗粒度的“服务端状态管理”。我们不需要复杂的缓存标签体系、不需要 e标签不够、不需要分页自动扩展这些能力。我们需要的只是一套规则:请求发出去后,把数据放到一个统一的地方,任何人需要时都能拿到同一份结果,并且能设置一个合理的过期时间。这类需求用一个很小的封装就足够覆盖。其二,生态库虽然强大,但它的概念模型是围绕自己的核心抽象展开的。这在团队协作中会引入额外的学习成本。新同学入职后需要理解缓存 key 的层级结构、失效策略、重渲染时机,这一套东西对于只维护两个后台页面的团队来讲,收益是负的。我更倾向于让代码保持“普通函数 + 普通状态”就能看懂的程度。其三,我需要一个能完全掌控生命周期和内存行为的方案。有些业务流程要求请求结果在特定操作后必须物理丢弃,而不是继续留在全局缓存里给下一次刷新用。这种定制需求在大而全的库里反而要写各种拦截逻辑,倒不如在自己这一亩三分地里直接控制来得干净。所以rea的定位不是“替代 react-query”,而是一个更小、更贴近业务请求层的协调器。它解决的就是从“业务组件主动发起请求”到“请求结果自动同步到所有依赖方”之间的这段空白。2. 核心设计思路与关键取舍2.1 把请求描述成一个“资源对象”rea的第一个核心概念是resource。所谓资源,就是一段配置对象,它描述了一个请求的完整行为:import { defineResource } from 'rea'; const userListResource = defineResource({ key: 'user-list', fetcher: () => fetch('/api/users').then(res => res.json()), staleTime: 30_000, // 30秒内认为数据是新鲜的 maxAge: 5 * 60_000, // 5分钟后无论是否新鲜,都需要重新拉取 retry: 2, });这里的每个字段都有明确含义。key是整个资源共享和缓存的全局唯一标识。也就是说,只要两个地方使用同一个资源对象,它们拿到的就是同一份数据、同一个请求状态。fetcher必须是一个返回 Promise 的函数,它负责真正发起网络请求。staleTime和maxAge是缓存策略的核心,这俩的差别我后面单独讲。retry是失败重试次数,默认 0。把请求描述成配置对象,而不是在组件里直接调用函数,目的是为了让请求成为可复用、可观察、可替换的实体。你想想看,在传统写法里,请求是在组件内联发生的,请求函数和组件的生命周期完全耦合;而一旦抽出成资源对象,这个请求就变成了一个独立的单元,它可以被挂载、被复用、被预取,甚至被测试替换。在rea内部,每个资源对应一个条目的状态机:idle → loading → success | error请求未触发时是idle,触发后变为loading,完成后进入success或error。任意时刻只有这几个状态,组件只需要关心状态机的分支,不需要自己维护布尔组合。这是我认为最划算的一个抽象。2.2 请求去重与竞态取消:两个容易翻车的细节刚开始实现时,我先把去重和竞态分开做了,后来发现它们其实是同一枚硬币的两面:都是为了“请求结果只能有一个胜利者”。去重的实现逻辑很直接。rea内部维护一个inFlight的 Map,key 是资源的全局 key,value 是当前正在执行的 Promise。当发起请求时,先查这个 Map,如果已存在同样的 key 的挂起请求,就不再发起新请求,而是直接复用那个已经存在的 Promise。这就保证了即使页面里三个组件同时挂载并都调用了刷新,也只会有一份真实的网络请求发到后端。我在某个页面上实测过,从三份请求降到一份,网络时间从平均三百毫秒降到一百毫秒,提升非常可观。竞态取消则是用了一个自增序列号。每次发起请求时,给这次请求分配一个递增的seq,请求返回后,检查这个seq是否仍然等于当前资源最新的seq。如果不相等,说明在这段时间内又发起了新的请求,那么旧请求的结果应该被直接丢弃。这个方案的优点是简单可靠,不依赖 AbortController 也能解决大部分业务竞态问题。需要注意的是,释放网络连接这件事仍然应该由AbortController来做,只是数据状态层面不需要因为它而额外引入复杂度。let seq = 0; async function runFetch(entry) { const currentSeq = ++seq; // 分配本次请求的编号 try { const result = await entry.fetcher(); if (currentSeq !== seq) return; // 已经过期,丢弃 entry.data = result; entry.status = 'success'; } catch (err) { if (currentSeq !== seq) return; entry.error = err; entry.status = 'error'; } emitChange(entry); // 通知所有订阅者 }这里有一个我开发时踩过的坑:我刚实现时只判断了成功分支的过期,漏掉了失败分支。结果是在快速切换参数时,旧请求如果失败了,还是会触发一次 error 状态更新,导致页面闪一下错误提示。解决方式就是成功和失败分支都要做同样的seq校验,一个都不能少。2.3 缓存只做“短时快照”,不越界rea的缓存策略是刻意保守的。它把数据保存在运行时内存里,而不是放进localStorage或sessionStorage。为什么不做持久化?因为请求数据很可能包含用户隐私或临时凭证,放进持久化存储后,刷新页面虽然可以读取,但你不得不考虑清理时机、多标签页同步、版本过期这些额外问题。一个最小方案把这些复杂度全部挡在外面,换来的是实现和心智的双重简单。staleTime的含义是:在这段时间内,任何组件来读取资源时,会直接拿到缓存数据,而不会触发新的请求。这个时间适合设置为“业务上能接受的最大滞后时间”,比如用户列表设置成 30 秒,意味着用户不在 30 秒内重复拉取也能看到相对新的数据,如果期间真的有大量更新,用户手动刷新一下也能拿到新数据。maxAge的含义是硬上限。哪怕还在staleTime内,一旦超过maxAge,下次读取就必须触发一次真实请求。这样做的意义是避免数据无限期停留在内存中。举个例子,用户切到别的页面再切回来,距离第一次请求已经过了十分钟,如果只有staleTime没有maxAge,读到的可能是十分钟前的数据,而它早就应该被更新了。这两个参数的组合实际上构成了一种姑且称之为“短时快照”的缓存模型:数据只在他的短暂生命周期内被复用,过期即消失,不会成为长期状态依赖。3. 实操上手:把 rea 接进一个真实页面3.1 初始化工程与安装依赖假设你正在用 Vite 搭建一个 React + TypeScript 项目,接入rea的流程非常简单。先把包装进来:npm install rea然后不用做任何全局配置。rea不需要 Provider、不需要初始化函数,因为你可以在模块级别直接定义资源对象,而这些对象本身就是仓库。这种设计让我们不需要在应用根部插入任何上下文组件,对于想逐步引入到老项目的团队来说,这一点是很大的便利。3.2 定义一个用户资源,并封装一个 hooks我把资源定义放进独立文件resources/user.ts,这样好维护:import { defineResource } from 'rea'; export interface User { id: number; name: string; avatar: string; } export const userResource = defineResource({ key: 'current-user', fetcher: async (params?: {id: number}) => { const res = await fetch(`/api/user/${params?.id ?? 'me'}`); if (!res.ok) throw new Error('加载用户失败'); return res.json() as Promise<User>; }, staleTime: 60_000, });接下来封装一个useUser的 hook,让组件可以直接消费这个资源:import { useRea } from 'rea'; import { userResource, User } from '../resources/user'; export function useUser(id?: number) { const query = useRea(userResource, { params: { id }, deps: [id], // 仅当 id 变化时,才重新拉取 }); return { user: query.data as User | null, loading: query.loading, error: query.error, refresh: query.refresh, }; }初看这段代码,你可能觉着useRea做的事情很像一次useEffect+useState的封装。但关键区别在于:userResource是模块级单例,它背后的数据仓库也是全局唯一的。因此,无论哪个组件调用useUser,只要 key 相同,它们读取到的就是同一份data和同一个loading。这就是资源共享的本质。3.3 三种消费姿势:默认加载、联动刷新、手动操作后失效我在这里整理三种最常见的使用场景,基本可以覆盖中后台项目里八成以上的需求。场景一:页面挂载后自动请求数据。这个最普通,但你不需要写任何useEffect:function Profile() { const { user, loading, error } = useUser(); if (loading) return <Skeleton />; if (error) return <ErrorTip message={error.message} />; return <div>{user?.name}</div>; }场景二:参数变化时自动重新请求。比如用户选择一个角色后,角色 ID 成为请求参数:function RoleMemberList({ roleId }: { roleId: number }) { const { users, loading, refresh } = useUserListByRole(roleId); // useUserListByRole 内部会把 roleId 放进 params 和 deps return <Table loading={loading} dataSource={users} onRefresh={refresh} />; }当roleId变化时,useRea会自动检测到依赖项变化,触发新一轮请求。这一步替代了原本手写的“useEffect 依赖[roleId]”逻辑,同时也自动保证了老请求不会覆盖新结果。场景三:提交一个新增操作后,手动失效缓存并刷新。这个我认为是它最提升体验的地方。以前你需要手写一个refresh函数,把setData暴露出来;现在直接调用refresh即可:const { refresh } = useUserList(); const handleCreate = async () => { await api.createUser(input); refresh(); // 触发重新拉取,所有依赖此资源的组件都会同步更新 };即便有十个组件同时使用了同一个资源,refresh()一次,也会通知到所有订阅者,它们会同时进入loading状态并拿到新数据。这种响应式扩散能力是用手写状态很难实现的,我觉得它是整套方案里最有代入感的一个特性。4. 踩过的坑与排查实录4.1 无限请求:依赖项里的对象引用陷阱我遇到的最严重的 bug,是接入useRea后页面出现无限循环请求。排查到最后发现原因出在deps数组写了一个对象引用:useRea(resource, { params: { keyword }, deps: [keyword], });这看起来没问题,但随后我改成了这样:useRea(resource, { params: { filters }, // filters 是对象 deps: [filters], // 每次 render 都是新的引用 });因为filters在父组件里是通过重组创建的新对象,导致rea认为依赖每次都在变化,于是不断触发请求。这其实是 React hook 依赖比对的老问题暴露在了自己的封装上。解决方式有两种:一是传基础类型作为资源 key 的一部分,二是使用稳定引用(比如useMemo或useRef)。我建议优先调整依赖结构,保证deps里只放原始值。4.2 刷新时数据短暂消失的问题第一次体验rea时,我发现每次手动刷新都会让页面先回到loading状态,之前展示的数据列表瞬间消失,然后整个表格变成骨架屏,最后再重新渲染。视觉上很割裂,尤其是用户只点了列表上一个刷新按钮,看到全屏闪烁会非常困惑。为了解决这个问题,我给useRea增加了keepPreviousData参数。当设置为true时,请求刷新期间不会清空已有数据,只是把loading置为 true,并用旧的data继续渲染。这种做法在数据密集型页面里几乎成了标配,它让刷新动作变得无感,用户体验好了很多。4.3 组件卸载后请求返回引发告警在最初的原型版本里,我在请求完成后直接调用emitChange(entry),而并没有检查资源是否还有活跃订阅者。这导致一个问题:某些场景下,组件已经卸载,但请求的回调仍然触发了状态更新,虽然 React 18 之后这个不算致命错误,但控制台会输出告警,而且无谓的重渲染也应避免。排查后的修复方案是:每个资源在内部维护一个订阅者 Set,emitChange前检查订阅者数量,如果没有订阅者,就不触发通知。同时,资源对象本身仍然保存着最新状态,后续有新的订阅者到来时,可以立刻读取到上次请求的结果。这个改动保证了内存使用和数据一致性之间的平衡。4.4 一份快速问题排查表我把实际项目中几个人最容易踩的问题整理成表格,方便你对照定位:现象可能原因解法页面无限请求、网络面板请求堆积deps中包含对象/数组等易变引用改为基础类型、稳定化引用或直接省略 deps快速筛选时数据偶尔被旧值覆盖竞态取消逻辑只覆盖了成功分支或完全未处理为每次请求分配 seq,成功与失败分支都做过期校验刷新时页面出现闪空默认行为是清空旧数据再加载给useRea加keepPreviousData: true多个页面各发各的请求、后端重复请求量很大未使用同一个资源实例,或资源互相不共享确保使用模块级单例资源,key 保持全局唯一切回页面时数据太旧、未自动更新staleTime设置过长且没有maxAge兜底引入maxAge作为硬过期时间,或在后台静默刷新某操作后所有页面数据不同步只刷新了组件局部状态,没有调用资源级 refresh手动操作后统一调用资源的refresh方法卸载组件后控制台出现 setState 告警没有判断订阅者是否还存在升级到带订阅者检查的版本,确保卸载后不派发更新这张表不是万能药,但它覆盖了我在这类场景里遇到的大部分异常。排查时先看网络面板里的请求次数,再检查依赖项和控制台告警,基本就能锁定问题。最后再分享一个我自己很受益的小习惯:像rea这种工具,不要期望它第一天就做成完美框架,我的建议是从一个真实项目里的一个高频场景出发,先抽取最小可用的 API,然后让同事试用一星期,集中收集反馈再迭代。我最初设计时没有keepPreviousData,也没有maxAge,这些都是在实际使用中被逼出来的。工具最终长成什么样,应该由它需要服务的真实代码形态决定,而不是由一开始的架构幻想决定。如果你也遇到类似的请求状态管理问题,不妨试着从小封装开始,你会发现收益比想象中来得快得多。