ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

HarmonyOS Next 网络请求封装:基于 @ohos/axios 的类型安全实践

HarmonyOS Next 网络请求封装:基于 @ohos/axios 的类型安全实践 HarmonyOS Next 的 request 模块配合 ArkTS 写业务时我一度被请求代码散落各处 vs 类型安全这件事卡了很久logger 打半天、错误码到处写if (code 200)、改个接口字段就要全局替换。后来下定决心用 ohos/axios 做了一层完整封装把类型安全作为主线贯穿进去才真正感受到开发效率的回升。这篇博文把整个封装思路、类型设计、拦截器细节和踩过的 ArkTS 原生限制全部拆开讲适合已经入门 HarmonyOS 开发、不想再被网络层拖后腿的读者。1. 先搞明白HarmonyOS 里为什么绕不开自研请求封装1.1 系统 http 模块和 axios 的真实差距HarmonyOS 自带的ohos.net.http模块不是不能用而是太裸了。它只提供最基础的 request 能力拼 URL、设置 header、回调返回码。你在业务里要自己处理的事包括错误码判断、网络超时策略、token 注入、请求取消、日志聚合。这些逻辑如果每个页面都写一遍页面代码会迅速裹上一层厚厚的网络油脂。axios 在 Web 端已经被证明了它的生态价值拦截器、取消机制、实例化配置、请求/响应转换这些都是生产力工具。而ohos/axios正是把 axios 的 API 适配到 OpenHarmony 环境上的实现用法和 Web 端的 axios 几乎一致但底层走的不是 XHR而是系统网络栈。直接引ohos/axios当然比裸用 http 模块好但真正的问题在于axios 本身是框架无关的通用库你的业务规范它不负责。baseURL 从哪读、token 快过期怎么处理、后端返回的 code 什么时候算成功、404 在什么场景下不该弹错误框这些都需要你封装一层才能定下来。1.2 不封装的三个直接恶果第一个恶果响应体在业务层到处展开。后端给你的统一响应是{ code, message, data }页面里每个地方都写response.data.data一旦后端调整了包装层你要改的地方是全局性的。第二个恶果类型被动丢失。拿到的响应如果不经过类型约束ArkTS 引擎和编译器就会让你的response.data变成object想访问字段只能as强转。强转本身没错但强转散落在 50 个页面里就等于你把类型安全完全交给了人工自觉。第三个恶果错误处理无法统一。token 过期要跳登录页这个逻辑是全局的。如果请求层不统一处理你在每个 catch 里写if (error.code 40100)写半年之后总会漏掉一两个场景。1.3 类型安全不只是规范洁癖是 ArkTS 的硬要求很多从 TS 转过来的开发者会忽略一个关键点:ArkTS 是 TypeScript 的严格子集它直接禁用了 any 类型。你写const res: any await request()不好意思编译不过。这本来是个限制但反过来看它逼着你在请求层就把类型收敛好——因为如果你在源头没有泛型约束业务层就只能写复杂且脆弱的类型断言。所以这里的类型安全有两层含义第一层是靠泛型把后端返回的数据类型固定住让编辑器补全、编译期检查替你兜底第二层是满足 ArkTS 编译器本身的硬性约束不靠绕开关过日子。把这两层想清楚封装的骨架就已经在脑子里了。2. 环境与依赖SDK 版本、axios 引入和工程目录设计2.1 SDK API 12 / 5.0.0 的依赖配置我当前用的环境是 HarmonyOS Next SDK 5.0.0(12)对应 DevEco Studio 5.0 及以上版本。工程里的module.json5需要声明兼容版本范围核心字段是{ module: { name: entry, type: entry, deviceTypes: [phone, tablet], requestPermissions: [ { name: ohos.permission.INTERNET } ] } }注意这里最容易漏的是ohos.permission.INTERNET。不声明这个权限真机上所有请求都会以权限错误收场而模拟器有时表现得不明显排错时会很困惑。在oh-package.json5里配置依赖我锁定的是ohos/axios的 2.x 版本。如果项目刚初始化直接用命令行安装ohpm install ohos/axios安装完成后在代码里引入import axios from ohos/axios这个导入路径和 Web 端import axios from axios不一样不要写错。ohos/axios的 API 设计和原版 axios 保持对齐后面封装的时候可以沿用很多网络端的习惯。2.2 axios 的模块化约定有的团队喜欢一个HttpUtil.ts里梭哈所有请求方法这个做法在小项目没问题但一旦请求数量超过 30 个文件会变得非常难合并和维护。建议按照职责把网络层和服务层拆开src/ ├── common/ │ └── types/ // 全局类型定义、枚举、统一响应模型 ├── net/ │ ├── httpClient.ts // axios 实例创建、加载拦截器 │ ├── interceptors.ts // 请求/响应拦截器 │ ├── errors.ts // 错误类型定义与归一化 │ └── request.ts // 对外暴露的 get/post/put/delete 方法 ├── service/ │ ├── authService.ts // 登录、登出、刷新 token │ ├── orderService.ts // 订单相关接口 │ └── fileService.ts // 上传下载接口 └── pages/ // 页面层只依赖 service 层分层逻辑很简单pages永远不要直接 importaxios或者request.ts的底层方法页面应该面对authService.login()这样的业务方法。这样后端改路径、改参数格式时你只需要动service层页面代码纹丝不动。2.3 请求层的配置来源baseURL 不要硬编码在 axios 实例里建议放到一个独立的配置对象中集中管理。我一般是建一个AppConfig.ts里面按 build mode 区分export class AppConfig { static readonly baseUrl: string https://api.example.com static readonly timeout: number 10000 static readonly retryTimes: number 2 }这里用class static readonly而不是const对象字面量是因为 ArkTS 对对象字面量的隐式类型推断比较严全局使用 class 静态字段会更稳定也方便后续在应用启动时用配置中心动态刷新。3. 类型安全核心响应模型、泛型推导和错误码约束3.1 统一响应模型 ApiResponse几乎所有团队的后端都会包一层统一响应结构常见的形态是export interface ApiResponseT { code: number message: string data: T }ApiResponse是泛型接口data的类型由调用方决定。比如获取用户信息时T就是UserInfo获取订单列表时T就是PageResultOrderItem。有几个细节值得注意字段名和字段类型一定要和后端契约对齐这是整个链路的源头。后端返回msg你接口里写message类型安全就成了空谈。code建议用number不要用string。很多后端习惯返回SUCCESS这种字符串枚举但字符串枚举在 ArkTS 里做联合类型约束更麻烦easier 的做法是统一成数字枚举。data的类型不写死让别人传。如果你的接口里给data固定成object那么每个具体接口都要在调用点强转前面的功夫白费了。3.2 泛型请求方法让返回值自己做主封装的核心方法长这样// request.ts import axios from ohos/axios import type { ApiResponse } from ../common/types/Response export async function getT( url: string, params?: object ): PromiseT { const response await axios.getApiResponseT(url, { params }) const body response.data if (body.code ! BizCode.SUCCESS) { throw new BizError(body.code, body.message) } return body.data }关键在axios.getApiResponseT这一行。axios 的泛型参数T在这里被我们传成了ApiResponseT意思是HTTP 层面的响应体一定是{ code, message, data }而data的类型就是调用方指定的T。于是业务层写起来非常舒服const info await getUserInfo(/user/info, { id: 1001 }) info.name // 编辑器能自动补全类型不对编译直接报错这个模式下类型信息从 service 层一路流向页面中间不需要一次as强转。3.3 错误码和枚举把魔法数字赶出代码响应码直接拿数字比如40100写在业务代码里是很容易失控的。推荐建一个枚举统一管理export enum BizCode { SUCCESS 0, TOKEN_EXPIRED 40100, PERMISSION_DENIED 40300, NOT_FOUND 40400, SERVER_ERROR 50000 }还能更进一步把错误码和提示文案做成映射表错误处理的时候自动取文案减少业务里的分支判断const codeMessageMap new Mapnumber, string([ [BizCode.TOKEN_EXPIRED, 登录状态已过期请重新登录], [BizCode.PERMISSION_DENIED, 没有权限执行该操作], [BizCode.NOT_FOUND, 请求的资源不存在], ])这里Map的 key 用枚举值在 ArkTS 里完全合法编译期还能帮你检查枚举拼写错误。4. 完整封装实现实例、拦截器、取消和重试4.1 创建实例并固化超时、baseURL所有请求统一从一个 axios 实例发出不要在业务代码里到处axios.get。创建实例的时候就把默认配置固定下来// httpClient.ts import axios from ohos/axios import type { AxiosRequestConfig } from ohos/axios export function createHttpClient(config: AxiosRequestConfig) { return axios.create({ baseURL: config.baseURL, timeout: config.timeout, headers: { Content-Type: application/json } }) }我建议实例的创建和拦截器注册分开不要挤在一个文件里这样单测和 mock 时会灵活很多。实例创建完即可全局持有作为单例导出。4.2 请求拦截器token 注入业务上最刚需的拦截器逻辑是 token 注入。在 ArkTS 里读写本地偏好数据一般用ohos.data.preferences我把读取工具封装好之后在拦截器里做如下处理import { httpClient } from ./httpClient import { StorageUtil } from ../common/utils/StorageUtil httpClient.interceptors.request.use( (config: InternalAxiosRequestConfig) { const token StorageUtil.getToken() if (token) { config.headers.set(Authorization, Bearer ${token}) } return config }, (error: Error) { return Promise.reject(error) } )有一个非常容易被网络端习惯带偏的写法config.headers[Authorization] token。这在 Web 端 axios 里能跑但在 ArkTS 的封装类型下不行——config.headers是Headers类型不是普通对象应该用set方法。这个细节我放在后面的踩坑章节细说。拦截器里只做注入和规范化不要做业务判断。token 是否存在、是否过期是 service 层的事页面不该关心。4.3 响应拦截器数据解包和统一错误处理响应拦截器要做两件事第一是网络层错误归一化第二是处理全局性的业务错误比如 token 过期。httpClient.interceptors.response.use( (response: AxiosResponse) { return response }, (error: AxiosError) { if (error.code 40100) { // 跳转登录逻辑统一走这里 AuthRedirector.toLogin() } return Promise.reject(normalizeError(error)) } )关于解包你可能会问为什么响应拦截器里不直接return response.data.data省得后面再解答案是不要在这里解包。原因有两点响应拦截器不知道调用方期望什么类型它拿到的是AxiosResponse里面包含 status、headers、配置等 HTTP 语义。如果提前返回业务数据HTTP 层面的信息就丢了调试时你会少很多线索。解包动作应该距离调用方最近也就是放在request.ts的getT/postT方法里因为只有这里才知道请求的泛型T是什么。4.4 请求取消维护一个请求池在页面退出、组件销毁时取消未完成的请求是避免状态更新的有效手段。ohos/axios的取消机制和现代 axios 一致的方案是基于AbortController。我封装了一个简单的请求池class RequestPool { private static controllers new Mapstring, AbortController() static add(key: string, controller: AbortController) { if (this.controllers.has(key)) { this.controllers.get(key)?.abort() } this.controllers.set(key, controller) } static remove(key: string) { this.controllers.delete(key) } static cancel(key: string) { this.controllers.get(key)?.abort() this.controllers.delete(key) } static cancelAll() { this.controllers.forEach((controller) controller.abort()) this.controllers.clear() } }在使用时const controller new AbortController() RequestPool.add(order-list, controller) try { const list await getPageResultOrderItem(/orders, { page: 1 }) // 正常渲染 } finally { RequestPool.remove(order-list) }页面aboutToDisappear里调用RequestPool.cancel(order-list)就能安全取消。这里用Map的好处是 key 语义化哪里发起了请求、哪里取消一目了然。4.5 失败重试指数退避的轻量实现网络请求在弱网环境下偶发失败是常态我加了带指数退避的重试机制export async function withRetryT( requestFn: () PromiseT, retryTimes: number 2, baseDelay: number 500 ): PromiseT { let lastError: Error | undefined for (let i 0; i retryTimes; i) { try { return await requestFn() } catch (e) { lastError e if (i retryTimes isRetryable(e)) { await sleep(baseDelay * Math.pow(2, i)) } } } throw lastError }重点在于isRetryable这个判断。只有网络超时、连接重置这类问题才值得重试业务错误比如参数错误、权限不足不能重试否则会放大问题。需要在这边通过错误类型或者 HTTP 状态码做过滤。5. 业务层实战登录、分页列表、上传文件5.1 登录类型安全的表单提交与错误提示先定义请求体和返回结果// service/authService.ts export interface LoginBody { account: string password: string } export interface LoginResult { token: string userInfo: UserInfo } export function login(body: LoginBody): PromiseLoginResult { return postLoginResult(/auth/login, body) }页面调用时const result await login({ account: dev, password: 123456 }) StorageUtil.setToken(result.token) StorageUtil.setUserInfo(result.userInfo)你注意到没有——login的返回类型在接口定义处就已经确定了页面和 service 之间的数据流转不存在任何response as any的操作。这就是类型安全带来的直接收益改接口时编译期一键联查而不是运行时才炸。5.2 分页列表泛型带来的联动推断分页接口是一种把泛型用到极致的地方。定义一个通用分页结果类型export interface PageResultT { list: T[] total: number hasMore: boolean }订单服务export interface OrderItem { id: string orderNo: string amount: number status: OrderStatus } export function fetchOrders(page: number, pageSize: number): PromisePageResultOrderItem { return getPageResultOrderItem(/orders, { page, pageSize }) }页面里result.list会自动推导成OrderItem[]你拿item.orderNo、item.amount时编辑器从补全到类型检查全都围绕OrderItem在转。这比在页面里JSON.parse之后再猜字段舒服太多。5.3 上传文件进度回调与请求取消结合上传文件是请求层比较容易翻车的地方。ohos/axios的请求配置支持onUploadProgress回调我把它封装成带进度和取消能力的方法export function uploadFile( fileUri: string, onProgress: (percent: number) void ): PromiseUploadResult { const controller new AbortController() const key upload-${Date.now()} RequestPool.add(key, controller) const formData new FormData() formData.append(file, fileUri) return new Promise((resolve, reject) { axios.postApiResponseUploadResult(/file/upload, formData, { headers: { Content-Type: multipart/form-data }, onUploadProgress: (progress) { const percent Math.round((progress.loaded / progress.total) * 100) onProgress(percent) }, signal: controller.signal }).then((response) { const body response.data if (body.code BizCode.SUCCESS) { resolve(body.data) } else { reject(new BizError(body.code, body.message)) } }).catch((error) { reject(error) }).finally(() { RequestPool.remove(key) }) }) }页面离开时调用RequestPool.cancel(key)就可以中止上传进度回调在 abort 之后不会再触发避免 UI 更新撞上卸载的组件。6. 踩坑记录ArkTS 语法限制和封装细节6.1 拦截器里的对象字面量会被类型系统拦下Web 端写 axios 拦截器经常直接返回一个对象字面量去覆盖配置// Web 端常见ArkTS 里不一定能编译 return { ...config, headers: { ...config.headers, Authorization: token } }ArkTS 对对象字面量的类型检查非常严格展开运算符和动态添加字段的组合很容易被编译器拒绝。最稳的方式是就地修改 config 参数本身config.headers.set(Authorization, Bearer ${token})别嫌 API 啰嗦ArkTS 的编译期保护就是靠这种显式调用换来的。6.2 headers 的 set 操作和字段索引差异这是我在 ArkTS 封装 axios 时实际卡了最久的一个点。Web 端config.headers[Authorization] token这种写法在 ArkTS 里不是简单的不推荐而是数据类型根本不允许。headers在你拿到手的时候是Headers类的实例不是普通Record对象。想要可靠地操作 header请始终使用config.headers.set(Authorization, Bearer ${token})读取的时候配合config.headers.get(Authorization)。如果你确实需要自定义很多 header建议直接定义一个接口来描述export interface AppRequestHeaders { Authorization?: string X-Request-Id?: string X-Trace-Id?: string }然后用一个特定的类型收窄函数把Headers转成你的业务视图不要在业务代码里到处get。6.3 泛型默认值不能用 null也别用 any在封装的公共类型里我最开始把响应模型写成export interface ApiResponseT null { code: number message: string data: T }在 ArkTS 编译中T null会让一部分泛型推导变得很别扭尤其是在处理列表数据时。后来我改成不设默认值强制每个 API 方法显式传入泛型参数export interface ApiResponseT { code: number message: string data: T }这反而变成了一件好事——因为它强迫你在写请求方法的时候就必须想清楚要返回什么少了很多暂定 object后面再改的偷懒空间。同理T any在 ArkTS 里是直接被编译器禁止的就别指望了。6.4 Promise.all 等并发场景的类型收敛并发请求是类型断言的重灾区。举个具体场景页面同时加载用户信息和配置信息。const [user, config] await Promise.all([ getUserInfo(), getAppConfig() ]) // user 的类型被推断为 UserInfoconfig 的类型被推断为 AppConfig这个并集推倒在 ArkTS 现代版本里能正常工作。但如果你用Promise.allSettled返回的status是fulfilled或rejected的联合类型这时做类型收窄时要小心const result await Promise.allSettled([getUserInfo(), getAppConfig()]) if (result[0].status fulfilled) { // 这里 result[0].value 才能被安全访问 }如果你发现某些 SDK 版本对Promise.allSettled的类型推断不够好最简单的办法是回到Promise.all并自己 catch 单个请求避免把所有请求失败都压到同一个错误类型里。7. 再补一个实用细节响应拦截器里保留 HTTP 错误码很多封装教程会直接在响应拦截器把非 2xx 状态码转成业务错误对象连带着把response.status丢掉。我自己实测下来的建议是保留一份原始 HTTP 状态码在错误对象上因为排查问题的时候500、504、408这些码在问题定位上意义完全不一样。export class BizError extends Error { code: number message: string httpStatus: number constructor(code: number, message: string, httpStatus?: number) { super(message) this.code code this.message message this.httpStatus httpStatus ?? 0 } }错误归一化函数里把AxiosError.response?.status透传进来export function normalizeError(error: AxiosError): BizError { const status error.response ? error.response.status : 0 const serverCode error.response?.data?.code const message error.response?.data?.message ?? error.message return new BizError(serverCode, message, status) }这样你既可以靠业务code做流程判断也可以在日志里单独检索 HTTP 状态码。我见过太多线上问题因为状态码被吞掉排查时只能靠猜。最后分享一个体会封装请求层这件事真正难的不是写代码而是克制——不要把路由守卫、状态管理、缓存逻辑全部塞进请求层。请求层守住类型安全、错误归一、取消可控这三条底线业务层自然会清爽。如果你现在正在 HarmonyOS 项目里为散落的请求代码头疼按这个结构动手整理一遍很快就能看到变化。
返回列表