关键词:Axios / TanStack Query / React Query / 前端工程化 / 数据缓存 / 接口抽象 / 并发控制 / 失效策略 / 中后台系统 / 长期维护
引言:从「手写请求」到「数据编排」的转折
有一家做 B 端中后台的团队,技术栈很典型:React + TypeScript + Axios + 自己封一层 request.ts。
前两年业务快速增长,迭代飞快,大家都觉得这套组合简单、高效、够用。
直到系统规模上来,问题开始密集出现:
- 同一个接口被不同页面各自请求、各自缓存、各自处理 loading 和错误
- 列表、详情、弹窗之间的数据不同步,刷新页面才能「自愈」
- 分页、筛选、轮询、预加载逻辑散落在几十个组件里,没人敢动
- 埋点、重试、降级、超时策略,一半靠约定,一半靠运气
最后他们不得不做一个决策:把「请求」当成「数据源」,而不是工具函数,从手写 Axios 封装,走向 TanStack Query 这一类「数据获取框架」。
现实里,类似的迁移并不少见:
- 很多早期用 Axios + Redux-Saga / MobX 的项目,后面把「服务端数据」统一迁到 React Query / TanStack Query
- 一些重前后端分离的 SaaS,把请求层收口到 Query 层,BFF、前端、移动端共享一套「数据契约」
- 一些大厂内部中台体系里,Axios 只负责传输,真正的「接口使用规范」全部写进 Query 层
- 甚至 Vue 生态也有 Vue Query、@tanstack/vue-query,走的是同一条路:从请求工具到数据编排层
问题从来不是「Axios 不行」,而是当系统变大之后,它本来不想管的那些东西,被无限放大。
这不是工具之争,而是:你把「服务端状态」当成什么来看。是一次调用,还是一个长期存在、需要被管理的资源。
一、黄金时代:只有 Axios 的那些年
1.1 手写封装带来的掌控感
大部分团队的起点都长这样:
ts
// apiClient.ts
import axios from 'axios';
export const apiClient = axios.create({
baseURL: '/api',
timeout: 10000,
});
apiClient.interceptors.request.use((config) => {
// 加 token、traceId、统一 header 等
return config;
});
apiClient.interceptors.response.use(
(resp) => resp.data,
(error) => {
// 统一错误处理 / 弹 Toast / 上报
return Promise.reject(error);
},
);
再往上封一层:
ts
// userApi.ts
import { apiClient } from './apiClient';
export const getUser = (id: string) =>
apiClient.get(`/users/${id}`);
export const updateUser = (id: string, payload: UpdateUserDto) =>
apiClient.put(`/users/${id}`, payload);
组件里直接用:
tsx
const [user, setUser] = useState<User | null>(null);
useEffect(() => {
getUser(userId).then(setUser);
}, [userId]);
- 一切都很直观 :
get/post/put,「我调用,我拿结果」。 - 封装成本很低:几百行就能搞出一套「全局请求体系」。
- 前期非常高效 :新接口只要在
xxxApi.ts里加一个函数,回调里写逻辑就完事。
这就是很多团队的「Axios 黄金时代」。
1.2 灵活自由背后的隐形复杂度
问题是,你用 Axios 做了两件事:
- 把「网络请求」当工具函数用
- 把「服务端状态」当本地变量用
前期这么干没问题,但这些东西很容易失控:
- 每个组件都可以随手发请求:
useEffect + axios到处都是 - loading / error 各写一遍,风格不统一,还不一定都处理
- 数据生命周期全靠「人脑记忆」:需要刷新就
refetch,不需要就不管
当系统小的时候,这叫「灵活」;当系统大了,这叫「不可预测」。
「用 Axios,你拥有的是自由;
用多了,你发现自由本身就是复杂度。」
二、当系统变大后,一切都变了:请求从「工具」变成「物理规律」
2.1 并发和重复请求:带宽被你自己打爆
典型场景:
- 页面 A 列表请求
/users - 点击某行进入详情页 B,再请求一次
/users/:id - 右侧抽屉 C 也要同一个详情,再来一次
/users/:id
如果你只是「在需要的地方发请求」,很快就会出现:
- 同一资源被多处重复请求:浪费带宽和服务端资源
- 手动去重很难做统一:各处
useRef+cancalToken,代码风格完全不一样 - 滚动加载、预加载、轮询、并发组合请求,每一个都是专项工程
本质上:你在「点状」地用 Axios,而不是「面状」地管理数据源。
2.2 数据过期与一致性:什么时候该刷新?
当业务变复杂,你会碰到这些问题:
- A 页面改了用户信息,B 页面并不知道,要不要刷新?
- Tab1 改了筛选条件,Tab2 里的图表要不要跟着更新?
- 详情弹窗关闭后,后面的列表是否需要重查一次?
如果你用的是「组件内发请求 + 本地 state」,这些问题很难统一回答,因为:
- 没有统一的「资源视角」:系统不知道「当前有哪些资源在用」「哪些已经 stale」
- 没有统一的「失效策略」 :只能在「某个操作之后」凭经验
reload一下 - 没有统一的「观察点」:也就谈不上可观测性和调试
「当你开始问『该不该重新请求?』的时候,
说明你需要的已经不是一个请求库,而是一个数据生命周期管理器。」
2.3 加载与错误状态:被复制粘贴吃掉的可维护性
几乎每个组件里,都能看到类似的模式:
tsx
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
setLoading(true);
getUser(userId)
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
}, [userId]);
当你有:
- 50+ 页面
- 200+ 接口
- 10+ 人同时改 UI
你会发现:
- loading / error 的逻辑到处都是 copy / paste
- 有人会忘记
.finally,有人会在 error 里直接message.error - 出现「只加载不展示错误」或者「只展示错误不加载」的奇怪状态
这些都是工程债,但在 Axios 这个层面,你几乎无从约束。
三、TanStack Query:克制到近乎啰嗦的艺术
3.1 从「请求一次」到「声明数据源」
TanStack Query(React Query)干的事非常直接:
把「服务端状态」抽出来,变成一个可以被声明、缓存、共享、失效和观察的「资源」。
一个最简单的例子:
tsx
import { useQuery } from '@tanstack/react-query';
import { getUser } from '@/apis/user';
function UserDetail({ userId }: { userId: string }) {
const { data, isLoading, error } = useQuery({
queryKey: ['user', userId],
queryFn: () => getUser(userId),
staleTime: 5 * 60 * 1000, // 5 分钟内视为新鲜,不重新请求
});
// ...
}
这里有几个工程上的关键点:
- queryKey 是资源 ID :
['user', userId]把「这个用户」当成一个资源,而不仅是一次调用 - 缓存与新鲜度有配置 :
staleTime把「什么时候该重新请求」从组件逻辑里抽出来 - 加载与错误状态集中管理 :
isLoading/error的行为在整个项目里趋于一致
这就是它看起来有点「啰嗦」的地方:你必须先把资源「命名」清楚,再去用它。
3.2 约束带来的工程收益:去重、缓存、失效统一化
一旦有了统一的 Query 层,你可以获得一堆「不再重复造轮子」的能力:
- 请求去重 :同一个
queryKey在同一时间段内多次触发,TanStack Query 会自动合并 - 缓存复用:页面 A 请求过的用户数据,页面 B 直接复用,甚至都不需要重新发请求
- 失效策略统一 :
queryClient.invalidateQueries(['user'])就可以让全局所有用户相关的数据按约定策略刷新 - 重试 / 超时 / 退避:这些都被放进一套可配置的「物理规律」,而不是一家一个写法
简单对比一下:
tsx
// Axios 风格(伪代码)
useEffect(() => {
getUser(userId).then(setUser);
}, [userId]);
// TanStack Query 风格
const { data: user } = useUserQuery(userId);
useUserQuery 里面可以内置:
- 缓存时间
- 失败重试
- 错误上报
- 依赖的其他 Query(比如需要先拿到 token)
这些东西一旦配好了,调用方就再也不用关心细节。
3.3 把「服务端状态」从本地状态管理里拆出去
很多团队在用 Redux / MobX / Zustand,碰到一个经典问题:
- 这个 state 是放全局 store 里,还是放组件内?
- 这个数据是「长期配置」,还是服务端返回的数据快照?
TanStack Query 的核心观点是:
「服务端状态」是一类完全不同的状态。它有自己的生命周期、更新规律和一致性需求。
你不应该用同一套工具(Redux 等)去管理「按钮是否高亮」和「用户列表数据」这两种不同的东西。
更合理的分工是:
- TanStack Query 管「服务端状态」:接口数据、缓存、失效、预取、并发控制
- 本地状态库管「客户端状态」:UI 控制、临时表单、拖拽位置、筛选条件等
这样一来:
- 在 UI 代码里,你看见 Query 基本就知道「这是服务端数据」
- Query 这一层可以集中做很多「全局优化」,而不用改每个组件
「成熟的工程,不是 state 写在哪,而是:
哪一类 state 有哪一类『物理规律』和『约束』。」
四、技术债 + 业务债的交织:手写请求体系如何拖垮团队
4.1 一次性 Demo 代码,被长期运营拖成泥潭
几乎所有系统都经历过这个阶段:
- 早期用 Axios 写的「Demo 级」代码,直接被拿来扩展
- 「先这样写着,后面有时间重构」这句话被说了很多遍
- 最后大家都默认「那块代码不能动」,因为一动就一堆回归问题
在这个过程中,请求层是最被低估的复杂度来源之一:
- 一开始只是十几个接口,每个组件各写各的
- 后面变成几百个接口,每个页面都有「自己那一套」请求逻辑
- 再往后,业务开始反复改版,接口升级 / 废弃交织在一起,你根本不知道哪些 API 还在用
如果一开始就有一个「统一的数据获取层」(哪怕不是 TanStack Query),很多债可以少一半。
4.2 「约定优于配置」的失败:口头规范从来不靠谱
你可能见过这些团队规范:
- 所有请求都要走
request.ts - 不要在组件里直接用 Axios
- 所有接口都要写在
apis/目录下 - loading 一律用
useLoading这个 hook
听起来都很合理,但实际执行往往是:
- 新人 copy 一段旧代码,里面直接
axios.get,没人注意 - 一些老代码来不及改,
request.ts和axios混用 - 某些特殊页面自己搞了一套
fetchXXX工具,理由是「更灵活」
口头约定在 2 个人的项目里还行,在 20 个人的项目里几乎必然失效。
TanStack Query 这种库,本质上是在帮你做一件事:
把「团队约定」写进「可执行的约束」里,而不是写进文档。
比如:
- 所有读操作必须通过
useQuery/useInfiniteQuery - 所有写操作必须通过
useMutation,并且显式写明要invalidateQueries哪些资源 - 所有接口调用都必须声明
queryKey,否则根本没办法用
这就是「硬约束」带来的工程收益。
4.3 可观测性与调试:当问题变成「这条数据是从哪来的?」
大型前端系统里,一个非常典型的排查场景是:
「为什么这个页面上的这个数据不对?」
如果你用的是「到处手写 Axios」的模式,你需要:
- 在代码里找「谁在请求这个接口」
- 再找「谁在改这个 state」
- 再找「是不是某个组件没刷 / 被缓存了」
如果你有 Query 层,你可以:
- 打开 React Query Devtools / 自己封装的调试面板
- 看看当前有哪些 Query,在什么时间被请求过
- 哪些被 mark 为 stale,哪些被 invalidate 过
起码你有一个观察「服务端状态」的统一入口。
「没有可观测性,就谈不上工程化。
你只是在用经验和直觉,硬抗系统复杂度。」
五、为什么即使是顶尖前端团队,仍然会走向 TanStack Query
5.1 并不是「它更潮」,而是「它更可控」
顶尖的前端团队完全可以自己造一套「请求 + 缓存 + 失效 + 并发控制」框架。
很多团队也确实造过,但是:
- 造出来的框架往往不可移植,换项目就重新造一次
- 文档跟不上迭代,只剩下口口相传的「内部 best practice」
- 很多细节(比如 queryKey 命名规范、失效策略组合)没有被沉淀出来
选择 TanStack Query,本质上是:
- 认同它的「服务端状态 = 一等公民」这个工程观
- 不想再在「请求层」上无限造轮子,而是把精力放到业务和体验上
5.2 物理规律不可对抗:延迟、抖动、失败率不会因为你用不用 Query 而改变
不管你用不用 TanStack Query,后端系统都会有:
- 延迟抖动(p99 比 p50 大一个量级)
- 短暂失败(网络、限流、瞬时错误)
- 冷启动、缓存失效、突发流量
区别在于:
- 用 Axios,每个页面都要自己应对这些「物理规律」
- 用 TanStack Query,这些东西可以被抽象成统一的「策略」
例如:
ts
// 全局配置:统一重试策略
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: 2,
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 10000),
staleTime: 60 * 1000,
},
},
});
你不用再一遍一遍地想:
- 这个请求要不要重试?
- 要不要加退避?
- 要不要缓存?缓存多久?
大部分时候,全局默认值 + 个别 override 就够用了。
「真正拖垮系统的不是单次调用,而是『在真实物理世界中,成千上万次调用的叠加效应』。
TanStack Query 管的是后者。」
5.3 「顶尖」的含义:不是代码写得多,而是系统写得少、错得少
顶尖团队最终关心的是:
- 团队整体的交付速度,而不是某个接口写得有多花哨
- 系统整体的可预测性,而不是某个页面跑得有多快
在这个尺度下,TanStack Query 给的是:
- 更可预测的「接口使用方式」
- 更可观测的「数据状态」
- 更容易被新同学理解和上手的「统一模式」
「成熟的工程,最重要的不是『我能做到什么』,
而是『我不需要再手动做到什么』。」
真实案例:从 Axios 到 TanStack Query 的演进之路
案例一:SaaS 中后台,从「到处 useEffect」到集中数据层
- 原本方案
React + Axios + 自封装request.ts,几十个页面里充满了useEffect + axios调用,loading / error / empty 各写各的。 - 遇到的瓶颈
列表和详情页数据不同步,操作完需要手动刷新;
重复请求严重,同一个用户详情可以在多个地方被请求 3--4 次;
新人完全不知道「改哪一块会影响哪些页面」。 - 迁移后的方案
引入 TanStack Query,把所有「读接口」统一抽到queries/目录,导出useUserQuery / useUsersQuery / useProjectQuery等;
所有 mutation 都通过useMutation,在onSuccess里统一invalidateQueries。
收益 :- 接口相关 bug 数量 2 个月内下降约 40%
- 列表与详情页数据不一致的问题几乎消失
- 新人从「看不懂请求层」到「半天能上手改业务」
案例二:电商管理后台,从「乐观更新地狱」到统一失效策略
- 原本方案
用 Redux 管全局数据,接口调用通过 thunk/middleware 分发,手动写乐观更新逻辑。 - 遇到的瓶颈
乐观更新和实际返回不一致时,很难回滚;
多人同时操作库存 / 价格时,前端状态与后端状态不同步;
写一个复杂的「批量更新 + 撤销」功能需要在多个 reducer 之间来回跳。 - 迁移后的方案
把所有「服务端状态」从 Redux 迁到 React Query,Redux 仅保留 UI 控制和部分客户端配置;
批量操作用useMutation+onMutate / onError / onSettled写出标准化的乐观更新模板。
收益 :- 大部分乐观更新逻辑可以被复用
- 整体代码量减少约 20%(大量重复 case 被删掉)
- 回滚逻辑更集中,可测试性提高
案例三:内部运营工具,从「API 文档 != 真实使用方式」到「查询即文档」
- 原本方案
有 Swagger 文档,但每个前端项目(PC / H5 / 内嵌)各封一套 API,并各自拼装不同的查询参数。 - 遇到的瓶颈
文档更新和前端调用方式不同步;
很多接口有奇怪的「隐形字段」只有一个团队知道;
调试时经常需要「抓一遍请求」才能搞清楚到底传了啥。 - 迁移后的方案
以 TanStack Query 为中心,把每一个查询封成一个useXXXQuery,这个 hook 本身就约束了参数、返回类型和使用场景 ;
给业务方的文档不再是「接口列表」,而是「可复用的查询能力」。
收益 :- 部门间的协作变成「对齐查询定义」而不是「对齐接口字段」
- 新增一个使用场景,只要复用已有的 Query 或在其基础上轻量扩展
结论很一致:Axios 很好用,但不适合扛大梁。
真正扛大梁的,是围绕它之上的那层「数据编排和工程约束」。
最合理的平衡:分层协作,而非替代
与其讨论「要不要抛弃 Axios」,不如把整个栈分层看:
| 阶段 / 层级 | 推荐技术 / 方案 | 设计目标 |
|---|---|---|
| 原型期 / 小项目 | Axios + 轻量封装(request.ts + 少量 hooks) |
快速试错,低心智负担 |
| 成长期中后台 | Axios + TanStack Query(或 Vue Query 等) | 把服务端状态抽象成「资源」 |
| 成熟期 / 多团队协作 | Axios + TanStack Query + 约定式 queryKey / 目录规范 | 统一数据访问模式,降低协作成本 |
| 极致性能 / 特殊场景 | Axios + TanStack Query + 自定义缓存层 / Streaming 方案 | 精细控制缓存、带宽与并发 |
| 渐进式改造遗留项目 | 保留原 Axios,新增功能全部走 TanStack Query | 避免大手术式重构,渐进演进 |
再细一点到前端内部职责:
| 前端层级 | 推荐角色分工 | 关键词 |
|---|---|---|
| 请求传输层 | Axios / fetch / 自研 HTTP 客户端 | 传输、重试、统一 header |
| 数据获取编排层 | TanStack Query / Vue Query | 缓存、去重、失效、并发控制 |
| 业务服务层(hooks) | useUserService / useOrderService 等 |
聚合多个 Query + 业务规则 |
| UI 组件层 | 只消费 hooks 暴露的数据和状态 | 关注展示与交互,不关心请求细节 |
不是「Axios vs TanStack Query」,而是:
Axios 负责「怎么发」,TanStack Query 负责「怎么用」,业务 hooks 负责「用来做什么」。
总结:从 Axios 到 TanStack Query,从工具到工程
- Axios 代表局部的自由与掌控,TanStack Query 代表全局的约束与可预测性。
- 问题不是 Axios 不行,而是当系统变大时,服务端状态的「物理规律」被无限放大。
- 顶尖团队真正关心的是「系统写得少、错得少」,而不是「这个请求写得多帅」。
- 成熟的前端架构,不再把「请求」当成一个工具调用,而是当成一个长期存在、被管理的资源。
- 最合理的方案从来不是「替代」,而是「分层协作」:请求传输层、数据编排层、业务服务层各司其职。
Axios 是起跑时的加速器,TanStack Query 是长跑时的配速器。
前者帮你快速上路,后者让你跑到终点。
选择技术,其实是在选择你如何对待复杂度:是把它摊在每个组件里,还是收口到一层可以被观察、被约束、被演进的工程抽象里。
尾声:技术的尽头,是工程哲学
- 稳定靠可预测性:同一类问题,在系统里应该有同一类解决方式。
- 团队靠约束:越是大型系统,越需要把「约定」变成「可执行的规则」。
- 成熟体现在写得少、错得少 :少写一次
useEffect + axios,多用一次useXXXQuery,就是把复杂度搬离业务代码一步。
Axios 让你随时可以写一行请求代码,TanStack Query 让这行代码背后有一整套「物理规律」兜底。
成熟的团队,既懂得什么时候该追求自由,也懂得什么时候该把自由托付给框架。