说实话HarmonyOS应用开发进行到网络层的时候,很多人会被“HTTP请求要不要再包一层”这个问题卡住。直接用@ohos.net.http写原生请求吧,逻辑能跑通,但token过期、并发重放、异常拦截这些真实工程问题会把你逼疯。我这次就基于Axios在HarmonyOS上做了一套HTTP客户端封装,核心功能是“自动刷新Token”,把登录态维护、请求重放、并发控制全部收敛在一个模块里,实测在多个业务场景下跑得很稳。这篇文章会从方案选型、拦截器设计、并发刷新队列、常见坑四个维度展开,把每一步为什么这么做、怎么落地讲清楚,适合正在做鸿蒙应用网络层、或者准备把Web端Axios经验迁移到鸿蒙的开发者参考。
1. 整体设计与方案选型拆解
1.1 为什么是Axios而不是原生http模块
@ohos.net.http是HarmonyOS提供的基础网络能力,功能上没问题,但用起来至少有两个痛点:第一,它没有一个原生拦截器机制,token过期时你需要在每个业务请求的地方手动判断状态码、手动刷新、手动重放,非常容易漏;第二,它的响应体解析、错误码映射都比较原始,需要自己在外面套一层工具函数,做多了就变成了手写一个半吊子封装,维护成本和返工风险都很高。
Axios则不同,它在Web生态里经过了大量验证,天然提供请求拦截器、响应拦截器、取消请求、统一错误处理这些能力。鸿蒙生态里有团队维护了基于TypeScript的移植版本,核心API和Web版保持一致,也就是说你在Web端写的axios.interceptors.request.use、axios.interceptors.response.use这些代码几乎可以直接平移到鸿蒙项目里。选它的本质不是“图省事”,而是把一个成熟的请求生命周期管理模型引入到鸿蒙应用中,把网络层做成一个模块而非散落在业务代码里的杂兵。
1.2 封装的核心目标:自动刷新Token并重放请求
接入过有登录态业务的都懂,token过期是常态,但用户感知应该是“无感”的。我的目标很明确:当任意请求因token失效返回401时,客户端自动走一遍刷新逻辑,刷新成功之后把之前失败的请求自动重放,全程不让用户重新登录。这个目标拆开看有三件事必须做对:第一,如何识别token失效;第二,如何在多个请求同时401时只刷新一次;第三,如何保证刷新后的重放请求是安全的、顺序正确的。
这三件事如果不用统一封装,靠业务方在页面里写try...catch去处理,大概率会出现“多个请求同时刷新、token被覆盖、部分请求始终重放不成功”的难看局面。我决定把这些逻辑统一收口到响应拦截器里,业务方只负责发起请求,其余全交给HTTP客户端。
1.3 技术选型:单实例还是多实例
这里有个容易忽略的设计决策。很多人一上来就全局axios.defaults一把梭,但真实项目里我需要至少两个实例:一个给业务请求用,带上鉴权拦截器、刷新拦截器;另一个给刷新token的接口自身用,这个实例必须干净,不能挂“token过期就刷新”的逻辑,否则刷新接口本身401时就会陷入死循环。
我的做法是:核心实例httpClient处理所有带认证标识的请求,附加请求/响应拦截器;内部再声明一个refreshClient,只用于调用/auth/refresh接口,自己控制超时时间和错误处理。两个实例隔离,各自的职责边界非常清晰。你如果后续要接第三方API、或者做无鉴权的公共接口调用,也可以基于这个思路再扩展一个publicClient,不挂任何鉴权逻辑。
2. 核心细节解析与实操要点
2.1 拦截器职责划分
拦截器是整套方案的灵魂,但如果你把全部逻辑塞进一个拦截器里,代码很快就会变成一坨浆糊。我建议严格划分三块:请求拦截器只做“附带身份标识”这一件事,响应拦截器负责“识别过期-触发刷新-重放请求”,而刷新逻辑本身独立成函数,不在拦截器里写大段复杂流程。
具体来说,请求拦截器里做的事情是:从本地存储模块读取出当前token,如果存在就设置到请求头Authorization字段。这里有个细节,一定要判断token是否为空字符串,不要盲目设置请求头,否则后端可能因为收到空凭证而返回非预期错误码。响应拦截器则是核心战场,当响应码是401时,先判断当前是否已经有“正在进行刷新”的锁,有则入队等待,没有则主动发起刷新。刷新成功后遍历等待队列逐个重放。
2.2 Token存储与并发访问的同步问题
鸿蒙应用里,本地存储的读写并不是绝对安全的,尤其在并发请求同时401、同时去读存储时,很容易读到旧值或者半个值。我在封装里单独做了一层TokenStore,读写都走同一个同步入口,并且把“当前缓存中的token值”常驻内存。刷新成功后立即更新内存值,再异步持久化到首选项(Preferences)。这一步的顺序不能反,因为并发请求重放时是从内存读最新token,从磁盘读会慢半拍。
关于存储的键名,我建议用token_type + access_token + refresh_token分开存,不要混成一个JSON。原因很简单:刷新返回的分字段更新,可以做到只改access_token不动refresh_token;而且排查问题时,线上能看到具体哪一项丢了,不用整包解JSON。
2.3 刷新接口的独立配置
刷新接口有几个关键参数必须单独处理。第一个是超时时间,刷新操作本身是“救火”请求,不能跟普通接口一个超时时间,我给refreshClient直接设置超时时间短一些且开启withCredentials等必要配置;第二个是请求头里的Content-Type,刷新接口用表单格式提交grant_type=refresh_token更稳,用JSON的话有些服务端框架解析时反而出幺蛾子;第三个是必须关闭“自动附加token”的逻辑,因为刷新接口用的是refresh_token,不是access_token。
另外刷新接口的返回体设计,我在项目里约定{ code, data: { access_token, refresh_token, expires_in } },拦截器拿到数据后,优先检查data.access_token是否非空,再决定是否更新存储。这个校验看起来多余,但在服务端偶发返回空体或者异常结构时能救你一命。
3. 实操过程与核心环节实现
3.1 初始化Axios实例和类型声明
先搞定类型声明。TypeScript的好处在这里体现得很直接,如果你用的鸿蒙版本支持ArkTS,尽量把请求配置、响应结构都定义成interface,后面写拦截器时类型提示会帮你避开很多低级错误。
import axios from '@ohos/axios'; export interface ApiResponse<T = unknown> { code: number; message: string; data: T; } export interface TokenData { access_token: string; refresh_token: string; expires_in: number; }然后是实例创建。核心实例挂默认配置:基础URL、超时时间、响应类型。这里我把超时时间设为15秒,上传类接口后续单独覆盖,避免所有请求默认超时过长导致用户等半天。
const httpClient = axios.create({ baseURL: 'https://api.example.com', timeout: 15000, headers: { 'Content-Type': 'application/json', }, }); const refreshClient = axios.create({ baseURL: 'https://api.example.com', timeout: 8000, headers: { 'Content-Type': 'application/x-www-form-urlencoded', }, });为什么超时时间差异化?普通接口复杂业务确实可能需要更多时间,但刷新接口就一个鉴权操作,后端正常情况下都是毫秒级返回,超过8秒基本可以判定网络或服务异常,继续等没有意义,还拖住了等待队列里那一堆重放请求。
3.2 TokenStore实现
TokenStore要提供四个能力:读取token、写入token、清除token、判断是否登录。读写都走内存缓存,避免并发访问时的数据错乱。
class TokenStore { private static instance: TokenStore; private accessToken: string = ''; private refreshToken: string = ''; private expiresIn: number = 0; static getInstance(): TokenStore { if (!TokenStore.instance) { TokenStore.instance = new TokenStore(); } return TokenStore.instance; } getAccessToken(): string { return this.accessToken; } getRefreshToken(): string { return this.refreshToken; } setToken(data: TokenData) { this.accessToken = data.access_token; this.refreshToken = data.refresh_token; this.expiresIn = data.expires_in; // 再异步持久化到 Preferences PreferencesUtil.putString('access_token', data.access_token); PreferencesUtil.putString('refresh_token', data.refresh_token); PreferencesUtil.putNumber('expires_in', data.expires_in); } clear() { this.accessToken = ''; this.refreshToken = ''; PreferencesUtil.delete('access_token'); PreferencesUtil.delete('refresh_token'); PreferencesUtil.delete('expires_in'); } isLogin(): boolean { return this.accessToken !== '' && this.refreshToken !== ''; } }3.3 请求拦截器实现
请求拦截器本身不复杂,但有一个小细节值得注意:判断URL是否在白名单内。有的接口本身是免登录的,比如配置拉取、验证码获取,它们不该被附加token,但也不能因为没有token就拦截掉。我维护了一个skipAuthUrls数组,通过url字段匹配,命中则直接放行。这样做不是画蛇添足,而是避免后台配置类接口在极端情况下因为带上了脏token而无法访问。
httpClient.interceptors.request.use((config) => { const url = config.url ?? ''; const skipAuth = SKIP_AUTH_URLS.some((item) => url.includes(item)); if (!skipAuth) { const token = TokenStore.getInstance().getAccessToken(); if (token) { config.headers['Authorization'] = `Bearer ${token}`; } } return config; });3.4 响应拦截器与自动刷新流程实现
这里是重点。响应拦截器要做的事情,等价于“当401发生时的故障转移机制”。我先定义两个全局概念:isRefreshing是一个布尔值,表示当前是否已经有刷新请求正在进行;waitQueue是一个数组,装满了在刷新期间被拦截下来的请求配置。
当响应拦截器捕获到401错误时,流程为:判断isRefreshing是否为true;如果是,直接把当前请求的config推入waitQueue,返回一个pending状态的Promise,等刷新完成后统一重放。如果不是,把isRefreshing置为true,调用刷新接口;刷新成功则重放waitQueue中所有请求,失败则清空队列并跳转登录页。
let isRefreshing = false; let waitQueue: any[] = []; httpClient.interceptors.response.use( (response) => { // 兼容业务码非200的情况 const res = response.data; if (res.code !== 200) { return Promise.reject(new Error(res.message)); } return res.data; }, async (error) => { const { response, config } = error; if (response && response.status === 401) { // 刷新接口自身不可重入 const url = config?.url ?? ''; if (url.includes('/auth/refresh')) { TokenStore.getInstance().clear(); redirectToLogin(); return Promise.reject(error); } if (isRefreshing) { return new Promise((resolve) => { waitQueue.push({ config, resolve, }); }); } isRefreshing = true; try { const newToken = await refreshToken(); TokenStore.getInstance().setToken(newToken); config.headers['Authorization'] = `Bearer ${newToken.access_token}`; waitQueue.forEach((item) => { item.config.headers['Authorization'] = `Bearer ${newToken.access_token}`; httpClient(item.config).then((res) => item.resolve(res)); }); waitQueue = []; // 当前这次请求也重放 return httpClient(config); } catch (refreshError) { TokenStore.getInstance().clear(); redirectToLogin(); return Promise.reject(refreshError); } finally { isRefreshing = false; } } return Promise.reject(error); } );这段逻辑的核心难点在于“重放时不能用同一个拦截器再触发刷新”,我在重放时仍然走httpClient,但由于token已经更新,理论上不会再次401。万一后端仍返回401(比如refreshToken也过期了),就会陷入递归。所以我额外加了一个标记:重放请求在config._isRetry字段上打标,拦截器里如果看到这个标记并且仍是401,就不再触发刷新,直接失败并引导重新登录。这个细节我不写注释的话,后来维护代码的同学一定想打人。
3.5 刷新接口封装
刷新接口的逻辑很简单,但有一个关键点:无论刷新成功还是失败,都要确保isRefreshing状态能够被正确复位。我在上面用的是try...finally,这比在成功和失败分支里分别复位更稳妥,因为你永远不知道会不会有意外抛错。
async function refreshToken(): Promise<TokenData> { const refreshToken = TokenStore.getInstance().getRefreshToken(); const params = new URLSearchParams(); params.append('grant_type', 'refresh_token'); params.append('refresh_token', refreshToken); const response = await refreshClient.post('/auth/refresh', params.toString(), { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); if (response.data?.code !== 200 || !response.data?.data?.access_token) { throw new Error('refresh token failed'); } return response.data.data; }refreshToken函数本身不感知isRefreshing状态,这个状态由拦截器层控制。谁调用刷新、何时允许刷新,是上层策略;刷新逻辑本身只管“拿refresh_token换新token”。这种职责分离的好处是可以单测刷新函数而不需要mock整个拦截器。
3.6 并发请求合并刷新的推演
假设页面加载时同时发出5个请求,恰巧token在10秒前过期了,这5个请求都会返回401。如果代码不做并发控制,5个401几乎同时到达响应拦截器,每个人都会尝试调一次刷新接口,那就是5次刷新请求,浪费带宽不说,还可能出现刷新接口返回新token后,后面几次刷新用旧refresh_token去换,直接被服务端拒绝。
用isRefreshing作为锁后,只有第1个401能进入刷新分支,后面4个401会直接进入waitQueue。刷新完成后再逐一重放。这里要特别注意:第1个请求本身也有一份config要重放,不能只救队列里的4个。另外,Promise的resolve要正确触发,否则业务接口就被吊着永远不返回,页面一直转菊花。
我测试下来,并发20个请求同时401的场景,这套机制能保证只发出1次刷新请求,剩余19个请求在刷新完成后1秒内全部重放完成,用户体感几乎无变化。
3.7 登录过期后的降级处理
自动刷新不是万能的。refresh_token过期、用户被踢下线、服务端手动吊销token,这些场景下刷新接口会返回明确的错误码,比如401或4003。这时候就不能再傻傻重放了,要做两件事:清空本地TokenStore,然后跳转登录页。
跳转登录页这个动作,我不建议在网络层直接router.pushUrl写死,因为鸿蒙应用里可能有多个入口栈,写死页面路径会跟业务导航耦合。我采用的方式是往外部暴露一个setUnauthorizedHandler方法,由应用入口处注入真正的登录跳转逻辑。网络层只管“判定登录态已失效”,怎么跳由业务层决定,这样后续做深链跳转、单点登录、多端互踢都能灵活适配。
4. 工具选型与依赖管理
4.1 鸿蒙Axios库的版本与能力边界
选Axios之前,我曾经犹豫过要不要用@ohos.net.http自己封装。后来对比官方文档和社区反馈,确认鸿蒙版Axios在API层面完整支持了拦截器、取消请求、超时、自定义适配器等内容,而且底层自动适配了鸿蒙的网络栈,同步接口和异步接口都有,稳定性可以放心。需要注意的是,鸿蒙版Axios对上传下载的进度事件支持需要额外配置onUploadProgress和onDownloadProgress,这两个回调在部分版本里不支持或者实现有差异,如果你要封装文件上传下载,一定要先写demo验证。
我用的版本是@ohos/axios 1.0.x以上,具体版本号记不太准了,但你尽量不要锁死在太老的版本上,尤其是如果你要用AbortController做请求取消的话,老版本可能没有对应能力。
4.2 依赖注入与模块解耦
网络层不该直接依赖业务页面或全局路由。我在封装里通过init(config)方法注入三个回调:getRefreshToken、onTokenRefreshed、onUnauthorized,分别提供刷新凭证获取、token更新回调、登录失效通知。
这样做的好处非常明显:如果项目里有多个登录体系(比如用户端+管理员端),可以各自初始化一套网络客户端实例;如果以后要迁移到微前端或者模块化开发,网络层也能作为一个独立的har包被引用。不要在模块顶层直接写死某个全局函数,那是给自己埋雷。
5. 常见问题与排查技巧实录
5.1 刷新请求死循环问题
典型症状:页面卡死,控制台网络面板看到同一批请求无限循环。原因多半是重放请求时没有加标记,或者刷新接口本身也走了带鉴权拦截器的实例,导致刷新接口401后又触发刷新逻辑。我自己的排查方法很直接:给所有请求的config加一个_isRetry标记,在拦截器入口第一步就判断config._isRetry && response.status === 401,是则直接抛错,不进入刷新分支。此外刷新接口一定走独立refreshClient实例,双保险,彻底断开这个环。
5.2 多个401并发导致刷新多次
有些同学按我上面的思路写完后,测并发时发现刷新接口被调了多次。检查点有三个:isRefreshing的赋值时机是否在await refreshToken()之前、waitQueue是否有清空操作、是否有多个httpClient实例各自有了状态。另外注意鸿蒙的异步回调时序在某些低端机上有微小概率出现两个函数同时通过if (isRefreshing)判断的情况,所以我额外把“刷新中”状态用Promise替代纯布尔值,只要第一个请求发起刷新,后续请求判断到存在一个pending的promise就直接挂起,从JavaScript事件循环层面杜绝了竞态。
5.3 重放请求时丢失自定义配置
重放请求看起来很简单,把config原封不动再调一次httpClient(config)就行。但如果你在请求拦截器里动态修改过headers,或者某些配置是拦截器执行后才补充的,直接用旧的config重放可能会丢失这些篡改。稳妥做法是:请求拦截器里不要直接改原始config对象,而是创建副本。或者在进入等待队列之前,就把最终要重放的配置快照存起来。我实际踩过的一个坑是:请求头里有动态的X-Request-Id,每次请求都会重新生成,重放时如果不更新,后端会认为这是幂等请求返回旧响应,导致数据刷新不及时。
5.4 网络异常(非401)拦截
自动刷新token只处理401场景。但弱网环境下,请求可能因为超时、DNS解析失败、服务端500等原因挂掉。这类错误我不推荐在网络层统一弹toast,因为不是所有请求都需要用户感知错误。更好的方式是:封装一个统一的错误分类器,把错误分为“网络异常”“服务端异常”“鉴权失败”“业务错误”四类,业务页面根据类型决定是静默处理、弹提示还是跳转登录。
function classifyError(error: any): ErrorType { if (error.response) { switch (error.response.status) { case 401: return 'AUTH_FAILED'; case 500: case 502: case 503: return 'SERVER_ERROR'; default: return 'BIZ_ERROR'; } } if (error.code === 'ECONNABORTED' || error.message.includes('timeout')) { return 'NETWORK_TIMEOUT'; } return 'NETWORK_ERROR'; }5.5 文件上传场景下的特殊处理
如果项目中涉及图片、文件上传,axios封装有几个地方要特别注意。第一,上传接口通常要求Content-Type为multipart/form-data,但axios会帮你自动设置boundary,如果你手动指定了Content-Type反而可能导致服务端解析失败;第二,上传接口的401处理和普通请求一致,但它不适合做“等待重放”,因为用户等了半天的上传进度条,刷新token后进度条清零重来,体验很差。我的做法是:上传接口不使用自动刷新重放机制,而是在上传前检查token是否即将过期,过期就先静默刷新,再开始上传流程。这算是自动刷新方案的一种前置变体,从源头上避免上传到一半被打断。
5.6 排查必备:抓包与日志分级
鸿蒙应用的网络排查有个特点,正式包无法直接像浏览器那样打开DevTools看网络面板。我一般分两层处理:开发阶段在拦截器里把请求URL、请求头、响应状态、耗时全部打到日志里,格式统一成[HTTP_REQ]前缀,方便过滤;真机调试阶段用Charles做代理抓包,但不要依赖它看鸿蒙内部的异步代码执行顺序,网络日志和抓包对照看才能定位是“请求发出去了”还是“响应处理环节出了问题”。
注意:这里提到Charles仅是流量查看工具,请确保符合当地法规与使用规范。
特别强调一点,绝对不要把token明文打到日志里。打印请求头时一律用token.replace(/^(.{0,6}).*$/, '$1***')这种脱敏写法,既能确认请求头有没有被正确附加,又不会泄露敏感信息。上线前建议把日志级别切到release模式,关闭细节打印。
6. 拦截器之外:接入细节和扩展建议
6.1 初始化Token的时机
应用冷启动时需要从本地存储恢复token,这个动作的时机很关键。不能等第一个请求发出时再读存储,因为那可能已经在并发场景里了。我的做法是在应用入口执行TokenStore.getInstance().loadFromStorage(),用同步构造函数后立即调用。如果存储里没有token,isLogin()会直接返回false,拦截器链路会跳过附加token的步骤,界面停留在登录页即可。
这里也顺便解释一下为什么不用异步的Preferences.get返回值直接做判断:因为请求拦截器是同步函数,你没办法在里面做await,搞成异步的话要么改变axios的拦截器签名,要么引入额外的复杂度。用内存副本配合启动时同步加载,是目前最简单可靠的做法。
6.2 对多个业务域的扩展思考
一个HTTP客户端封装,如果在团队里用得久,必然会遇到“不同业务域要不同的BaseURL”“不同环境要不同的超时时间”这些衍生需求。我建议的扩展方案不是复制粘贴创建N个实例,而是用一个工厂函数createHttpClient(options),内部统一走拦截器注册逻辑,参数差异化。这样既能保证每个实例都有自动刷新token能力,又能按需覆盖配置。
export function createHttpClient(options: HttpClientOptions) { const client = axios.create(options.axiosConfig); const domainKey = options.domainKey ?? 'default'; const authHandler = new AuthHandler(domainKey); registerInterceptors(client, authHandler); return { instance: client, auth: authHandler, }; }6.3 关于ArkTS的兼容性提醒
鸿蒙原生开发如果用的是ArkTS,有两点跟常规TypeScript不一样:第一,ArkTS对类型声明和泛型支持有部分限制,axios的AxiosRequestConfig类型可能不能跨包直接使用,需要显式声明自己的接口类型;第二,ArkTS不支持any满天飞,必须为请求配置、响应结构定义完整类型。建议封装时在tsconfig.json里开启strict模式,宁可写起来烦一点,也不要上线后一堆隐式类型错误。
7. 优化技巧与后续演进
7.1 token刷新时间预判
除了靠401被动刷新,还可以在请求发出前主动判断token是否即将过期。实现方式是在拿到token时记录expires_at时间戳,请求前比较当前时间和过期时间,如果剩余时间小于5分钟就先刷新再发请求。这个预判的最大好处是减少“请求先401再重放”的链路耗时,在一些延迟敏感的场景下体验差异挺明显。
function isTokenExpiringSoon(): boolean { const expiresIn = TokenStore.getInstance().getExpiresIn(); if (expiresIn <= 0) return true; const timeLeft = expiresIn - Date.now(); return timeLeft < 5 * 60 * 1000; }7.2 刷新失败后的退避策略
如果服务端临时故障,刷新接口连续失败,每次失败都立即跳登录页其实有点粗暴。稍微好一点的做法是:连续两次刷新失败后,再跳登录页;每次刷新失败之间加上退避延迟,比如第一次失败等0.5秒重试,第二次等1秒重试,第三次直接放弃。不过这个策略不建议做太复杂,一个简单计数+清空状态就够,否则没有足够服务端配合时反而更容易出问题。
7.3 与页面状态管理的联动
自动刷新token后,部分页面可能依赖“登录时间”展示业务信息,比如会员到期时间、积分有效期。token刷新不一定要强制通知所有页面刷新数据,但至少应该提供一个事件总线或者回调机制。我通常会在setToken方法里抛一个tokenUpdated事件,页面对登录态有变化的刷新需求时订阅这个事件即可。这样可以保证token更新逻辑对上层业务是可见的,而不是完全黑盒。
8. 总结与个人心得
写到这里,核心的自动刷新Token的HTTP客户端封装已经完整呈现了。我个人在实际项目里体会最深的是:拦截器设计的本质是“把异常流程变成正常流程”,当你把401刷新、并发重放、失败降级做到位后,业务代码里看几乎见不到token出现,登录态的维护对你透明了,这带来的开发体验提升是非常大的。
如果你也要在鸿蒙项目里做这套封装,我给三个建议:第一,一定要把TokenStore、刷新函数、拦截器拆成独立模块,别图省事全塞在一个文件里,后面需求演进时你会感谢这个决定;第二,并发刷新队列的测试不能只测“成功的路子”,要多mock401响应、模拟刷新失败、模拟重放后再次401,把异常路径走一遍才算可靠;第三,上线前做一次“登录态失效全局回归”,因为自动刷新机制最怕的问题不是功能不能用,而是某个边角场景没有降级而把用户卡死在白屏页。
最后再分享一个我自己一直用的习惯:每次封装网络层,我都会在工程里留一个简单的NetworkTestPage,页面里面放几个按钮手动触发已登录请求、过期请求、并发请求、无网请求,每次改动完拦截器逻辑,花三分钟把所有场景点一遍再提测。这比跟测试同学反复解释“这个流程是自动的,你多试几次”高效得多。这套自动刷新token的HTTP客户端封装,后续如果条件支持,我还会把离线请求队列、网络状态监听、请求幂等内容加进来,让网络层真正成为整个应用最扎实的基础设施。