
项目标题: 鸿蒙 HarmonyOS 6 | 逻辑核心 (03)网络通信——Axios 封装、拦截器设计与泛型接口处理1. 网络层设计为什么你的每个鸿蒙应用都躲不开这一层做鸿蒙应用开发最怕的不是页面写不出来而是需求一变更所有接口调用全部推倒重来。这个坑我踩过好几次所以才有了今天这篇关于网络层的总结。HarmonyOS 6 里网络通信依然是一切业务逻辑的地基从登录鉴权到数据列表再到文件上传几乎每个功能都依赖一个稳定、统一、可维护的 HTTP 请求层。在项目里选择 Axios 作为网络核心不是因为它是“标配”而是它的拦截器机制和泛型支持特别适合鸿蒙生态下的动态权限、Token 管理和多端适配。很多朋友会问鸿蒙官方不是有ohos.net.http吗为什么还要用 Axios这个问题问到点子上了。官方的 HTTP 能力确实够用但它太“底层”——你要自己处理请求头拼接、响应状态码判断、错误码映射、Token 刷新、取消请求、并发控制等等一套写下来工作量不小而且很难复用到其他 App。而 Axios 在鸿蒙中提供了类似前端生态的开发体验基于 Promise天然搭配 async/await又支持拦截器链、取消令牌和请求/响应统一处理这跟鸿蒙的 ArkTS 语法非常契合。这篇文章我会把我在 HarmonyOS 6 项目里沉淀下来的网络层方案完整拆开从 Axios 实例封装、拦截器设计、泛型接口处理到登录请求的完整落地流程最后再放一批真实踩过的坑。目标是让你能直接照着这套思路在自己的项目里搭出一套“带保险丝”的网络请求管道。适合正在做鸿蒙应用开发的初学者也适合团队里需要统一网络层规范的负责人。2. 封装 Axios 实例定好规矩再干活2.1 创建独立的 Http 工具类网络请求层最忌讳“到处 new Axios”每个人都按自己的习惯配置一遍超时、Header那项目很快就失控了。我习惯先做一个独立的 Http 工具类里面只负责创建和导出一个统一配置的 Axios 实例。在 HarmonyOS 6 项目里Axios 的引入方式通常是这样import axios from ohos/axios; import { BusinessError } from kit.BasicServicesKit;声明一个实例时推荐的配置项包括基础 URL、超时时间、Header 预设。我把它们放在一个配置类里方便不同环境切换export class HttpConfig { static baseURL: string https://api.example.com/v1; static timeout: number 15000; static defaultHeaders: Recordstring, string { Content-Type: application/json, Accept-Language: zh-CN, }; } export const httpClient axios.create({ baseURL: HttpConfig.baseURL, timeout: HttpConfig.timeout, headers: HttpConfig.defaultHeaders, });这里有两个细节值得留意。第一个是baseURL最好不要写死在代码里因为在鸿蒙的多环境打包场景里测试服、预发布服、生产服的域名往往不同。我见过团队用条件编译处理但在 HarmonyOS 6 里更稳妥的做法是结合BuildProfile或运行时配置来实现。如果你的应用还涉及多端部署这一层更要做好隔离。第二个是timeout的设置。15 秒不是拍脑袋定的而是综合了接口耗时统计和弱网模拟测试的结果。太短容易在弱网环境下频繁失败太长又会让用户面对漫长的加载态。建议第一次配置时用 15 秒作为基线等跑一段时间收集到真实的 P95 请求耗时再动态调整。注意headers里的Content-Type默认用application/json但如果你有文件上传接口得单独处理multipart/form-data。不建议在默认实例里把上传场景的 Header 写死否则后续每个上传请求都要绕开默认设置。2.2 请求取消与超时兜底很多新人在初始化实例之后以为“封装”就算完成了但实际使用中页面销毁后的请求回调才是崩溃重灾区。在鸿蒙的 ArkUI 里页面aboutToDisappear之后如果网络回调里还去操作State变量轻则内存泄漏重则直接抛异常。Axios 的CancelToken在这里发挥了关键作用。比如在列表页中每次发起请求前先生成取消令牌离开页面时取消export function createCancelToken(): AbortController { const controller new AbortController(); return controller; } export async function fetchList(params: ListParams, signal?: AbortSignal) { const response await httpClient.getListResponse(/items, { params, signal, }); return response.data; }在页面侧aboutToDisappear() { this.abortController?.abort(); }这个做法在 HarmonyOS 6 的网络层中非常实用特别是当页面有多个并发请求时通过AbortController可以一次性中断所有关联请求。建议团队封装一个useCancellableRequest之类的工具把“页面生命周期 请求取消”绑定起来效率会高很多。补充一个细节不要只在 UI 层做取消数据层也要考虑。因为 ArkTS 是单线程模型网络回调如果不做控制很容易穿透生命周期继续执行。这里我的经验是在业务代码的.catch里先判断是否为“主动取消”如果是直接吞掉异常不要上抛到全局错误处理器。2.3 环境切换与实例复用一个大型项目往往有多个模块不同模块可能使用不同的域名。有人会针对每个模块创建新实例但这样会重复代码。我习惯的做法是创建一个createHttpClient(config)工厂函数export function createHttpClient(baseURL: string, config?: PartialHttpConfig) { return axios.create({ baseURL, timeout: config?.timeout ?? HttpConfig.timeout, headers: { ...HttpConfig.defaultHeaders, ...config?.headers, }, }); }主实例、用户模块实例、支付模块实例都从同一个工厂函数创建保证核心配置统一同时按业务域隔离。如果你在 HarmonyOS 6 中使用了元服务或者碰上了多 Module 工程这种模式会让整个网络层的边界清晰很多。3. 拦截器设计给请求与响应装上“安检门”3.1 请求拦截器Token 注入与动态 Header拦截器是 Axios 封装中最值得投入精力的部分。请求拦截器做的事情可以有很多但最重要的永远是鉴权信息的注入。HarmonyOS 的本地存储能力和安全组件体系比较完善Token 一般会存放在用户偏好数据库或安全存储中。请求拦截器示例httpClient.interceptors.request.use( (config: InternalAxiosRequestConfig) { const token getTokenFromPreference(); if (token) { config.headers[Authorization] Bearer ${token}; } // 附带客户端版本信息方便排查问题 config.headers[X-Client-Version] getAppVersion(); return config; }, (error: BusinessError) { return Promise.reject(error); } );这里对InternalAxiosRequestConfig的headers操作要特别小心。鸿蒙的 Axios 类型定义和 Web 端略有差异headers的类型更严格。如果你直接给config.headers赋值一个普通对象在某些版本下会报类型错误。稳妥的做法是使用config.headers.set(key, value)或者先用config.headers config.headers ?? {}做空值兜底再赋值。注意请求拦截器里不要做耗时太久的同步操作。如果你要从安全存储中读取 Token 或做解密这个过程本身可能涉及异步逻辑。HarmonyOS 6 的 Axios 拦截器支持返回 Promise因此你完全可以在拦截器内awaitToken 获取完成后再放行请求而不是把 Token 读取逻辑散落在业务代码里。动态 Header 除了 Token还有签名参数、设备信息、语言标识等。建议把所有这些逻辑都收敛在请求拦截器中让业务代码保持干净。这样如果后端要求新增一个签名头你只需要在一处改动。3.2 响应拦截器统一拆包与错误码收敛响应拦截器承担两个核心任务统一拆包和全局错误处理。先看一个常见的后端响应结构{ code: 200, message: success, data: { ... } }如果不做拆包每个业务代码里都得写res.data.data这不仅难看而且一旦接口结构调整所有调用点都要跟着改。我的习惯是在响应拦截器里先把res.data解析出来然后检查业务状态码codeinterface ApiResponseT any { code: number; message: string; data: T; } httpClient.interceptors.response.use( (response) { const apiResponse response.data as ApiResponse; if (apiResponse.code ! 0 apiResponse.code ! 200) { // 业务错误统一处理 handleBusinessError(apiResponse); return Promise.reject(new Error(apiResponse.message)); } return response; }, (error) { handleNetworkError(error); return Promise.reject(error); } );错误处理中最怕的是把所有异常都一股脑弹 Toast。我的经验是首先要区分“业务错误”和“网络错误”业务错误通常意味着请求已经到达服务器并完成处理只不过业务逻辑不允许而网络错误则是请求根本没有成功。这两类错误的排查思路和提示方式都应该不同。同时建议把错误信息统一转成自定义的ApiException或HttpException而不是让原生 Error 直接冒泡到业务层。这样调用方能统一 catch并根据错误类型做分支处理。3.3 响应拦截器的“重试机制”与并发队列处理有些场景下接口因为弱网或瞬时抖动返回 502但用户其实可以容忍一次短暂重试。我习惯在响应拦截器里针对幂等 GET 请求做一次重试重试次数为 1并且用额外的配置标记httpClient.interceptors.response.use( async (response) { // ... }, async (error) { const config error.config as InternalAxiosRequestConfig { retry?: boolean }; if (config.retry error.response?.status 502) { config.retry false; return httpClient.request(config); } return Promise.reject(error); } );这里一定要记住重试只适用于幂等请求比如普通查询。像登录、支付、下单这类非幂等操作重试会导致重复提交风险很大。所以我会为这种重试能力设计一个显式参数默认不开启只有业务侧确认安全后才打开。并发请求方面在高频请求场景下如果同一个页面有多个接口同时发出一旦 Token 过期后发起刷新会有一堆排队中的请求拿的是“旧 Token”。比较通用的方案是用一个队列缓存住这些请求等新 Token 到位后再逐个重新发起。这事放在响应拦截器里做最合适因为它能拿到所有失败的请求配置。let isRefreshing false; let pendingQueue: Array(token: string) void []; async function handleTokenExpired(error: ApiException) { // 判断是否 401 const originalConfig error.config; if (!isRefreshing) { isRefreshing true; try { const newToken await refreshToken(); pendingQueue.forEach(cb cb(newToken)); pendingQueue []; return httpClient.request(originalConfig); } finally { isRefreshing false; } } else { return new Promise((resolve) { pendingQueue.push((token: string) { originalConfig.headers[Authorization] Bearer ${token}; resolve(httpClient.request(originalConfig)); }); }); } }这种写法我在鸿蒙项目里实测过确实能解决并发 401 的“雪崩式”问题。但要注意refreshToken接口本身不能被同一个拦截器拦截否则会形成死循环。建议通过独立的refreshHttpClient实例发出或者在拦截器里加一个特殊标记_skipAuthRefresh。4. 泛型接口处理让 TypeScript 成为你的接口“合同”4.1 通用响应泛型定义鸿蒙的 ArkTS 语言在严格模式下对类型的要求非常高这既是挑战也是机遇。绕开类型定义直接写 any短期快长期迟早要为类型错误买单。所以在网络层设计中我一上来就定义了一套泛型基础类型。最基础的响应结构是一个泛型容器export interface ApiResultT { code: number; message: string; data: T; } export interface PageResultT { list: T[]; total: number; page: number; pageSize: number; hasMore: boolean; }接着基于ApiResultT我们可以定义一套 API 函数签名例如export type ApiFunctionTReq, TRes (params: TReq) PromiseTRes;这套类型的意义在于当你编写业务接口时不再是零散地传 URL 和参数而是把请求参数、响应数据、错误处理都绑定到了一个函数签名中。在团队协作中后端只要给了接口文档前端就可以先按文档把接口 type 定义好页面开发时完全不关心网络细节。4.2 泛型请求函数封装没有泛型封装之前常见写法是const res await httpClient.get(/user/info, { params }); return res.data.data;这样写三次五次没问题但涉及几十个接口时你会发现自己一直在复制同样的“拆包 断言”代码。用泛型函数封装后流程变成了export async function requestT( config: AxiosRequestConfig ): PromiseT { const response await httpClient.requestApiResultT(config); return response.data.data; }这样调用侧就变得非常干净interface UserInfo { id: string; name: string; avatar: string; } export function getUserInfo(userId: string): PromiseUserInfo { return requestUserInfo({ url: /user/${userId}, method: GET, }); }响应里到底是不是UserInfo类型运行时其实不保证但类型系统给了我们一个“契约”。只要后端接口文档规范这个契约就能在编译期拦截大量低级错误。4.3 高阶泛型复杂业务场景中的泛型实践有些接口的数据结构比较绕不是简单的data字段。比如上传接口的返回往往是上传后的文件 URL 和缩略图 URL 的集合列表接口会多包一层分页信息。针对这些场景我们可以定义更丰富的泛型手段。例如分页列表export interface PageParams { page: number; pageSize: number; } export async function fetchPageListT( url: string, params: PageParams ): PromisePageResultT { const response await httpClient.requestApiResultPageResultT({ url, method: GET, params, }); return response.data.data; }业务层调用interface TodoItem { id: string; title: string; completed: boolean; } const todos await fetchPageListTodoItem(/todos, { page: 1, pageSize: 10 });再进阶一点我们可以让请求函数支持“返回原始响应”与“返回业务数据”两种模式。有些场景需要拿到响应头里的分页信息或Set-Cookie这时候直接用requestT反而麻烦。我通常会加一个配置选项export async function requestRawT( config: AxiosRequestConfig ): PromiseAxiosResponseApiResultT { return httpClient.requestApiResultT(config); }通过request和requestRaw两个函数并存既保证了 90% 场景的类型安全又为特殊场景留了逃生舱。注意泛型不会在运行时进行数据校验。如果你对接的后端数据质量不稳定建议在关键接口上加运行时校验比如zod或手写轻量校验函数。我在鸿蒙项目里就遇到过后端把number返回成string的情况类型系统完全没发现最后是一个线上数据错乱问题排查了很久。4.4 ArkTS 与 TypeScript 泛型的兼容性经验在 HarmonyOS 6 的 ArkTS 环境下泛型语法大部分保留但有些写法会触碰编译限制。比如泛型约束extends可以使用但泛型默认值在某些版本会报错keyof、in等映射类型支持度不一条件类型和infer可能在部分场景下受限。我的建议是网络层保持简单泛型复杂类型体操放业务层。不要为了炫技写高难度的泛型推导ArkTS 的编译器和 DevEco Studio 的类型提示目前还没达到 VS Code 里 TypeScript 的完整度。优先使用明确的接口 基础泛型参数代码的可维护性远胜过花哨的类型计算。5. 实操从登录请求到用户信息加载的完整链路5.1 登录请求的定义与调用理论讲了不少不如直接跑一遍完整链路。假设我们有一个登录功能输入用户名和密码后端返回 Token 和用户信息。首先定义接口和类型export interface LoginParams { username: string; password: string; } export interface LoginResult { token: string; userInfo: UserInfo; } export function sendLoginRequest(params: LoginParams): PromiseLoginResult { return requestLoginResult({ url: /auth/login, method: POST, data: params, // 登录接口通常不会自动打入重试队列 headers: { Authorization: None, }, }); }这里故意把Authorization头设为None是为了避开请求拦截器的自动注入。因为用户在登录前还没有 Token请求拦截器也不该往登录请求里加一个空 Token。调用侧async function handleLogin(username: string, password: string) { try { const loginResult await sendLoginRequest({ username, password }); await PreferencesUtil.setString(token, loginResult.token); await PreferencesUtil.setString(userInfo, JSON.stringify(loginResult.userInfo)); // 跳转首页 router.pushUrl({ url: pages/Index }); } catch (error) { // 统一错误提示 showLoginError(error); } }为了让登录错误提示更准确我建议在showLoginError中对错误类型做细分。比如网络超时提示“当前网络不稳定请稍后重试”密码错误提示“用户名或密码错误”。这些信息后端已经放在 message 字段里泛型请求只是把它包装成了 Error 的 message业务侧直接读取即可。5.2 初始化 Token 注入与静态资源请求的差异化处理登录成功后用户信息里面的头像经常是第三方 CDN 地址域名跟接口域名不一样。这时候拼接 URL 要注意不要再用baseURL拼接因为 CDN 是完整 URL。如果你的接口层对 baseURL 做了强约束建议在请求配置里用url属性传完整地址Axios 会识别为绝对路径不会跟 baseURL 拼接。在 HarmonyOS 6 中图片组件加载网络图片会自动发起请求不经过你的 Axios 实例。所以如果你有“图片请求也带 Token”的需求比如私有图片需要用 Image 组件的onComplete或自定义带 Header 的Image加载。这一点容易被忽略但它会影响业务边界。5.3 页面生命周期与请求状态管理在 ArkUI 的Component中我习惯把所有网络请求的状态收敛到一个状态类中Observed export class ListViewModel { loading: boolean false; error: string ; items: Item[] []; async loadData() { this.loading true; try { this.items await fetchPageListItem(/items, { page: 1, pageSize: 20 }); } catch (e) { this.error (e as Error).message; } finally { this.loading false; } } }这样页面上只需要通过State观察这个 ViewModelUI 和数据层完全解耦。网络层的封装只负责数据不关心谁在展示。需要注意的一点是在Observed类中修改loading等属性时用State绑定到组件才会正常刷新。如果类没有被标记为Observed那属性变化可能不会触发 UI 更新。6. 常见问题与排查技巧实录6.1 Token 过期与刷新接口死循环这是网络层最常见的坑。我在 3.3 节提到过防死循环标记这里再展开。假设响应拦截器里判断code 401然后去请求刷新接口。刷新接口本身返回 200但是如果刷新接口的响应数据也包含code字段而你的拦截器逻辑是“只要 code 不为 0 就 reject”那刷新接口可能也被错误地当作业务失败。我的解决方式为刷新请求单独创建一个不带响应拦截器的实例。const refreshClient axios.create({ baseURL: HttpConfig.baseURL, timeout: HttpConfig.timeout, }); export async function refreshTokenRequest(refreshToken: string): Promisestring { const response await refreshClient.post(/auth/refresh, { refreshToken }); return response.data.data.token; }这个刷新实例保留请求拦截器可能还需要带旧 Token但不接响应拦截器避免被统一逻辑干扰。6.2 拦截器中的 this 指向与作用域问题有些同事喜欢在拦截器回调中调用工具类方法时省略this然后发现编译不通过或者运行时是 undefined。拦截器的回调是独立函数作用域是全局执行上下文不是你的工具类实例。推荐用箭头函数或者在拦截器外定义具名函数对象保证this正确同步。我踩过的一次坑是在请求拦截器中同步调用了this.refreshToken()结果每次请求都触发refreshToken is not a function。后面统一改成模块级函数导入问题自然消失。这个小细节虽然写出来简单但在项目里出现了不止一次值得提醒。6.3 泛型转换导致的数据丢失const data response.data.data as PageResultUserInfo;这种方式在运行时不做任何转换只是编译器层面的断言。如果后端返回的数据里hasMore不是布尔值而是字符串true前端判断if (data.hasMore)时字符串true会被当作真值处理看起来没错但如果返回的是0判断就会出错。在泛型接口处理中我建议对关键布尔字段和后端容易变形的字段做归一化处理。可以在拦截器里写一个轻量化的normalizePageResult函数专门处理hasMore、total等字段。泛型负责编译期安全运行时归一化负责真实数据可靠性两者配合才有最佳效果。6.4 超时时间设置与溯源超时问题的排查在鸿蒙设备上比 Android 更复杂一点因为不同设备的系统版本和网络库实现可能不同。如果你把超时时间设成 10 秒但依然经常出现 10.5 秒左右的超时那就说明超时的背后可能不是网络层问题而是 DNS 解析慢或者并发连接数受限。我的排查套路是三步走在拦截器里记录 startTime在响应和错误回调里算总耗时对比后端日志里的处理耗时看耗时主要发生在哪一段如果是排队耗时查看是否有大量请求同时发出需要做并发限制。并发限制可以简单地用信号量实现也可以用p-limit类似的移植思路。鸿蒙的异步模型支持Promise实现一个轻量并发队列并不难。6.5 表格错误类型速查与处置建议错误场景特征推荐处理方式无网络error.code 为ENETUNREACH或类似网络不可达提示检查网络不重试请求超时error.code 为ETIMEDOUT提示“网络开小差”可引导重试401 Token 过期response.status 为 401进入 Token 刷新队列403 无权限response.status 为 403提示无权限必要时引导重新登录业务错误码非 0response.data.code 非 0展示后端 message视情况埋点主动取消error.code 为ERR_CANCELED或ERR_ABORTED静默吞掉不上报这张表建议直接贴在项目 Wiki 或者代码仓库的 README 里团队新成员上手网络层时先照着这个表格理解异常处理流程能省不少沟通成本。6.6 调试利器拦截器里的日志开关与请求追踪排查网络问题离不开日志。我的做法是在请求拦截器中给每个请求生成一个traceId放在请求头和日志中。这样在任何地方看到报错都能追溯到完整请求链。httpClient.interceptors.request.use((config) { const traceId generateUUID(); config.headers[X-Trace-Id] traceId; console.info([HTTP] [${traceId}] Request: ${config.method?.toUpperCase()} ${config.url}); return config; });响应拦截器里记录状态码和耗时。通过 DevEco Studio 的 Log 面板过滤[HTTP]标签就能拿到完整的请求日志序列。生产环境里这个日志开关要设置成可配置避免大量日志拖慢性能和刷爆存储。我也利用 HarmonyOS 的性能打点工具对网络层做了几次耗时分析发现响应拦截器中的 JSON 序列化操作其实比例不低。如果你的后端返回大 JSON建议在拦截器里不要无脑对整个 body 做 JSON.stringify 日志输出只打印部分关键字段就好。7. 我在实际项目中的几点体会网络层这东西写起来永远不像业务功能那样能“看得见摸得着”但恰恰是这种基础设施决定了项目的长期维护成本。我在多个鸿蒙项目里迭代过这套封装最大的体会有两条。第一条是“规约先行”。在动手敲 Axios 实例之前先跟后端把响应结构、错误码、鉴权方式对齐前端才能设计出稳妥的拦截器和泛型接口。如果后端接口风格不统一前端所有优雅的设计都会变成补丁套补丁。项目里如果有条件建议推动后端统一 code 语义至少保证“成功”与“失败”的判断标准一致。第二条是“适度抽象”。封装网络层要掌握好度。过度封装会让所有请求走向都变得不透明调试问题时还得先逆向一层层封装逻辑封装不足又会到处是重复代码。我目前比较满意的状态是实例统一、拦截器统一、泛型统一但每个业务模块的接口函数还是显式定义不搞所谓的“接口自动注册中心”。这样可读性最好。最后分享一个近期在做的小优化。HarmonyOS 6 系统的应用恢复能力强App 从后台回前台时如果页面里的数据还停留在旧的网络状态可以先通过内存缓存直接渲染。网络层加一层可选的缓存策略配合拦截器能在弱网环境下明显提升体验。建议读到这里的你不在最初版本就引入缓存——先把封装、拦截器、泛型这套骨架跑通再加缓存、加重试、加离线包这些进阶能力一步步来。