HarmonyOS 7 axios+Preferences:401刷新单飞与页面离场取消
一、一个过期令牌,打出了十二次刷新
订单页上线前最后一次弱网回归,我把 Access Token 的有效期缩成 30 秒,然后停在 OrderSyncPage 等它过期。页面刷新时会并发请求订单头、行项目、物流、优惠、发票等 12 个接口。结果十二个请求一起收到 401,响应拦截器也各自调用刷新接口:服务端日志里出现 12 个 /auth/refresh,本地 token 被写了 12 次,后返回的旧响应甚至覆盖了先返回的新令牌。
当时最迷惑的现象是“偶尔成功”。如果刷新接口按顺序完成,最后一个 token 仍可用,页面看起来正常;一旦用户在弱网中离开页面,迟到的重放请求又会写入已销毁的 ViewModel。问题并不在 Axios 会不会拦截 401,而在我们把拦截器误当成了串行入口。Demo TokenSingleFlightLab 就从这段失败日志开始,只做三件事:刷新单飞、请求代次隔离、离场批量取消。

二、先约定哪些请求有资格重放
不是所有 401 都应该刷新。刷新接口自身返回 401,说明 Refresh Token 已失效;登录、注销和匿名接口也不能进入重放。业务请求最多重放一次,否则服务端持续 401 时会形成无限递归。为此,每个 Axios 配置增加三个内部字段:skipAuthRefresh、retried、requestGeneration。它们不发给服务端,只用于本地决策。
本轮固定任务 ID 为 AUTH-0810,批量请求数 12,初始 token epoch 为 26。成功刷新后进入 epoch 27;最终指标应是 401=12、refresh=1、replayOk=10、cancelled=2、staleCommit=0,总耗时 438 ms。两次取消来自用户在物流与发票重放未完成时切换筛选范围,不计为失败。状态从 TOKEN_EXPIRED 进入 REFRESHING,随后短暂经过 REPLAYING,最后落到 HEALTHY。
三、Preferences 里保存的是版本化凭据
第一段代码解决“刷新结果以什么顺序落盘”。只存一个 token 字符串无法判断迟到写入,Demo 把令牌与 epoch 视为不可分割快照。每次刷新必须基于调用前看到的 epoch 提交;如果期间已有更新,迟到响应直接丢弃。
import { preferences } from '@kit.ArkData'
interface TokenSnapshot { accessToken: string; refreshToken: string; epoch: number }
class AuthVault {
private prefs!: preferences.Preferences
private current: TokenSnapshot = { accessToken: '', refreshToken: '', epoch: 0 }
async init(context: Context): Promise<void> {
this.prefs = await preferences.getPreferences(context, 'auth_vault')
this.current = JSON.parse(this.prefs.getSync('snapshot',
'{"accessToken":"","refreshToken":"","epoch":0}') as string)
}
snapshot(): TokenSnapshot { return { ...this.current } }
async commitIfCurrent(baseEpoch: number, next: TokenSnapshot): Promise<boolean> {
if (this.current.epoch !== baseEpoch || next.epoch !== baseEpoch + 1) return false
this.current = { ...next }
this.prefs.putSync('snapshot', JSON.stringify(this.current))
await this.prefs.flush()
return true
}
}
这里的 epoch 不是服务端 token 版本,而是客户端提交序号。刷新响应必须带回调用时的 baseEpoch,并且只允许 +1,这样两个意外并行的刷新也不会互相覆盖。真实项目还应将敏感凭据放入合适的安全存储,示例用 Preferences 是为了展示状态原子性,而不是把明文持久化当成安全方案。另一个边界是 flush():只有落盘完成后才能唤醒等待重放的请求,否则进程切走时可能出现内存里是 epoch 27、磁盘仍是 26 的分叉。
四、刷新单飞不是一个布尔值
我们最早用 isRefreshing,发现 401 时设为 true,其余请求轮询等待。问题是异常路径容易忘记复位,等待者也拿不到同一个失败原因。第二段代码直接共享刷新 Promise:第一位 401 创建它,后续 401 只 await;finally 无论成功或失败都清空引用。
import axios, { AxiosError, AxiosInstance, InternalAxiosRequestConfig } from '@ohos/axios'
class AuthHttpClient {
private client: AxiosInstance = axios.create({ baseURL: 'https://api.example.test' })
private refreshPromise?: Promise<TokenSnapshot>
private refreshCount: number = 0
install(vault: AuthVault): void {
this.client.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = vault.snapshot()
config.headers.Authorization = `Bearer ${token.accessToken}`
return config
})
this.client.interceptors.response.use(r => r, async (error: AxiosError) => {
const config = error.config as RetryConfig
if (error.response?.status !== 401 || config.skipAuthRefresh || config.retried) {
throw error
}
config.retried = true
const base = vault.snapshot()
if (!this.refreshPromise) {
this.refreshCount++
this.refreshPromise = this.refresh(base).finally(() => this.refreshPromise = undefined)
}
const next = await this.refreshPromise
config.headers.Authorization = `Bearer ${next.accessToken}`
return this.client.request(config)
})
}
}
config.retried 必须在等待刷新前写入,不能等重放时再写,否则刷新 Promise 拒绝后某些包装层会把同一配置再次送进拦截器。刷新请求使用单独的裸客户端,并设置 skipAuthRefresh=true,避免它反过来触发自己。等待者拿到的是同一个 TokenSnapshot;如果刷新失败,十二个请求收到同一错误,统一转入重新登录,而不是各自弹一次提示。
这里还有一个经常被忽略的资源问题:拦截器是注册在实例上的。全局客户端只安装一次,不能在每次页面出现时重复 use();测试专用的临时实例则要保存 interceptor ID,并在销毁时 eject()。否则页面反复进入后,一个响应会经过多层相同拦截器,刷新计数又悄悄增加。
五、取消网络,不等于允许旧回调提交
Axios 的取消只能尽力阻止传输,无法撤回已经进入微任务队列的 then 回调。第三段代码给每次页面加载建立 RequestScope:所有请求共享页面 generation,筛选条件变化或页面销毁时同时取消;响应返回后仍要验证 generation,双保险阻止旧数据落到新页面。
class RequestScope {
private generation: number = 0
private sources: axios.CancelTokenSource[] = []
begin(): number {
this.cancelAll('superseded')
return ++this.generation
}
token(): axios.CancelToken {
const source = axios.CancelToken.source()
this.sources.push(source)
return source.token
}
assertCurrent(generation: number): void {
if (generation !== this.generation) throw new StaleRequestError(generation)
}
cancelAll(reason: string): void {
this.generation++
this.sources.splice(0).forEach(source => source.cancel(reason))
}
}
async function loadOrders(filter: string): Promise<void> {
const generation = this.scope.begin()
const calls = ORDER_ENDPOINTS.map(url => this.http.get(url, {
params: { filter }, cancelToken: this.scope.token(), requestGeneration: generation
}))
const results = await Promise.allSettled(calls)
this.scope.assertCurrent(generation)
this.applyBatch(results)
}
begin() 先取消旧批次再递增 generation,保证新请求拿到唯一代次。Promise.allSettled() 让 10 个已完成重放能形成一次批量提交;两条被取消的请求只进入诊断计数,不触发全页失败。onPageHide 是否取消要看产品语义:本 Demo 的订单页离开即停止,所以在 aboutToDisappear 调用 cancelAll('page-disappear');若只是弹出系统选择器,就不该把短暂遮挡当成销毁。
六、我用日志反推是否真的只有一次刷新
工程目录按职责拆成 network/AuthHttpClient.ets、network/RequestScope.ets、store/AuthVault.ets、pages/OrderSyncPage.ets 和 model/AuthMetrics.ets。HiLog 标签固定为 AuthFlight。每条日志都带 task=AUTH-0810、batchGeneration=33 与 tokenEpoch,不打印 token 内容。
关键序列是:十二条 401 intercepted epoch=26,一条 refresh start baseEpoch=26,一条 refresh committed epoch=27,十条 replay ok,两条 request cancelled,最后一条 batch settled staleCommit=0 elapsed=438ms。如果看到第二条 refresh start,就算页面最终成功也算回归失败;如果 staleCommit 大于 0,说明取消只停了网络,没有守住状态提交。

DevEco Studio 右侧模拟器显示 REFRESHING → REPLAYING → HEALTHY 的收敛过程,中间代码停在共享 Promise 和 retried 标记,底部日志能对上 epoch 26 到 27。这个画面和普通“网络请求成功”截图的区别,是它能证明十二个 401 没有变成十二次刷新。
七、最终页面展示的是一次收敛结果
最终运行时,任务 AUTH-0810 的请求批次为 12,拦截到 12 个 401,刷新次数为 1;10 个重放成功,2 个被页面切换取消,旧代次提交为 0。token epoch 稳定在 27,状态为 HEALTHY,耗时 438 ms。页面同时显示“当前无等待中的刷新 Promise”,这是判断单飞锁是否释放的重要信号。

手机页没有展示令牌明文,也没有用绿色大字把它包装成营销成果。真正值得看的,是刷新次数、重放数、取消数和旧提交数之间的关系:10 + 2 = 12,且刷新只有 1。用户再次点击“模拟令牌过期”时会创建新的 batchGeneration 34,不复用上一轮的取消源或配置对象。
八、单飞能解决风暴,不能替代鉴权策略
刷新单飞只适合同一账号、同一刷新凭据的并发窗口。多账号切换时,单飞键必须至少包含 accountId;不同环境或不同 audience 的 token 也不能共用 Promise。服务端若支持 Refresh Token 轮换,客户端要把新旧 token 的提交与 epoch 一起处理,不能只替换 Access Token。
此外,401 不一定都代表过期。账号封禁、权限撤回、签名错误可能也使用 401,最好由错误码或 WWW-Authenticate 语义再做细分。离线状态下不要不断刷新;网络恢复后应先检查页面 generation 与登录态,再决定是否重放。到这里我才确定这个修复不是“给拦截器加个锁”,而是把凭据、请求和页面三条生命周期重新对齐:凭据按 epoch 提交,请求共享一次刷新,页面只接受自己的代次。三个条件同时成立,偶发成功才变成可验证的稳定结果。
更多推荐


所有评论(0)