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.getT每个页面都要自己处理res.data.code ! 0这种业务校验等于把规范留在口头约定层面时间一长必然有人不遵守。这时候做一层统一封装就不只是优化代码结构而是把团队的协作边界用类型和工具函数固定下来。2. 类型安全封装的核心设计泛型、统一响应、分层2.1 泛型到底解决了什么问题类型安全这个词听起来玄落到 ArkTS 里其实就是一件事让编译器替你把后端返回什么结构这件事管起来。你定义一个fetchUserInfo(userId: number): PromiseUserInfo调用方拿到的就是一个UserInfo类型点.的时候 IDE 直接补全字段写错字段名编译期就报错而不是等到接口返回再去 console.log 猜结构。用生活里的话说这就像把去仓库提货从凭感觉拿变成了提货单上写明型号和数量货不对板当场就能发现。泛型就是这个提货单的格式模板。封装层的核心方法签名是requestT(options: RequestOptions): PromiseTT 就是你要提的货的型号具体每个接口传什么 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 统一的请求与响应类型定义封装层的类型定义是整个工具的地基。我通常会定义ApiResponseT作为后端返回壳再定义RequestOptions作为请求参数的统一入口还可以顺带定义分页类型因为分页接口太常见了。// 后端统一返回结构 export interface ApiResponseT unknown { code: number; message: string; data: T; } // 分页数据结构 export interface PageResultT { 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 requestT(options: RequestOptions): PromiseT { const response await service.requestApiResponseT({ 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 getT(url: string, params?: object, options?: PartialRequestOptions): PromiseT { return requestT({ ...options, url, method: GET, params }); } export function postT(url: string, data?: object | string | FormData, options?: PartialRequestOptions): PromiseT { return requestT({ ...options, url, method: POST, data }); } export function putT(url: string, data?: object | string | FormData, options?: PartialRequestOptions): PromiseT { return requestT({ ...options, url, method: PUT, data }); } export function delT(url: string, params?: object, options?: PartialRequestOptions): PromiseT { return requestT({ ...options, url, method: DELETE, params }); }用PartialRequestOptions做第三个参数是为了让调用方可以在不传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: AxiosResponseApiResponse) { 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表示请求体是keyvaluekey2value2这种键值对串后端用表单解析器处理。很多问题的根源就在这同样的 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; boundaryxxxx。这里最大的坑来了很多人习惯在封装的通用方法里手动传headers: { Content-Type: multipart/form-data }结果发现后端一直报错。原因就是手动设置的头缺少boundary服务端根本不知道请求体在哪里分段。绕过这个问题的唯一正确姿势是传FormData时完全不要设置 Content-Type让 Axios 自动生成。export function uploadAvatar(fileUri: string): PromiseUploadResult { const formData new FormData(); formData.append(file, { uri: fileUri, name: avatar.png, type: image/png, }); formData.append(scene, avatar); return postUploadResult(/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 变成了表单格式参数解析不出来。我在本地一抓请求报文发现数据确实变成了keyvaluekey2value2这种形式。为什么新版本 Axios 的transformRequest默认行为变了当它发现传入的数据是普通对象且没有显式设置 Content-Type 时会自己决定序列化方式同时新版代码对data为字符串的场景也可能在拦截器阶段重新推断格式把原来的 JSON 字符串又包了一层表单处理。这事儿的教训很直接不要把 Content-Type 的决定权交给 Axios 的自动模式尤其是升级依赖之后默认行为可能悄悄变化。正确的做法是显式控制// 发送 JSON显式设置头和字符串化 export function postJsonT(url: string, data: object): PromiseT { return requestT({ url, method: POST, data: JSON.stringify(data), headers: { Content-Type: application/json }, }); } // 发送表单显式设置表单头 export function postFormT(url: string, params: Recordstring, string): PromiseT { const formData new URLSearchParams(); Object.keys(params).forEach((key) { formData.append(key, params[key]); }); return requestT({ 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泛型类型与后端实际结构不一致检查ApiResponseT嵌套层级接口层补字段拦截器里改 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、最后才怀疑框架往往能少走很多弯路。
返回列表