前端请求库不只是发请求:从 Axios 到 Alova 的思路变化
要不要从 Axios 换一个请求方案,这一步的代价得先算清楚,再谈值不值。
请求层不像换个 UI 组件,影响面局限在一个页面。它是几十上百个接口调用共用的基础设施,一旦动,缓存策略、错误处理、请求顺序保证全跟着变,回归范围能拉到半个后台。所以哪怕手上这套用着有点难受,也不该只因为「有个更新的库」就整体迁过去。这篇想把这层取舍讲清楚:Axios 到底解决了哪一段问题、Alova 这类新库把管辖范围往哪儿扩了、以及真要动手,代价该怎么控。
先交代背景,免得显得凭空焦虑。我在后台项目里最常见的重复代码,就是 loading/data/error 三件套:每个列表页写一遍,每个详情页写一遍,最后错误提示、重试按钮、空状态还各写各的、长得都不一样。重复到一定程度我才想明白,选型的关键不在「Axios 还是 fetch」这两个名字上,而在要不要把「服务端状态」当成一个独立问题来管——这几年请求库演进,回答的正是这个问题。
请求背后压着一堆状态
一个普通列表请求,从来不只是「调个接口」。页面实际要处理的还有:加载中、请求失败、空数据、分页、搜索条件变化、缓存和失效、重试、取消请求、乐观更新、重复请求合并。
这些状态如果全靠业务页面自己写,代码很快会重得离谱。Vue 组件里最典型的就是这一段:
1const loading = ref(false); 2const data = ref([]); 3const error = ref(null); 4 5async function loadList() { 6 loading.value = true; 7 error.value = null; 8 9 try { 10 const result = await request.get("/api/posts"); 11 data.value = result.data; 12 } catch (err) { 13 error.value = err; 14 } finally { 15 loading.value = false; 16 } 17}
这段代码本身没错。问题是项目里几十个列表、详情、搜索、提交表单都在重复这套流程,而每个人对错误处理、空状态、取消、缓存的写法还未必一致。
竞态尤其容易漏。用户快速切 tab 或连着改搜索词,旧请求可能后返回,把新结果覆盖掉,表现就是「我明明搜的是 A,列表却闪成了上一次的 B」。这类请求顺序问题,手写页面十有八九会漏。
这些重复也不是「多敲几行」那么无害。它们分散在几十个文件里,同一个「加载失败重试」的交互,A 页面用 toast、B 页面用整块占位、C 页面直接静默,改一次全局交互规范就要翻遍所有页面。压垮维护性的往往是同一件事被复制了几十份、还各有各的写法,而不是某一处代码本身写得有多复杂。判断要不要动请求层,本质是在算「继续复制的长期成本」和「一次迁移的短期风险」哪个更高。
Axios 的定位:把 HTTP 那一段做扎实
先说清楚 Axios 不是要被淘汰的角色。它的定位很清楚——发请求、收响应、跑拦截器,非常适合做统一的 HTTP 层:统一 baseURL、注入 token、处理响应格式、处理 401 登录过期、处理网络错误、设置超时。
一个常见封装:
1import axios from "axios"; 2 3export const request = axios.create({ 4 baseURL: "/api", 5 timeout: 10000, 6}); 7 8request.interceptors.response.use( 9 (response) => response.data, 10 (error) => { 11 const message = error.response?.data?.message || "请求失败"; 12 return Promise.reject(new Error(message)); 13 } 14);
这层负责的是「请求怎么发、错误怎么抛」。但它不会替你维护组件里的 loading、error、data,也不主动管缓存和分页。所以 Axios 更像底层能力——它不差,只是它管的那一段,和 Alova、TanStack Query、SWR 这类工具不是同一段。
这也决定了迁移不必是「二选一」。我倾向把 Axios 这层留着做协议封装:baseURL、token、超时、统一错误、登录失效;上层再决定用什么去管服务端状态。底层 HTTP 客户端和请求状态管理不是互斥的,多数项目是组合使用,这样迁移的代价也小得多——动的是上层,底层协议约定不用重写。
Alova 这类库把管辖范围往「请求策略」扩
Alova 是这两年才起来的一个请求库,还比较新,我目前是在内部工具上试,没敢一上来就压到核心业务。它和 Axios 最大的区别,是关注点从「发请求」挪到了「请求策略」:不只是把 HTTP 请求发出去,还想帮你管请求相关的状态——自动维护 loading/error、提供分页策略、支持缓存和失效、请求共享、乐观更新,并和 Vue、React 这些框架的响应式集成。
这个思路和 TanStack Query、SWR 是一路的:把请求当成应用状态的一部分来管理,而不只是一次性的网络调用。一个列表页关心的其实是「当前搜索条件下的文章列表状态」,GET /posts 只是实现这个状态的一个动作——关键词变了、页码变了、用户重新进页面了,工具按策略决定是重新请求、读缓存,还是先留旧数据等新数据回来。
比每个页面手写状态更稳,也更容易在团队里长出统一风格。但要提醒一句,请求策略库不是银弹。缓存多久、什么时候失效、重试几次、乐观更新怎么回滚,这些都得团队自己约定。工具给的是能力,不会替你判断「订单详情该缓存多久」「权限接口失败要不要全局退出登录」。而且 Alova 相对年轻,生态、文档、踩坑积累都不如 TanStack Query 厚,选它就得接受「遇到问题可能得自己啃源码」这部分成本——这也是我先在内部项目探路的原因。
一段真实的写法对比
把前面那段 Vue 手写的 loading/data/error 换成 Alova 的 useRequest,落到代码上大概是这样:
1import { createAlova } from 'alova' 2import VueHook from 'alova/vue' 3import GlobalFetch from 'alova/GlobalFetch' 4 5const alova = createAlova({ 6 baseURL: '/api', 7 statesHook: VueHook, 8 requestAdapter: GlobalFetch(), 9 responded: (response) => response.json(), 10}) 11 12// 组件里 13const { loading, data, error, send } = useRequest( 14 alova.Get('/posts', { params: { page: 1 } }), 15)
loading、data、error 直接是响应式的,不用再自己 ref 三个再手动切换。分页更明显,官方有个 usePagination 扩展,把页码、总数、是否还有下一页这些都收进去:
1const { data, page, pageSize, total, isLastPage } = usePagination( 2 (p, size) => alova.Get('/orders', { params: { page: p, pageSize: size } }), 3 { initialPage: 1, initialPageSize: 20 }, 4)
要留意的是 statesHook 这个设计——Alova 把「怎么产生响应式状态」抽成了适配器,Vue 用 VueHook、React 用 ReactHook。这让同一套请求定义能跨框架复用,但也意味着接入时得先搭对适配器和请求适配器(GlobalFetch 底层还是 fetch),这层配置成本比 import axios 一行要高一些,试点时得算进去。
缓存和请求共享是怎么生效的
Alova 吸引我的一个点,是它把缓存分了两档:内存缓存和持久化缓存。GET 请求默认会走内存缓存,同一个 method 实例在缓存有效期内再次发起,直接拿缓存不打网络。持久化缓存则能把结果落到 localStorage,适合那种不常变的字典、配置类接口:
1alova.Get('/dict/city', { 2 localCache: { 3 mode: 'placeholder', 4 expire: 60 * 60 * 1000, // 一小时 5 }, 6})
placeholder 模式挺实用:先把上次缓存的旧数据顶上去当占位,同时后台悄悄重新请求,回来再替换。列表页返回上一页时,用户不会看到白屏闪一下。这和 TanStack Query 的 placeholderData、SWR 的 stale-while-revalidate 是同一类思路。
请求共享(sharing)解决的是另一个浪费:同一时刻多个组件请求同一个接口,比如页面上三个小部件都要拿当前用户信息。没有共享,就是三个一模一样的请求同时飞出去;开了共享,Alova 会把它们合并成一个,回来再分发给三处。这个能力手写也能实现——用一个 Map 缓存「进行中的 Promise」——但库内建了就省事。不过共享的粒度依赖 method 的唯一标识,本质又绕回前面那个 key 设计问题:标识算不准,该合并的没合并,或者不该合并的合并了。
请求状态和服务端状态要分清
前端有两类状态特别容易混:本地 UI 状态和服务端状态。
本地 UI 状态是弹窗开没开、当前选中哪个 tab、输入框里临时敲的内容。服务端状态是用户信息、文章列表、订单详情、权限配置。后者有几个绕不开的特点:来源不在前端、可能被别的用户或别的页面改掉、需要重新获取或失效、可能有缓存、失败时要有降级展示。
把服务端状态当本地状态随手一存,就容易数据不同步:列表页删了一条,详情页缓存还在;用户提交成功了,顶上的统计数字没更新。请求状态管理工具真正要解决的,就是这类问题。
也正因如此,我现在把服务端状态的 key 设计当成重点。列表请求不能只拿 /api/orders 当缓存 key,得把页码、筛选、排序都算进去:
1const key = ["orders", { page, keyword, status }];
这个思想在不同库里写法不同,本质一样:请求状态得能准确描述「这份数据到底是谁」。key 设计不清楚,缓存命中就会变成事故——本该是第 2 页的数据,命中了第 1 页的缓存。
key 之外还得想清楚什么时候失效。订单列表和订单详情不是两份独立数据:详情改了状态,列表对应那行也得刷新;新增订单后,第一页列表和统计数字都要失效。工具一般都给 invalidate 或 refetch,但调用时机得业务自己定。我习惯把 key 收成一组函数,别在页面里手写字符串:
1const orderKeys = { 2 all: ['orders'] as const, 3 list: (params: { page: number; keyword?: string; status?: string }) => 4 ['orders', 'list', params] as const, 5 detail: (id: string) => ['orders', 'detail', id] as const, 6}
改完订单状态,代码就能清楚说明要刷哪一类数据:
1async function updateOrderStatus(id: string, status: string) { 2 await request.post(`/orders/${id}/status`, { status }) 3 4 invalidate(orderKeys.detail(id)) 5 invalidate(orderKeys.all) 6}
invalidate(orderKeys.all) 不是随手一刷,而是明确告诉请求层:所有订单相关的列表、统计、详情都可能过期了。这个动作散在各个页面里迟早会漏;收进一套 key 约定后,团队看代码就懂数据之间的关系。
请求乱序和统一错误:换不换库都得处理
不管用 Axios 还是 Alova,请求错误都不该散在每个页面各写各的。先统一错误结构:
1type ApiError = { 2 code: string; 3 message: string; 4 status?: number; 5}; 6 7function normalizeError(error: unknown): ApiError { 8 if (error instanceof Error) { 9 return { 10 code: "REQUEST_ERROR", 11 message: error.message, 12 }; 13 } 14 15 return { 16 code: "UNKNOWN_ERROR", 17 message: "未知错误", 18 }; 19}
页面层怎么展示可以各自决定,但错误格式要尽量统一。否则同一个接口失败,有的页面弹 toast、有的静默、有的直接甩英文报错,体验会很割裂。统一之后,登录失效、权限不足、限流、网络断开这些全局逻辑也好集中处理。
错误还要分层。401、403、网络断开适合全局;表单字段错误、业务校验失败适合页面局部。全塞全局 toast 会烦死用户,全丢给页面又会体验不一致。
旧请求覆盖新结果,是搜索、筛选类页面绕不过的坎。用户连着输入 a、ab、abc,三个请求都发出去,最后回来的不一定是最后发的那个。不依赖库时,我用「取消旧请求 + 只认最新 taskId」双保险:
1let currentId = 0 2let controller: AbortController | null = null 3 4async function search(keyword: string) { 5 currentId += 1 6 const id = currentId 7 8 controller?.abort() 9 controller = new AbortController() 10 11 const result = await fetch(`/api/search?q=${encodeURIComponent(keyword)}`, { 12 signal: controller.signal, 13 }).then((res) => res.json()) 14 15 if (id !== currentId) { 16 return 17 } 18 19 renderResult(result.data) 20}
请求状态管理库能把这层封得更自然,Alova 这类也会内建请求共享和乱序处理,但原理不变:旧请求不能覆盖新意图。这个问题不处理,搜索页、级联选择器、快速切 tab 的页面都会偶发脏数据。也就是说,请求顺序保证和统一错误是「无论换不换库都得自己想清楚」的部分——换库能省的是重复劳动,省不掉这些设计决策。
顺带提一个容易被忽略的层次:AbortController 是浏览器原生能力,Axios 从 0.22 起也已经支持用它取消,不用再依赖旧的 CancelToken。所以「能不能取消请求」这件事,在 fetch 和 Axios 上其实都不缺底层支持,缺的是把「什么时候该取消」这个业务判断落进每个页面的纪律。请求策略库的价值,正是把这份纪律变成默认行为——组件卸载、参数变化时自动取消上一次,而不是指望每个开发者都记得写。
乐观更新,麻烦在回滚
乐观更新是这类库常被拿来当卖点的能力:用户点「收藏」,界面立刻变成已收藏,不等接口回来;接口真失败了,再悄悄退回去。体验上很顺,「先改 UI」这步本身也不难,工程上难的是回滚这一步。
手写一版大概长这样,重点是把改之前的旧值存住:
1async function toggleFavorite(id: string, next: boolean) { 2 const prev = getFavorite(id) 3 setFavorite(id, next) // 先乐观更新 4 5 try { 6 await request.post(`/posts/${id}/favorite`, { value: next }) 7 } catch (error) { 8 setFavorite(id, prev) // 失败回滚到旧值 9 toast('操作失败,请重试') 10 } 11}
看着简单,坑在并发。用户手快连点两下,两个请求叠在一起,回滚时用哪个「旧值」就成了问题——存早了会把中间那次的结果也抹掉。请求策略库能把这套「快照旧值、失败回滚、成功后按服务端返回校正」封装成模式,但它替你封的是流程,替不了你想清楚「这个乐观更新在并发下语义是什么」。所以我把乐观更新只用在收藏、点赞、开关这类幂等、单值、容错代价低的操作上;订单状态流转这种一步错就是资损的,宁可老老实实等接口回来再更新。这也是评估要不要迁移时要想清楚的一点:乐观更新看着诱人,但用错地方比不用更危险。
什么时候值得付这个迁移成本
项目只有少量接口,Axios 或原生 fetch 完全够用,别为了「更现代」硬引复杂工具——那点重复代码远没到需要动基础设施的程度。
但如果项目里大量列表、搜索、分页、缓存、重复请求、状态同步扎堆,继续手写请求状态只会越来越累。判断值不值得升级,我一般过这几个问题:页面里是不是大量重复 loading/data/error;列表页是不是老在处理分页、搜索、刷新;详情页和列表页是不是经常数据不同步;要不要缓存接口结果;有没有重复请求的浪费;要不要统一乐观更新和回滚。答案大多是「是」,问题就已经不在 HTTP 客户端这一层了。
真要引入,我不建议一次性全量替换——那是把迁移代价一次性拉满。先挑列表、详情、搜索这种重复最明显的模块试点,等团队把缓存、错误、分页、失效这套约定跑顺,再逐步铺开。请求层影响面大,迁移节奏得比 UI 组件谨慎得多。
所以回到最初那个取舍:选请求方案,先看页面里重复的东西是不是已经超出了「封装一个 request 函数」能兜的范围。只是少量接口,Axios 或 fetch 加一层统一错误就够;列表、搜索、分页、缓存、刷新、乐观更新到处都是,再考虑 Alova 这类请求状态管理工具才划算。库名只是外壳,文章开头那笔账才是关键——请求乱序覆盖、缓存 key 设计、乐观更新回滚,这些判断不管换不换库都得自己做。库能省掉的是重复劳动,省不掉这几处非做不可的决策——真要迁移,先看这几处想清楚了没有,再看要不要动 Axios。