拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

鸿蒙网络层封装实战:Axios泛型类型安全与Content-Type避坑

鸿蒙网络层封装实战:Axios泛型类型安全与Content-Type避坑

做 HarmonyOS 应用开发这一年多,我最大的体感是:网络层如果不好好收拾,后面全是债。刚开始我用的是系统自带的@ohos.net.http,接口少时还能忍,等到页面多、业务接口超过二十个之后,重复的httpRequest.create()、手写回调、手动解析 JSON、一个接口一个 try-catch,代码膨胀得让人头皮发麻。后来我把网络层统一换成了 Axios 的 OpenHarmony 适配版,并且在外层封装了一个带泛型的类型安全请求工具,才真正体会到什么叫"接口定义一处、全项目受益"。这篇就围绕这套封装,把设计思路、完整代码和实际踩过的坑一次讲清楚,特别是 Content-Type 相关的几个大坑——比如升级 Axios 版本后,同一段代码发送的报文从 JSON 悄悄变成了表单格式,后端直接解析失败,这种问题排查起来能让你怀疑人生。

1. 为什么我坚持在 HarmonyOS 项目里封装自己的请求工具

1.1 原生 @ohos.net.http 的三类痛点

先说原生请求。@ohos.net.http本身能力并不弱,支持 GET、POST、上传下载、证书配置,但它是典型的事件回调风格。每写一个接口,你都得创建HttpRequest对象、设置 method 和 header、注册on('headersReceive')、on('dataReceive')一段段拼接返回数据、在on('dataEnd')里统一解析,再手动处理异常。这种模板代码一次两次还能接受,三四十个接口写下来,你会发现大量逻辑都是复制粘贴,改一个公共超时时间要全局搜索替换,非常痛苦。

第二个痛点是错误处理不统一。原生请求的失败分支分散在各处,有的人在页面里catch之后弹一个@ohos.promptAction.showToast,有的人直接吞掉异常,有的人连超时都没处理,导致线上出现请求卡死时你根本不知道是哪一层出了问题。说到底,没有一个统一的拦截点,你就没有办法做全局的 token 注入、错误码收敛和日志上报。

第三个痛点是类型是散的。ArkTS 本身是静态类型语言,但很多人写网络请求时仍然用response.result as any这种写法,后端返回结构一变,全项目编译期毫无感知,运行期才炸。字段名写错、类型写错、嵌套结构搞混,这些问题在纯 JS 时代靠经验兜底,在 HarmonyOS 这种强调工程化的环境里,完全可以通过类型系统提前消灭。

1.2 Axios 适配版解决了什么,又留下了什么

Axios 的 OpenHarmony 适配版把 Web 生态里成熟的 Promise 风格、拦截器机制带到了鸿蒙上。它能解决上面大半的问题:统一的拦截器可以收敛鉴权和错误处理,Promise 风格不再需要手写回调,实例配置让 baseURL 和超时只维护一份。但这些好处都有一个前提——你得围绕它再包一层。

直接裸用 Axios 也有新问题。首先,ArkTS 对类型的要求比 TypeScript 更严格,很多在浏览器里能跑的写法在鸿蒙上会被编译器拦住;其次,如果你在业务代码里到处axios.get<T>,每个页面都要自己处理res.data.code !== 0这种业务校验,等于把规范留在口头约定层面,时间一长必然有人不遵守。这时候做一层统一封装就不只是优化代码结构,而是把团队的协作边界用类型和工具函数固定下来。

2. 类型安全封装的核心设计:泛型、统一响应、分层

2.1 泛型到底解决了什么问题

类型安全这个词听起来玄,落到 ArkTS 里其实就是一件事:让编译器替你把"后端返回什么结构"这件事管起来。你定义一个fetchUserInfo(userId: number): Promise<UserInfo>,调用方拿到的就是一个UserInfo类型,点.的时候 IDE 直接补全字段,写错字段名编译期就报错,而不是等到接口返回再去 console.log 猜结构。

用生活里的话说,这就像把"去仓库提货"从凭感觉拿,变成了"提货单上写明型号和数量",货不对板当场就能发现。泛型就是这个提货单的格式模板。封装层的核心方法签名是request<T>(options: RequestOptions): Promise<T>,T 就是你要提的货的型号,具体每个接口传什么 T,由业务层的接口函数决定。

2.2 统一响应结构,让业务代码回归清爽

绝大多数后端接口都会包一层统一的返回壳,常见的是{ code, message, data }。如果没有封装,每个页面写const res = await getXxx(); if (res.code === 0) { use(res.data); },这种重复判断写多了,总会漏掉一两个分支。我的做法是在拦截器里把壳拆掉:请求成功且业务码正确时,直接把data返回给业务层;业务码错误时,统一弹出错误提示并 reject;网络异常时,统一转成用户能看懂的话。

这么设计之后,页面里的代码变成const user = await fetchUserInfo(123);,干净得像在调用本地函数。业务层只关心数据和异常,不关心协议壳长什么样,这是封装请求工具最实在的收益。

2.3 三层结构:请求核心、业务接口、页面调用

我习惯把网络层拆成三个层次,每个层次职责单一,改一层不影响另外两层。

第一层是request.ts,负责创建 Axios 实例、注册拦截器、暴露get、post、put、delete这几个带泛型的方法。这一层全项目只有这一个文件需要碰网络配置。第二层是api目录,按模块拆文件,比如user.ts、order.ts,文件里每个函数就是一个接口定义,负责声明 URL、参数和返回类型。第三层是页面,直接调用api层函数,完全不感知 Axios 和 HTTP 细节。

这样分层之后,后端接口路径变了,只需要改api层;公共 header 变了,只需要改request.ts;页面里永远做最薄的那层调用。团队合作时,新人看api目录就能快速摸清项目所有接口的出入参,比翻文档好用得多。

3. 完整实现:一套可以直接抄的请求工具代码

3.1 创建 Axios 实例与基础配置

封装的第一步是创建实例。这里有个细节:baseURL 我建议单独抽成常量,不要散落在代码里,同时timeout给一个业务默认值,个别慢接口后面可以在请求级覆盖。

// src/common/http/request.ts import axios, { AxiosInstance, AxiosResponse } from '@ohos/axios'; const BASE_URL = 'https://api.example.com'; const DEFAULT_TIMEOUT = 15000; const service: AxiosInstance = axios.create({ baseURL: BASE_URL, timeout: DEFAULT_TIMEOUT, headers: { 'Content-Type': 'application/json', }, });

这里默认 Content-Type 设为application/json是比较通用的选择,因为大部分业务接口都是 JSON 格式。但注意,这只是默认值,后面处理表单和 multipart 时它反而是个坑,我会在第四节重点说。

3.2 统一的请求与响应类型定义

封装层的类型定义是整个工具的地基。我通常会定义ApiResponse<T>作为后端返回壳,再定义RequestOptions作为请求参数的统一入口,还可以顺带定义分页类型,因为分页接口太常见了。

// 后端统一返回结构 export interface ApiResponse<T = unknown> { code: number; message: string; data: T; } // 分页数据结构 export interface PageResult<T> { list: T[]; total: number; page: number; pageSize: number; } // 统一请求参数 export interface RequestOptions { url: string; method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; data?: object | string | FormData; params?: object; // URL 上的查询参数 headers?: object; timeout?: number; }

T = unknown这个默认值很关键。ArkTS 在没有确切类型信息时,使用unknown比any安全得多,它强制调用方先做类型判断或断言再使用,避免把隐患带到运行期。而分页类型把list、total、page这些字段固化成模板,所有列表接口直接复用,非常省事。

3.3 泛型请求方法封装

接下来是核心的request函数,以及对外暴露的快捷方法。这个封装的精髓在于:拦截器处理完业务壳之后,直接返回T,调用方拿到的就是干净的业务数据。

export async function request<T>(options: RequestOptions): Promise<T> { const response = await service.request<ApiResponse<T>>({ url: options.url, method: options.method ?? 'GET', data: options.data, params: options.params, headers: options.headers, timeout: options.timeout, }); return response.data.data; } export function get<T>(url: string, params?: object, options?: Partial<RequestOptions>): Promise<T> { return request<T>({ ...options, url, method: 'GET', params }); } export function post<T>(url: string, data?: object | string | FormData, options?: Partial<RequestOptions>): Promise<T> { return request<T>({ ...options, url, method: 'POST', data }); } export function put<T>(url: string, data?: object | string | FormData, options?: Partial<RequestOptions>): Promise<T> { return request<T>({ ...options, url, method: 'PUT', data }); } export function del<T>(url: string, params?: object, options?: Partial<RequestOptions>): Promise<T> { return request<T>({ ...options, url, method: 'DELETE', params }); }

用Partial<RequestOptions>做第三个参数,是为了让调用方可以在不传url的情况下覆盖超时、headers 等配置,灵活性高又不会破坏类型检查。比如某个上传接口需要更长超时,可以写post(url, formData, { timeout: 60000 }),其他配置仍然走默认值。

3.4 拦截器:鉴权、业务码、异常提示

拦截器是 Axios 相比原生请求最大的优势。请求拦截器负责注入公共 header,比如登录 token;响应拦截器负责统一拆壳、业务码校验和异常归一化。我做了以下几件事。

// 请求拦截器:自动注入 token service.interceptors.request.use((config) => { const token = getTokenFromStorage(); if (token) { config.headers = { ...config.headers, 'Authorization': `Bearer ${token}`, }; } return config; }); // 响应拦截器:统一拆壳与错误处理 service.interceptors.response.use( (response: AxiosResponse<ApiResponse>) => { const res = response.data; if (res.code !== 0) { showToast(res.message); return Promise.reject(new Error(res.message)); } return response; }, (error) => { const message = normalizeHttpError(error); showToast(message); return Promise.reject(error); } );

这段代码里有三个容易被忽略的点。第一,token 的读取函数不要直接写在拦截器里,抽成独立函数,方便以后换存储方案。第二,业务码的判断标准要跟后端约定好,有的团队用0表示成功,有的用200,统一在这里定死,业务层永远不用关心。第三,normalizeHttpError这个函数会把超时、断网、HTTP 状态码错误映射成用户能看懂的话,比如"网络连接超时"、"服务器繁忙请稍后重试",而不是直接把英文异常抛给用户。

4. Content-Type 实战:JSON、表单和 multipart 的坑

4.1 JSON 和表单模式,核心区别在哪里

HTTP 请求体本质上就是一段字节流,服务端怎么解析它,全靠Content-Type这个头告诉它。application/json表示请求体是 JSON 文本,后端用 JSON 解析器处理;application/x-www-form-urlencoded表示请求体是key=value&key2=value2这种键值对串,后端用表单解析器处理。

很多问题的根源就在这:同样的 body 内容,用错误的 Content-Type 发送,后端就可能解析出空参数甚至是 400。我在项目里遇到过好几次,Android 端发 JSON 正常,等到鸿蒙端用同一套后端接口,后端一直收不到参数。排查到最后,就是鸿蒙端 Axios 默认把对象序列化成了表单格式,而后端只认 JSON。

如果在封装层已经默认设置了Content-Type: application/json,并且传入data是普通对象,大部分情况下 Axios 会帮你JSON.stringify后以 JSON 发送。但如果你传的是字符串,就得自己保证字符串是合法 JSON,同时头信息也得匹配,否则后端解析逻辑会对不上。

4.2 multipart/form-data 文件上传的正确姿势

文件上传用的是multipart/form-data,它跟前面两种最大区别是:请求体会被一条随机生成的boundary分隔成多个部分,每个 part 可以有自己的类型和文件名。这个boundary是谁生成的呢?是 Axios 在发现你传了FormData对象后自动生成的,同时自动把 Content-Type 改成multipart/form-data; boundary=xxxx。

这里最大的坑来了:很多人习惯在封装的通用方法里手动传headers: { 'Content-Type': 'multipart/form-data' },结果发现后端一直报错。原因就是手动设置的头缺少boundary,服务端根本不知道请求体在哪里分段。绕过这个问题的唯一正确姿势是:传FormData时,完全不要设置 Content-Type,让 Axios 自动生成。

export function uploadAvatar(fileUri: string): Promise<UploadResult> { const formData = new FormData(); formData.append('file', { uri: fileUri, name: 'avatar.png', type: 'image/png', }); formData.append('scene', 'avatar'); return post<UploadResult>('/user/avatar/upload', formData, { timeout: 60000, }); }

所以我的封装层里,凡是检测到data是FormData,就会把默认的Content-Type: application/json从 header 里删除。这一点如果不处理,即便你不显式传 header,默认的application/json也会跟着 FormData 一起发出去,同样会导致边界问题。

4.3 升级 Axios 版本后,报文从 JSON 变成表单的问题

这个坑我必须单独说,因为我真的被它坑了一整天。项目原本用的旧版@ohos/axios,部分接口是在请求方法里手动JSON.stringify后作为字符串传的,header 也手动设置成application/json,一直跑得好好的。后来依赖升级到新版本,突然有后端同事反馈:某个接口收到的报文从 JSON 变成了表单格式,参数解析不出来。

我在本地一抓请求报文,发现数据确实变成了key=value&key2=value2这种形式。为什么?新版本 Axios 的transformRequest默认行为变了:当它发现传入的数据是普通对象且没有显式设置 Content-Type 时,会自己决定序列化方式;同时新版代码对data为字符串的场景,也可能在拦截器阶段重新推断格式,把原来的 JSON 字符串又包了一层表单处理。

这事儿的教训很直接:不要把 Content-Type 的决定权交给 Axios 的"自动模式",尤其是升级依赖之后,默认行为可能悄悄变化。正确的做法是显式控制:

// 发送 JSON:显式设置头和字符串化 export function postJson<T>(url: string, data: object): Promise<T> { return request<T>({ url, method: 'POST', data: JSON.stringify(data), headers: { 'Content-Type': 'application/json' }, }); } // 发送表单:显式设置表单头 export function postForm<T>(url: string, params: Record<string, string>): Promise<T> { const formData = new URLSearchParams(); Object.keys(params).forEach((key) => { formData.append(key, params[key]); }); return request<T>({ url, method: 'POST', data: formData.toString(), headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }); }

把"发 JSON"和"发表单"分别封装成独立函数,比在调用侧临时改 header 要稳得多。这样无论 Axios 后续再怎么变默认行为,你的报文格式都是自己说了算。这里也提醒大家,升级网络库之后,千万别只看编译过没过,一定要拿真实接口抓包验证几个典型场景,尤其是上传和表单接口。

5. 高频问题排查与封装使用技巧

5.1 高频问题速查表

把我在鸿蒙项目里遇到的网络层高频问题整理成一张速查表,基本覆盖了日常开发 80% 的坑。

现象可能原因解决办法
后端收不到 body,参数全为空Content-Type 与服务端解析方式不匹配显式设置Content-Type: application/json或表单头
升级 Axios 后 JSON 变表单新版本transformRequest默认行为变化手动JSON.stringify,并用独立函数锁定格式
multipart 上传一直报错手动设置了缺少 boundary 的 Content-Type删掉手动 header,让 Axios 自动生成 multipart 头
文件上传中途超时默认 15 秒不够用上传请求单独配置timeout: 60000
页面拿到的数据是 undefined泛型类型与后端实际结构不一致检查ApiResponse<T>嵌套层级,接口层补字段
拦截器里改 header 不生效直接给config.headers赋值而不是合并用展开运算符合并旧 header
并发请求把 token 带错了登录态更新后未重新创建实例用service.interceptors.request.use动态读取 token

排查网络问题时,我的习惯动作是三步走:先看请求是否发出、再看报文格式对不对、最后看响应壳结构有没有变化。在 DevEco Studio 里直接查看请求日志,配合拦截器里加一行console.info打印实际 header 和 data,通常一分钟就能定位问题,别一上来就怀疑后端。

5.2 几个让工具更好用的细节

封装做完只是第一步,让它在真实项目里经得住用,还得补几个细节。

第一个是日志开关。我习惯在request.ts里加一个LOG_ENABLED标志位,拦截器里统一打印请求路径、参数、耗时和响应状态。开发环境全打出来,上线前把标志位关掉就行。这个习惯帮我省了大量的联调时间,因为你可以一眼看出请求是用 JSON 还是表单发出的,不用每次抓包。

第二个是业务层的类型要尽量具体。不要图省事在api/user.ts里写get('/user/list', params)而不声明返回类型,那类型安全就名存实亡了。每个 API 函数都必须给返回类型,这是封装这套工具的核心约束,也应该成为团队代码评审的一条硬指标。

第三个是不要把请求工具跟业务状态耦合。比如登录失效的处理,拦截器里检测到特定业务码后,不要直接在里面跳转页面,而是抛出一个特定错误,由调用方或全局监听者处理。把网络层和 UI 状态解耦,后面做多端适配时你会感谢这个决定。

最后一个建议是给上传和下载这类特殊场景单独开一行封装。上传用FormData,下载则需要拿到进度回调,它们跟普通 JSON 请求的差异很大,混在同一个方法里会让类型和配置都变得别扭。我最终把request.ts拆成了request.ts、upload.ts、download.ts三个文件,每个文件只管一件事,阅读和维护的体验好了不止一个档次。

回到封装这套工具的初衷,我最大的体会是:类型安全不是多写几行泛型的事,而是把规则固化在代码结构里,让团队里的每个人都只能按正确的方式写请求。刚开始封装时确实会多花一点时间,但等项目跑到一百个接口、五六个模块的时候,你几乎不需要再为网络层返工。你在自己的项目里动手封装时,如果遇到和 Content-Type 相关的怪异问题,记住先抓报文、再看 header、最后才怀疑框架,往往能少走很多弯路。

返回列表