☰
ArkTS环境下的Axios封装:类型安全与拦截器实战
2026/10/2 10:04:31 网站建设 项目流程

HarmonyOS 应用开发跑到一定阶段,网络请求这块就成了绕不过去的坎。ArkTS 的语法约束比普通 TypeScript 严格得多,类型安全基本是硬性要求,Axios 作为最成熟的请求库自然也成了首选。但直接裸用 Axios 写业务代码,很快就会发现重复代码、错误处理、类型断言越来越多,等堆到一定量级再回头整理,成本高得让人想重写。我在几个正式项目里反复调整过这套请求工具,把类型安全、拦截器、上传下载、错误处理都揉到一起之后,业务侧写接口的体验才算真正顺了。这篇文章不聊虚的,直接说封装思路、完整代码、以及实测中踩过的 Content-Type 和版本升级的坑,适合已经在 HarmonyOS 项目里打转、想给团队成员一套统一请求规范的开发者参考。

1. 为什么非要在 ArkTS 里封装请求工具

1.1 裸用 Axios 写业务代码,痛在哪儿

先从最真实的场景说起。假设你直接在 Page 里写axios.get,每写一个接口都要重复指定 baseURL、token、超时时间、loading 开关、错误提示。短项目还能忍,一旦页面超过十个,你会发现同样的代码复制粘贴了十几次,后端的 code 变了,你要去十几个页面里改判断逻辑。这还只是表面问题。

在 ArkTS 环境里,这个问题会被放大。ArkTS 对类型的检查比 TS 更挑剔,它不允许你随便用 any 去绕开类型问题。裸用 Axios 时,response.data 的类型基本就是 unknown,你想拿到业务字段就得一层层自己断言。写少了编译不过,写多了全是 as。时间一长,接口列表和实际返回的数据结构一旦对不上,排查起来真的头疼。我在第一次把 Web 端的请求代码迁移到 HarmonyOS 项目时就吃过这个亏,后端接口返回值明明是{ code, message, data },我在页面里直接用res.data.list,结果跑起来才报 undefined,编译期一点提示都没有。

还有个被很多人忽视的点:取消请求。HarmonyOS 页面有明确的生命周期,页面销毁之后网络请求还在飞,回调里再去操作 UI 就会出现各种诡异问题。如果不做统一封装,每个页面都要记得自己去管取消逻辑,漏一次就是一次线上问题。我做开发这几年,因为忘记取消请求导致的内存泄漏和空指针报错,遇到的不止一次两次,这都是裸用请求库的真实代价。

1.2 类型安全不是做减法,是把错误提前到编译期

类型安全听起来很玄,拿装修来类比就好懂。你装修前先画好水电图,哪里能走管、哪里不能走,设计阶段就定死了,后面施工照着走就行。如果不画图,师傅都是边装边想,装完才发现水管和电线打架,返工成本极高。类型安全的请求工具就是那张水电图。

具体到代码层面,收益有三块。第一,接口返回的数据结构有自动提示。你定义好 UserInfo 之后,res.list[0].name敲下去的时候,编译器能告诉你这个字段存不存在。第二,参数约束不容易传错。get<T>的 params 是Record<string, string | number>,你传一个对象进去,字段名拼错了编译期就会报错。第三,业务错误码可以被收窄成联合类型,比如你定义type BizCode = 0 | 401 | 403 | 500,判断 code 的时候 IDE 会智能提示。

这些收益在普通 TS 里是加分项,在 ArkTS 里基本是刚需。因为 ArkTS 不允许你在业务代码里偷偷加 any 逃课,你必须有意识地把所有接口的数据结构都定义出来。既然定义都定义了,顺手做一套统一的泛型封装,反而是最省力的做法。很多同学一开始觉得封装麻烦,总想先上车后补票,等接口写了几十个再回到函数签名里去补泛型,那个痛苦程度足以让人怀疑人生。

2. 动手前先想清楚:接口规范和请求层架构

2.1 统一返回结构:先和后端把 code 口径聊明白

封装之前,第一件事不是写代码,是先把接口返回结构定下来。市面上绝大部分后端接口都会包一层统一结构,典型长这样:

{ "code": 0, "message": "success", "data": { ... } }

code 为 0 表示成功,非 0 表示业务异常,message 给用户看,data 才是真正要用的数据。我建议新项目直接按这个结构和后端对齐。如果你们后端已经有一套自己的结构,那也行,但一定要在公司内部统一,不能在 A 接口返回{ code, data },B 接口返回{ errCode, result },C 接口直接给你裸数据。没有统一结构,再好的类型封装也救不了你。

定了统一结构以后,前端这边的泛型设计就很清晰了。data 部分留给业务方自己去指定,其他部分由封装工具统一处理。分页接口再包装一层,data 里面的内容是 list、total、page 这些。把这两层定义好,整个封装的地基就稳了。

这里还要特别注意和团队对齐一个约定:业务失败不要用 HTTP 状态码表达,而是用业务 code。比如用户未登录返回 code 401,HTTP 状态码仍然可以是 200。这样做的好处是传输层错误和业务错误彻底分开,拦截器里可以统一判断业务 code 做 toast,HTTP 状态码只处理断网、超时、服务器崩溃这类传输级错误。我在实际项目里见过很多前后端联调吵架的场面,根因都是这两层没有分清楚。

2.2 四层划分:实例、拦截器、API 定义、调用方各管一段

我在项目里习惯把整个请求体系拆成四层,每一层只干一件事:

  1. Axios 实例层:负责创建 instance,配置 baseURL、timeout、公共 headers。
  2. 拦截器层:负责请求发出前的 token、loading,响应回包后的状态码判断、错误统一提示。
  3. API 定义层:每个业务模块一个文件,只存放接口函数和数据类型的定义。
  4. 调用方:页面或 ViewModel 里直接 await 接口函数,拿到的就是解包后的业务数据。

这样分层的理由很简单。后端接口调整时,大部分情况下只需要动 API 定义层;token 过期逻辑调整时,只需要动拦截器;谁也不会跑到页面里去翻网络代码。我在一个中大型项目里实测过,页面里基本看不到 axios 相关代码,所有的网络处理都收口在框架层,排查问题的时候一行定位。如果你让页面里到处散落着 axios 调用,那这个封装基本等于没做。

3. 核心实现:从零封装一个类型安全的 Axios 工具

3.1 公共类型定义与泛型约束设计

先建一个types.ts,把公共类型都放在这里。第一步定义后端返回的最外层结构:

// types.ts export interface ApiResponse<T = unknown> { code: number; message: string; data: T; } export interface PageResult<T> { list: T[]; total: number; page: number; pageSize: number; }

ApiResponse的泛型默认值给了unknown,这样那些还没有严格定义 data 类型的接口也能先跑起来,后面再逐步收窄。PageResult单独定义,分页接口直接复用。

然后是请求配置。我自定义了一个RequestConfig,它把 Axios 的配置和业务控制项合并在一起:

export interface RequestConfig<T = unknown> { url: string; method?: 'GET' | 'POST' | 'PUT' | 'DELETE'; params?: Record<string, string | number>; data?: unknown; headers?: Record<string, string>; timeout?: number; // 是否显示全局 loading,默认 false showLoading?: boolean; // 是否只返回 data 字段,默认 true unwrap?: boolean; }

我在早期版本里直接extends了 Axios 的AxiosRequestConfig,后来发现一个麻烦:自定义的 showLoading、unwrap 会随着 config 透传到 axios 实例里,axios 不认识这些字段,虽然不报错,但语义上很脏,还容易踩到类型声明冲突。所以我后来干脆自己声明了一套精简配置,在封装函数内部再手动映射成 axios 认识的配置。这个取舍在 ArkTS 里尤为重要,因为它的类型声明检查格外严格,跨库透传自定义字段很容易在编译期炸出一堆问题。

3.2 创建 Axios 实例与拦截器

接着是request.ts,核心是创建实例和拦截器。

// request.ts import { axios, AxiosInstance, AxiosResponse } from '@ohos/axios'; import { ApiResponse } from './types'; const instance: AxiosInstance = axios.create({ baseURL: 'https://api.yourapp.com', timeout: 15000, headers: { 'Content-Type': 'application/json', }, }); instance.interceptors.request.use((config) => { const token = getTokenFromStorage(); if (token) { config.headers = { ...config.headers, 'Authorization': `Bearer ${token}`, }; } return config; }); instance.interceptors.response.use( (response: AxiosResponse) => { const res = response.data as ApiResponse<unknown>; if (res.code !== 0) { showToast(res.message); return Promise.reject(new Error(res.message)); } return response; }, (error: Error) => { // 网络错误、超时、HTTP 错误统一处理 return Promise.reject(error); } );

三个细节需要强调。

第一,在 HarmonyOS 的@ohos/axios里,拦截器的类型和 Web 端 axios 不完全一样,错误对象的类型也不一样。我之前把 Web 端的代码直接搬过来,发现 catch 里拿到的 error 并不是AxiosError,而是一个带自定义字段的错误对象,几个字段的路径对不上。建议你在写统一错误处理前,先console.log打印一次真实错误对象,照着真实结构去处理。

第二,请求拦截器里修改 headers 的时候,一定要先展开再赋值。如果你直接config.headers['Authorization'] = xxx,在部分版本里会因为对象字面量类型推断导致编译不过,或者拦截器返回后 headers 没有生效。展开之后再赋值,实测最稳。

第三,响应拦截器里我先判断了业务 code。这里有个取舍:把业务错误放在拦截器统一弹 toast,页面里只需要处理 reject 的情况;如果你希望某些页面自己处理错误提示,可以在RequestConfig里加一个silent字段,拦截器发现silent=true就只 reject 不弹 toast。后续有需求再加,一开始别做太复杂。

3.3 统一 Request 方法:让返回值直接变成业务数据类型

接下来是核心的 request 方法。它做的事情很纯粹:接收业务配置,剔除自定义字段,调用 axios 发请求,返回值类型由泛型推导。

// request.ts import { RequestConfig } from './types'; export function request<T>(config: RequestConfig<T>): Promise<T> { const { showLoading = false, unwrap = true, ...axiosConfig } = config; if (showLoading) { showGlobalLoading(); } return instance.request(axiosConfig).then((response) => { // 返回整体结构还是只拿 data,由调用方按需决定 if (unwrap) { return (response.data as ApiResponse<T>).data; } return response.data as ApiResponse<T>; }); }

然后基于 request 封装 get、post 这些常用方法:

export function get<T>( url: string, params?: Record<string, string | number>, config?: Partial<RequestConfig<T>> ): Promise<T> { return request<T>({ url, params, method: 'GET', ...config }); } export function post<T>( url: string, data?: unknown, config?: Partial<RequestConfig<T>> ): Promise<T> { return request<T>({ url, data, method: 'POST', ...config }); }

这里的泛型 T 就是这个接口最终返回的业务数据类型。调用方传不传都行,不传就推断成unknown,传了就有全链路提示。注意...axiosConfig的解构方式,这么写就是为了把 showLoading 和 unwrap 这两个自定义字段从 axios 配置里剥掉,避免透传到实例上引起类型告警。

3.4 业务 API 定义与调用示例

到了 API 定义层,体验就完全不一样了。举个例子,用户模块:

// api/user.ts import { get, post } from '../request'; import { PageResult } from '../types'; export interface UserInfo { id: string; name: string; avatar: string; } export function fetchUserList(page: number, pageSize: number) { return get<PageResult<UserInfo>>('/api/user/list', { page, pageSize }); } export function updateUserInfo(data: Partial<UserInfo>) { return post<UserInfo>('/api/user/info', data); }

调用的时候:

const listData = await fetchUserList(1, 20); console.log(listData.list[0].name); // 有自动补全,写错字段编译期就报错

这套写法和原来差了多远呢?早前用 Axios 原生写法的时候,拿一个 res 要先做四五层断言才能访问到listData.list[0].name,中间任意一层字段拼错,运行到那行才炸。现在用这个封装,接口定义写一次,之后所有页面都吃这套类型,省下来的时间非常可观。而且不需要额外引入代码生成器或者复杂的工具链,就是一个纯手写的小工具,几十分钟就能落进项目里。

4. 上传下载与 Content-Type:最容易翻车的地方

4.1 multipart/form-data 的正确打开方式

上传文件时最容易出问题的是 Content-Type。很多同学手动设置headers['Content-Type'] = 'multipart/form-data',结果后端报错解析不到文件。原因很典型:multipart/form-data 需要带 boundary 分隔符,这个 boundary 是随请求体随机生成的。你手动写死 Content-Type 之后,如果 header 里的 boundary 和实际请求体的 boundary 对不上,后端就拿不到完整的分隔信息,自然解析失败。

我在项目里的做法是:先构建 FormData,然后尽量别手动指定 multipart 的 Content-Type,让 axios 根据 data 类型自动去设置。如果不放心,就抓一次包确认实际发送的 Content-Type 长什么样,再决定要不要固定。示例:

export function uploadAvatar(filePath: string) { const formData = new FormData(); formData.append('file', filePath as unknown as string); formData.append('scene', 'avatar'); return request<UploadResult>({ url: '/api/upload', method: 'POST', data: formData, timeout: 60000, }); }

注意里头的as unknown as string。ArkTS 的 FormData.append 对类型要求比较严格,文件路径和真实 File 对象在不同版本里表现不一样,写成 unknown 过渡一下再 as 过去,可以避免编译期一堆类型报错。上传接口超时时间一定要单独放大,默认 15 秒在弱网环境下大概率不够用。

如果你确实需要显式指定表单模式,比如后端要求application/x-www-form-urlencoded,那就用URLSearchParams或者手动拼 Query String,并设置好对应的 Content-Type。这里没有对象自动序列化,字段名拼错编译期也发现不了,所以我在项目里通常更推荐 JSON 模式,逼后端也多走 JSON 接口。

4.2 Axios 升级前后请求报文变化,排查步骤实录

还有一个很邪门的坑,是升级 Axios 版本之后遇到的。某次我把项目的 axios 依赖升了一个版本,回归测试发现之前好好的接口,后端突然就收不到参数了。抓包看到,升级前客户端发送的报文里 Content-Type 是application/json,请求体是 JSON 字符串;升级之后发送的报文里 Content-Type 变成了application/x-www-form-urlencoded,请求体变成了a=1&b=2这种 Query String 格式。后端是按 JSON 解析的,全部解析失败。

这个问题的根因是 axios 的 transformRequest 默认行为:当 data 是普通对象时,axios 会根据 Content-Type 选择合适的序列化方式。新版对 Content-Type 的推断逻辑更激进,如果你的拦截器里或者业务代码里没有显式设置application/json,它就可能走表单序列化。排查顺序我整理成了一套固定动作:

  1. 抓包对比实际发送的报文,看 Content-Type 和 body 结构到底长什么样。
  2. 检查所有拦截器里有没有对 headers 做过修改,一旦重新赋值 headers,可能导致默认 Content-Type 丢失。
  3. 检查有没有自定义 transformRequest,自定义之后 axios 不会再走默认 JSON 序列化逻辑,你得自己处理一切。
  4. 看传入 data 的实际类型,如果传的是字符串而不是对象,axios 不会自动序列化,也需要手动指定 Content-Type。

我把这个教训总结成了一句口诀:只要上报文异常,先看 Content-Type,再看序列化;先改 header,再查拦截器。实际上 Content-Type 相关的坑还有另一种:post 请求想用表单模式,但后端收到的却是 JSON,原因正好相反,axios 默认把对象序列化成 JSON 了。解决方式就是上面说的,先用 URLSearchParams 或手动拼 Query String,再设置表单 Content-Type。

4.3 超时、取消与错误码兜底策略

统一封装里我还会加三个不太起眼但关键时刻救命的能力。

第一个是超时。我的习惯是:普通接口 10 秒,上传下载 60 秒,具体接口可以在 RequestConfig 里单独覆盖。timeout 值不要全局一刀切,否则用户拿弱网测一次,体验直接崩掉。

第二个是取消请求。HarmonyOS 页面有 onPageHide、onPageUnload 这类生命周期,页面销毁时要把还没结束的请求 cancel 掉。Axios 支持 CancelToken 和 AbortController,在 ArkTS 里实测下来 AbortController 写法更直观,封装成 helper 之后,页面里只需要在初始化时创建、销毁时调用取消即可。不做这一步,页面来回跳转几次,回调堆积起来,轻则报错重则内存泄漏。

第三个是错误码兜底。响应拦截器里除了业务 code 判断,还要对 HTTP 状态码做一层兜底:401 跳登录并清 token,403 提示无权限,500 提示服务器繁忙。这层放在拦截器而不是页面里,是为了确保任何业务接口都覆盖得到,不至于有些接口报错之后毫无提示,用户以为自己断网了。

5. 实战中踩过的坑与排查清单

5.1 高频问题速查表

把我在多个项目里实际遇到的高频问题整理成一个速查表,遇到直接对号入座:

现象原因处理方式
返回值类型总是 unknown泛型没传或者接口层没定义类型在 API 定义层明确写get<T>/post<T>
上传文件后后端收不到文件Content-Type 被手动写死,boundary 丢失让 axios 根据 FormData 自动设置 header
升级 axios 后参数解析失败transformRequest 默认序列化逻辑变化抓包对比,显式设置 application/json
拦截器里修改的 headers 没生效直接给 headers 属性赋值被类型推断卡住先展开 config.headers 再赋值
页面销毁后请求回调仍执行未做取消逻辑用 AbortController,在 onPageUnload 里 cancel
自定义 showLoading 字段被传给 axiosRequestConfig 没有剥离业务字段在 request 函数里解构剔除再传
泛型嵌套过多编译报 TS2589类型递归过深减少多层泛型,必要时用 unknown 截断

5.2 几个我后来才想明白的设计细节

第一点是 unwrap 这个开关。我一开始把解包写死,所有接口返回 data 字段。后来发现有些接口确实需要拿到整个 ApiResponse,比如登录接口要同时更新 token 和用户信息,只返回 data 很别扭。后来加了 unwrap 开关,默认 true,需要完整结构的接口手动关掉就行。这个开关看起来多余,实际上避免了后期大规模返工。

第二点是拦截器里该管什么、不该管什么。早期我把 loading 的显隐也放在拦截器里,页面调用确实很爽,但问题是我没法知道某个接口是不是需要 loading,只能一刀切。后来改成 RequestConfig 里的 showLoading 标识,默认 false,需要 loading 的接口显式传 true,全局 loading 和局部 loading 都能控制。这比拦截器一刀切灵活得多。

第三点是类型定义要和后端字段严格对齐,但不要盲目信任后端的文档。后端字段改了,前端编译期不会报错,运行期才会炸。我的做法是在开发环境加了一层返回结构校验,用简单的运行时断言发现 code 非 0 或者关键字段缺失时,在日志里打醒目告警,尽早暴露问题。这种校验不用全接口覆盖,挑核心接口做就行,成本低,收益高。

我个人在实际项目中把这一套封装跑了两三个版本,最大的体会是:类型安全的请求工具真正的价值不是让你少写几行代码,而是让团队里每个人都遵循同一套接口规范和错误处理方式,新人上手也只需要看一个 api 目录就能了解全部网络请求。如果你是在现有项目里改造,别一次性把所有页面都迁过来,先选一两个模块试点,跑通之后再逐步铺开。最后一个小技巧:ApiResponse 里建议把成功的 code 值约定为 0 而不是 200,因为业务接口通常用 0 表示成功,HTTP 200 留给网络层,这样业务错误码和传输层状态码能彻底分开,排查问题时会省很多力气。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询