前端请求库不只是发请求:从 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);

这层负责的是「请求怎么发、错误怎么抛」。但它不会替你维护组件里的 loadingerrordata,也不主动管缓存和分页。所以 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)

loadingdataerror 直接是响应式的,不用再自己 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 之外还得想清楚什么时候失效。订单列表和订单详情不是两份独立数据:详情改了状态,列表对应那行也得刷新;新增订单后,第一页列表和统计数字都要失效。工具一般都给 invalidaterefetch,但调用时机得业务自己定。我习惯把 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 会烦死用户,全丢给页面又会体验不一致。

旧请求覆盖新结果,是搜索、筛选类页面绕不过的坎。用户连着输入 aababc,三个请求都发出去,最后回来的不一定是最后发的那个。不依赖库时,我用「取消旧请求 + 只认最新 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。