跳到主要内容

react-query 管不到 BFF?alova/server 补上重试限流

· 阅读需 4 分钟
Alova Team

你用 react-query 搭了前端,它的缓存和重试让浏览器那边很顺。后来你起了个 Node BFF 调三个下游服务,这些调用的重试和限流都落在服务端,而 react-query 在那儿根本不跑。这篇划清 react-query 的工作止步于哪、alova/server 从哪接手,并给出两侧的代码。

那条线

react-query 活在浏览器里。它的 useQuery 重试、缓存、去重,全都发生在每个浏览器标签页里,绑着 React 的渲染周期。当你的 BFF 调下游时,这些逻辑在 BFF 内部一个都不执行。所以问题不是「react-query 还是 alova」,而是「服务端那层调用归谁管」。

重试:浏览器 vs BFF

react-query 在浏览器里重试一个失败的查询:

useQuery({
queryKey: ['resource', id],
queryFn: () => fetch(`/api/resource/${id}`).then(r => r.json()),
retry: 3,
retryDelay: attempt => Math.min(1000 * 2 ** attempt, 8000)
});

这个重试护的是用户的视图。它对 BFF 出站调下游毫无作用,因为那次调用压根不经过 react-query。

在 BFF 上,alova/server 对出站请求做退避重试:

const { createAlova } = require('alova');
const { axiosRequestAdapter } = require('@alova/adapter-axios');
const { retry } = require('alova/server');

const alovaInst = createAlova({
baseURL: 'https://downstream.internal',
requestAdapter: axiosRequestAdapter()
});

const getResource = (id) =>
retry(alovaInst.Get(`/resource/${id}`), {
retry: 5,
backoff: { delay: 1000, multiplier: 2 }
});

// 在路由 handler 里:
const data = await getResource(req.params.id);

同一个思路,不同层。react-query 管浏览器,retry 管服务端。顺带一句:alova 的客户端 hook 在 uni-app / Taro / 小程序里也能跑同一套 useRequest,所以「浏览器/服务端分层」在内客户端也成立。

限流:只在服务端有

react-query 完全没有「给 BFF 调下游做限流」这个概念,它的 API 里找不到「对服务 X 每秒最多 4 次」这种东西。那是服务端的事,而 alova/server 的 createRateLimiter 正是为它生的:

const { createRateLimiter } = require('alova/server');

const rateLimit = createRateLimiter({ duration: 4000, points: 4 });

app.get('/api/resource/:id', async (req, res) => {
try {
const data = await rateLimit(
retry(alovaInst.Get(`/resource/${req.params.id}`), {
retry: 5,
backoff: { delay: 1000, multiplier: 2 }
}),
{ key: `downstream:${req.params.id}` }
);
res.json(data);
} catch (err) {
res.status(429).json({ error: 'downstream busy, retry later' });
}
});

rateLimit 在请求真正出去之前先扣一个点,被下游打趴的时候它不被你自己的流量埋了。默认存储是 method 的 l2Cache;跨实例时你换成一个共享存储(@alova/psc 或 redis),额度才是真正集群级的。

缓存:先搞清楚它活在哪一侧

这是最容易被混的一点。react-query 的缓存是「每浏览器」的,它不让同一个用户在同一标签页里重复拉同一个资源,但对你 BFF 猛锤一个慢下游毫无作用,因为那次调用不在浏览器里。

alova 的缓存也是客户端策略特性:cacheFor 控制响应保留多久,模式只有 memoryrestore,非 GET 请求默认是 null(不缓存)。在服务端,alova/server 给你的是 retrycreateRateLimiter,它并不顺手塞给你一个下游响应的服务端缓存。如果你不想对重复的慢 GET 反复打下游,得自己加一层缓存(或倚仗下游自己带的),再把 retry/rateLimit 叠上去。

所以分工很干净:react-query 守浏览器缓存,alova/server 守 BFF 出站的重试和限流,互不相踩。

两个工具管的是不同层。请求止步于浏览器的地方用 react-query,BFF 那层交给 alova/server。要完整选型视角,见 react-query 与 alova 对比

你的 BFF 被限流打挂,加一层重试就好

· 阅读需 5 分钟
Alova Team

你跑着一个 Node BFF,夹在前端和三个下游服务中间。某天下午最大的那个下游开始回 429,你的 BFF 对每个 429 的回应是:立刻把同一个请求再发一遍。一分钟之内,下游被「它已经拒绝过的请求的重试」埋了,你的前端看到一整面 500。这场景熟不熟悉?

下面这种写法最容易把你带到沟里:

app.get('/api/resource/:id', async (req, res) => {
try {
const data = await fetch(`https://downstream.internal/resource/${req.params.id}`);
res.json(await data.json());
} catch (err) {
// 朴素重试:现在就再发一次
const retry = await fetch(`https://downstream.internal/resource/${req.params.id}`);
res.json(await retry.json());
}
});

它看起来人畜无害,直到出事。里面藏着两个问题:

  1. 重试不看为什么失败。 429 的意思是「慢点」。立刻重试是慢点的反义词,你把你刚搞砸的那个状态又放大了一遍。
  2. 没有客户端限流。 BFF 不管不顾地把每个进来的请求都转发给已经喘不上气的下游,没有任何东西给下游兜底。

如果你跑了不止一个 BFF 实例,手写计数器(模块级变量、内存 Map)也救不了你。每个进程各算各的,于是你以为的「每秒 4 次」上限,实际变成了每个实例每秒 4 次。三个节点加起来就是每秒 12 次。

这一层到底需要什么

先别管库。一个扛得住限流的下游调用层,至少得有这些能力:

  • 退避,而不是立刻重发。 等一会儿,再等比刚才更久。指数退避加一点抖动,能让所有实例别踩着同一个拍子重试。
  • 重试要封顶、要挑失败。 试几次就停,而且只针对值得重试的失败(超时、5xx、429),对永远不成的 4xx 别浪费次数。
  • 客户端限流。 在真正调用下游之前先查一份大家共享的额度,别往火里添柴。
  • 计数器要跨进程。 这个额度得放在所有实例都认的地方,不是某个进程的内存里。

全手写的话,上面的 plumbing 不小。alova 的 server 端给了两个 hook,前三项直接覆盖,第四项靠一个可插拔的存储。

retry + createRateLimiter,一个中间件

alova 是一套请求策略层。在服务端,alova/server 提供了包裹请求 method 的 hook:retry 加退避,createRateLimiter 加客户端限流。它们包的都是你本来就拿去调下游的那个 method 实例。

先建一个 alova 实例。服务端我用 axios 适配器,请求还是从 axios 出去,alova 只负责「怎么请求」这一层:

const { createAlova } = require('alova');
const { axiosRequestAdapter } = require('@alova/adapter-axios');
const { retry, createRateLimiter } = require('alova/server');

const alovaInst = createAlova({
baseURL: 'https://downstream.internal',
requestAdapter: axiosRequestAdapter()
});

// 每 4 秒窗口 4 个点,额度记在 method 的缓存存储里
const rateLimit = createRateLimiter({ duration: 4000, points: 4 });

然后包住下游调用。retry 放里面,rateLimit 放外面:

app.get('/api/resource/:id', async (req, res) => {
try {
const data = await rateLimit(
retry(alovaInst.Get(`/resource/${req.params.id}`), {
retry: 5,
backoff: { delay: 1000, multiplier: 2 }
}),
{ key: `downstream:${req.params.id}` }
);
res.json(data);
} catch (err) {
// 触发限流,或重试耗尽
res.status(429).json({ error: 'downstream busy, retry later' });
}
});

和朴素版比,改动在这几处:

  • retry: 5 封了次数。backoff.delay: 1000multiplier: 2,间隔拉成 1s、2s、4s、8s,被下游打趴的时候它喘口气,而不是立刻补第二刀。
  • rateLimit 在请求真正到达下游之前先扣一个点。一个窗口里只有 4 个能过去,其余的会抛错、你早早回 429,下游不被你自己的流量压垮。
  • key 把额度按下游资源分开,一个热点 key 不会把别的资源饿死。

retry 的默认值在这里也有意义:不传参数时它重试 3 次、每次隔 1 秒。我们改大,是因为这个下游够不稳定、值得多试几次。

集群那一块

默认的限流存储是 method 的 l2Cache。单进程下就是内存,够用。跨实例时你把 store 指到一份共享存储,比如 @alova/psc 或 redis 适配器,所有节点从同一个额度里扣。这一块是手写内存计数器自己永远造不出来的,除非你专门去搭。

你能拿到什么

不是基准测试,只是这份配置实际产生的行为:

场景朴素 handler加 retry + rateLimit
下游回 429立刻重试,更重的负载退避等待,5 次后停
三个 BFF 节点,上限 4/s实际发了 12/s跨节点共 4/s(共享存储下)
同一请求来了两次两个都在飞第二次复用第一次在途的请求

最后一行是请求共享:alova 会把完全相同的并发请求去重,两个用户要同一个资源,不会触发两次下游调用。

如果你的 BFF 调的下游会限流,而且你跑了不止一个实例,上面这套组合值得花一个下午。

react-query 还是 alova:看请求跑不跑出浏览器

· 阅读需 4 分钟
Alova Team

选数据请求库,通常开头都是「谁的缓存更好」。这个问题跳过了一个事后才咬人的点:你的请求到底跑在哪?

如果你的请求全在浏览器里、而且用 React,react-query 是成熟、踩坑少的选择。可一旦你需要同一套请求逻辑跑在「不是 React Native 的手机端」,或者跑在一个调下游服务的 BFF 里,地图就变了。alova 把这些情况收到了同一套 API 下。这篇是直来直去的对比,也包括 alova 更弱的地方。

react-query 明显领先的地方

先讲诚实的一面:

  • 和 React 贴合。 react-query 是围着 React 的渲染模型建的。useQuery 这类 hook 天然嵌进组件,它的 devtools 和查询面板是这个领域里最顺手的。
  • 缓存成熟度。 它的缓存、去重、后台重拉、stale-while-revalidate,在大量生产应用里被反复验证过。
  • 社区与答案。 这么多年的 Stack Overflow、博客、能直接抄的片段,意味着大部分问题网上已经有人解过了。

如果你的前端是 React、只在浏览器里、你也对这个生态满意,react-query 是更稳的默认。说反话是不诚实的。

alova 走得更远的地方

alova 也是请求策略层,但它不绑死某个框架或某个运行时:

  • 一套 API 跨运行时。 同一套 useRequestusePagination 调用,跑在 Web、移动端(React Native / Expo / uni-app / Taro / 小程序)、以及服务端(Node BFF)。请求逻辑你只写一遍。
  • 服务端 hook。 alova/server 给 BFF 的出站调用加了 retrycreateRateLimiter,这一层 react-query 管不到,因为它活在浏览器里。
  • 框架任选。 React、Vue、Svelte、Solid 都有一等公民级别的 hook,混合技术栈的团队不必被绑进 React。
  • 站在你现有的适配器上。 alova 自己不发请求,它跑在 fetch 或 axios 适配器之上,所以你已经在用 axios 的话,它继续干活。

逐项对照

关注点react-queryalova
主运行时浏览器(React)Web、移动端、服务端(BFF)
框架React 优先React / Vue / Svelte / Solid
客户端缓存极佳、成熟不错,策略 hook
服务端重试 / 限流不归它管alova/server hook
跨端 App仅 React NativeReact Native、Expo、uni-app、Taro、小程序
自己发 HTTP否(底层 fetch/axios)否(fetch/axios 适配器)
社区规模非常大较小

最后一行说明:两个库都不替代 axios。它们都坐在请求适配器之上,所以团队已经在用 axios 的话,发请求还是它的活。

什么时候 react-query 更对

  • 前端是 React、只在浏览器,你想要最深的 React 缓存生态。
  • 你不跑一个需要出站重试/限流的 BFF。
  • 团队已经熟 react-query,用它出活快。

什么时候 alova 更合适

  • 你要跨 Web、非 React Native 的 App、Node BFF 维护同一套请求逻辑。
  • 你的 BFF 需要对下游调用做退避重试加客户端限流(见 BFF 重试那篇)。
  • 你在用 Vue/Svelte/Solid,或混合栈,想要一个策略层。

什么情况下两个都不需要

如果你就发几个请求,手写 loading/data/error 三个状态也不重复,那 fetch 加一个小封装就够了。两个库只有在「同一类请求模式反复出现」时才值回票价。

axios 和 alova 一起用,谁管什么

· 阅读需 3 分钟
Alova Team

一句话:它们是分层关系,不是二选一。 axios 是 HTTP 客户端,负责把请求发出去;alova 是请求策略层,负责「怎么请求」——分页、缓存、重试、去重、token 刷新。alova 可以把 axios 用作请求适配器(@alova/adapter-axios),所以选 alova 从来不意味着扔掉 axios。

alova 的定位:一套请求 API,跑在 Web、App(uni-app/Taro/小程序)、服务端(BFF)。

分工表

关注点axiosalova
发送 HTTP 请求✅ 本职交给适配器(fetch / XHR / axios / uni-app / Taro)
拦截器beforeRequest / responded(你的 axios 拦截器继续生效)
loading / error / data 状态手写useRequest 等自动管理
分页、表单、上传、SSE 流程手写✅ 20+ 现成策略 hooks
响应缓存手写✅ 多级缓存(L1/L2)+声明式失效
相同并发请求去重手写✅ 内置请求共享
跨端(uni-app / Taro / 小程序)社区封装✅ 官方适配器,同一套 API
服务端重试 / 限流手写alova/server

axios 实打实更强的地方

诚实的对比要写两面:

  • 生态与熟悉度。 axios 拥有 JavaScript 世界最大的社区之一,几乎每个边角问题都有现成答案,几乎每个开发者都用过它;alova 的社区规模远小于它。
  • 团队零学习成本。 所有人都会 axios.get();alova 引入了 Method 抽象和策略 hooks,这是真实存在的(虽然不大的)心智负担。
  • 请求本来就少的项目,axios 单独用就够了。策略层只有在你反复手写同样的请求逻辑时才值回票价。

两个一起用长什么样

保留你现有的 axios 实例——包括拦截器和 baseURL——让 alova 来驱动它:

import axios from 'axios';
import { createAlova } from 'alova';
import { axiosRequestAdapter } from '@alova/adapter-axios';
import VueHook from 'alova/vue';

// 你现有的 axios 实例,一行不改
const customAxios = axios.create({ baseURL: '/api', timeout: 10000 });

const alovaInst = createAlova({
statesHook: VueHook,
requestAdapter: axiosRequestAdapter({ axios: customAxios })
});

Vue 3 里一个普通请求的前后对比:

// 只用 axios:手写三个状态
const loading = ref(false);
const data = ref({});
const error = ref(null);
const load = async () => {
try {
loading.value = true;
data.value = await customAxios.get('/todos');
} catch (e) {
error.value = e;
}
loading.value = false;
};
onMounted(load);

// axios + alova:状态自动管理
const { loading, data, error } = useRequest(alovaInst.Get('/todos'));

你为 axios 做的配置全部继续有效:method 配置支持 axios 的全部请求选项;拦截器顺序也是确定的——alova 的 beforeRequest 先于 axios 请求拦截器触发,alova 的 responded 晚于 axios 响应拦截器触发。

什么情况下你不需要 alova

  • 项目请求本来就少,没有反复出现的分页/缓存/重试样板——直接用 axios 就是正确选择。
  • 纯 React、纯 Web、且已深度使用 React Query——除非你需要跨端或服务端策略,否则切换收益不大。
  • 你想要零抽象的技术栈——alova 的 Method + hooks 模型是多一层概念,这个成本是真实的。

下一步

uni-app 分页列表:用一个 hook 解决竞态、重复请求和加载状态

· 阅读需 5 分钟
Alova Team

你大概也在 uni-app 项目里写过这样的分页加载——onReachBottom 里加一页、发请求、拼数组:

<script setup>
import { ref } from 'vue';
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';

const list = ref([]);
const page = ref(1);
const loading = ref(false);

const loadList = async () => {
loading.value = true;
const res = await new Promise(resolve => {
uni.request({
url: `https://api.example.com/goods?page=${page.value}&pageSize=10`,
success: resolve
});
});
list.value = [...list.value, ...res.data.list];
loading.value = false;
};

onReachBottom(() => {
page.value++;
loadList();
});

onPullDownRefresh(async () => {
page.value = 1;
list.value = [];
await loadList();
uni.stopPullDownRefresh();
});

loadList();
</script>

这段代码能跑,但里面至少埋了 3 个问题,你可能已经在线上遇到过其中一两个:

  1. 竞态:用户快速滚动触发两次 onReachBottom,第 2 页和第 3 页的请求同时在飞。谁先回来谁先拼进数组——弱网下第 3 页完全可能先到,列表顺序就乱了。
  2. 重复请求onReachBottom 在部分平台会连续触发多次,没有防重,同一页数据会被请求并拼接两遍。
  3. 边界缺失:没有"是不是最后一页"的判断,滚到底还在发空请求;下拉刷新和加载更多共用一个 loading,刷新时列表先被清空再慢慢回填,页面会闪。

要把这三个坑都补上,还得加:请求去重的标记位、按页码丢弃过期响应、isLastPage 计算、刷新与追加两套状态……手写下来通常是几十行和列表业务无关的样板代码,而且每个列表页都要再来一遍。

理想的分页方案需要什么

先不谈任何库,一个"不出事"的分页实现至少要内建这些能力:

  • 请求级去重:同参数请求在飞时不重发;
  • 响应按页归位:晚到的旧页响应不能覆盖新页;
  • 追加/刷新两种模式:加载更多是追加,下拉刷新是重置;
  • 边界状态isLastPagetotal、加载中/预加载中要能直接拿到;
  • 跨端可用:同一套代码要能跑在小程序、H5、App。

最后一点是 uni-app 场景的特殊约束——React Query、SWR 这类方案默认面向浏览器 fetch,在小程序环境(uni.request)里没有官方适配。这也是很多 uni-app 项目最终还是回到手写的原因。

用 usePagination 实现

alova 是一个请求策略库:一套请求 API,跑在 Web、App(uni-app/Taro)、服务端(BFF)。它通过 @alova/adapter-uniapp 适配器直接使用 uni.request 发请求,上层的 usePagination 策略 hook 把上面那份需求清单全部内建了。

安装(注意:uni-app 适配器目前仅支持 Vue 3 版本的 uni-app):

npm install alova @alova/adapter-uniapp @alova/shared --save

创建 alova 实例,适配器一次性提供请求适配、存储适配和 VueHook:

// api/index.js
import { createAlova } from 'alova';
import AdapterUniapp from '@alova/adapter-uniapp';

export const alovaInst = createAlova({
baseURL: 'https://api.example.com',
...AdapterUniapp(),
responded(response) {
const { statusCode, data } = response;
if (statusCode >= 400) {
throw new Error('request error');
}
return data || null;
}
});

列表页完整实现——之前手写的页码管理、竞态防护、边界判断、双 loading,现在都由 hook 返回:

<template>
<view v-for="item in data" :key="item.id" class="goods-item">
{{ item.name }}
</view>
<view v-if="loading">加载中...</view>
<view v-if="isLastPage">没有更多了</view>
</template>

<script setup>
import { usePagination } from 'alova/client';
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app';
import { alovaInst } from '@/api';

const queryGoods = (page, pageSize) =>
alovaInst.Get('/goods', {
params: { page, pageSize }
});

const { loading, data, page, isLastPage, total, reload } = usePagination(
(page, pageSize) => queryGoods(page, pageSize),
{
append: true, // 追加模式:下一页数据自动拼到列表底部
initialPageSize: 10,
data: response => response.list,
total: response => response.total
}
);

// 加载更多:只改页码,去重、竞态、末页判断都在 hook 内部处理
onReachBottom(() => {
if (!isLastPage.value) {
page.value++;
}
});

// 下拉刷新:reload 重置到第一页
onPullDownRefresh(async () => {
await reload();
uni.stopPullDownRefresh();
});
</script>

对比一下:业务代码只剩"页码 +1"和"reload"两个动作,之前那 3 个坑对应的防护逻辑(请求共享去重、响应归位、isLastPage)都不需要你自己维护。usePagination 还默认开启相邻页预加载,翻页时下一页往往已经在缓存里。

同一份代码编译到微信小程序、H5、App 均可运行——发请求的始终是 uni.request,alova 只负责"怎么请求"这一层。如果你的项目已经在用 axios(H5 端),它同样可以通过 @alova/adapter-axios 作为 alova 的请求适配器继续工作,拦截器逻辑不用动。

在线示例(含分页、下拉加载等 24+ 个可运行例子):alova.js.org/examples

什么情况下你不需要它

诚实地说,以下场景手写就够了,引入任何请求库都是多余:

  • 列表只有一页、或数据量固定:一个 uni.request 加一个 ref 就是最优解。
  • 项目只跑 H5 且已深度使用 React Query/SWR:它们在纯浏览器环境很成熟,没必要为单端项目换方案。
  • uni-app Vue 2 项目@alova/adapter-uniapp 仅支持 Vue 3,Vue 2 项目请评估后再引入。
  • 团队已有稳定的分页封装且经过线上验证:能跑的老代码比新依赖更可靠,等重构窗口再说。

反过来,如果你的项目里有多个分页/下拉加载列表、踩过竞态或重复请求的线上问题、或者要同时维护小程序 + H5 + App 三端,usePagination 值得试一次:

npm i alova