@ohos/axios 源码解读(二):适配层是一场翻译,而翻译要处理四类错位
@ohos/axios 是 Axios v1.3.4 在 OpenHarmony 上的适配版本,对外保持与 Web 端一致的 API,底层把请求交给鸿蒙的 @ohos.net.http 执行。它的 core/ 目录与 Web 版几乎一致,真正为鸿蒙重写的部分是 adapters/ohos/ 这个目录 ------axios 的 config 有几十个字段,鸿蒙的 HttpRequestOptions 又是另一套命名和取值,中间这层转换才是适配工作的主体。
这层转换的本质是翻译,而翻译不是逐字对照。两套 API 之间存在四类错位,每一类的处理方式都不同。
本文内容:按这四类错位逐一展开------哪些字段只是改了名、哪些需要语义转换、哪些能力鸿蒙侧根本不存在只能降级、哪些新特性要先查系统版本;最后讲翻译必须付出的代价,也就是原生资源的生命周期管理。读完可以确切知道:从 Web 迁移过来哪些写法会静默失效、用高级特性前为什么要先判断 API 版本、以及为什么参数校验必须写在创建原生对象之前。
📦 仓库地址 | 本文基于 v2.2.13 源码根目录:
library/src/main/ets/components/lib/,下文路径均相对于此。
四类错位与本文结构:
翻译的两端:先看两套 API 长什么样
翻译之前得知道两种语言各自的语法。左边是 axios 的 config(几十个字段,Web/Node/鸿蒙通用),右边是鸿蒙 @ohos.net.http 的 HttpRequestOptions。
适配层的全部工作集中在两个函数里,都在同一个文件:
📄 adapters/ohos/index.js
javascript
const setHttpConfig = (config, reject) => { ... } // 校验 + 创建原生 HttpRequest
const setOptions = (config, handle) => { ... } // config → options 的字段翻译
setHttpConfig 负责"要不要发、用什么对象发",setOptions 负责"怎么发"。文件末尾把两个函数连同 judgeMaxContentLength 一起导出,供 http.js / httpStream.js / upload.js / download.js 四条路径共用:
📄 adapters/ohos/index.js(文件末尾)
javascript
export { setHttpConfig, setOptions, judgeMaxContentLength }
这个共用关系很重要------四类错位的处理只写一遍,四个分支自动继承。下面按错位类型逐类看。
第一类错位:改名即可对应
最简单的一类:两边都有这个能力,只是名字不一样。
📄 adapters/ohos/index.js · setOptions
javascript
const setOptions = (config, handle) => {
let options = Object.assign(Object.create(null), {
method: own(config, 'method').toUpperCase(), // axios 存小写,鸿蒙要大写
header: own(config, 'headers'), // headers → header(少了 s)
readTimeout: 0,
maxLimit: -1
});
handle && handle(options);
const data = own(config, 'data');
if (!utils.isUndefined(data)) {
options.extraData = data; // data → extraData
}
const maxContentLength = own(config, 'maxContentLength');
if (utils.isNumber(maxContentLength)) {
options.maxLimit = maxContentLength; // maxContentLength → maxLimit
}
const proxy = own(config, 'proxy');
if (!utils.isUndefined(proxy)) {
options.usingProxy = proxy; // proxy → usingProxy
}
...
};
完整的改名清单:
| axios config | 鸿蒙 options | 说明 |
|---|---|---|
headers |
header |
少了个 s,最容易记错 |
data |
extraData |
鸿蒙侧叫"额外数据" |
maxContentLength |
maxLimit |
语义相同,名字不同 |
proxy |
usingProxy |
鸿蒙侧强调"启用代理" |
responseType |
expectDataType |
还要转枚举,见第二类 |
clientCert.keyPasswd |
clientCert.keyPassword |
嵌套字段改名,见下 |
证书那个嵌套字段改名用了对象解构:
📄 adapters/ohos/index.js · setCertificate
javascript
const clientCertConfig = own(config, 'clientCert');
if (!utils.isUndefined(clientCertConfig)) {
options.clientCert = clientCertConfig;
// keyPasswd 转换为 keyPassword
let { keyPasswd: keyPassword, ...rest } = options.clientCert;
let clientCert = { ...rest, keyPassword };
// certType 转换为大写
let certType = clientCert.certType;
clientCert.certType = certType ? certType.toUpperCase() : certType;
options.clientCert = clientCert;
}
axios 侧沿用 Web 习惯写 keyPasswd,鸿蒙 @ohos.net.http 侧叫 keyPassword。一行解构重命名解决,同时把 certType 统一转大写('p12' → 'P12')。国密加密证书 clientEncCert 走完全相同的处理。
这类适配的价值 :业务方按 axios 的习惯写 keyPasswd,不用去记鸿蒙那边叫 keyPassword。适配层吃的就是这个信息差。
还有个防御性细节------Object.create(null) 创建无原型对象,避免 options.constructor、options.toString 这类继承属性与用户数据撞名。
第二类错位:语义需要转换
名字对上还不够,取值形式也可能不同。
超时:两个字段映射到同一个目标
📄 adapters/ohos/index.js · setOptions
javascript
// 设置连接超时
const connectTimeout = own(config, 'connectTimeout');
if (utils.isNumber(connectTimeout)) {
options.connectTimeout = connectTimeout;
}
// 设置读取超时
const timeout = own(config, 'timeout');
if (utils.isNumber(timeout)) {
options.readTimeout = timeout;
}
// 设置读取超时
const readTimeout = own(config, 'readTimeout');
if (utils.isNumber(readTimeout)) {
options.readTimeout = readTimeout;
}
注意后两段:timeout 和 readTimeout 都写入 options.readTimeout,且 readTimeout 在后。
这不是冗余,是刻意设计的优先级:axios 的 timeout 语义上就是"读取超时"(连接超时另有 connectTimeout),所以映射到 readTimeout;而 axios 同时提供了更明确的 readTimeout 字段,后写覆盖前写 ,于是"更具体的配置优先"。两个都传时 readTimeout 生效,符合直觉。
responseType:字符串枚举转数字,且只有三种
📄 adapters/ohos/index.js
javascript
const _getResponseType = (responseType) => {
switch (responseType.toUpperCase()) {
case 'STRING': return 0;
case 'OBJECT': return 1;
case 'ARRAY_BUFFER': return 2;
default: return 1; // 兜底当 object
}
};
这就是文档里那句"鸿蒙环境下 ResponseType 已适配为 'string' / 'object' / 'array_buffer',与原库 Axios 的 'arraybuffer' / 'blob' / 'document' / 'json' / 'text' / 'stream' / 'formdata' 存在差异"的实现位置------鸿蒙原生只有这三种数据类型,其余无法映射。
default 分支兜底成 1(object),所以传 responseType: 'blob' 不会报错,只会静默按 object 处理。这是"不抛错但行为不符预期"的典型场景,从 Web 迁移时要特别注意。
请求体大小:五种数据形态的统一度量
maxBodyLength 校验要先知道字节数,但 axios 的 data 可能是任何东西:
📄 adapters/ohos/index.js · calculateLength
javascript
const calculateLength = (data) => {
let len = 0;
if (data) {
if (typeof data === 'string') {
len = buffer.byteLength(data, 'utf8'); // 字符串:按 UTF-8 算字节
} else if (buffer.isBuffer(data)) {
len = data.length; // Buffer:取 length
} else if (data instanceof ArrayBuffer) {
len = data.byteLength; // ArrayBuffer:取 byteLength
} else if (typeof data === 'number') {
len = data; // number:直接当长度
} else {
let tmp = data;
if (utils.isFormData(data)) {
function formDataToObject(formData) {
let object = {};
formData.forEach(function (value, key) { object[key] = value; });
return object;
}
tmp = formDataToObject(data); // FormData:先摊平成对象
}
len = buffer.byteLength(JSON.stringify(tmp), 'utf8');
}
}
return len;
}
三个要点:
字符串用 buffer.byteLength(data, 'utf8') 而非 data.length 。中文一个字在 UTF-8 下是 3 字节,'中文'.length === 2 但实际 6 字节。用 length 会让 maxBodyLength 形同虚设。
ArrayBuffer 取 byteLength,Buffer 取 length ------属性名不一样,混用得到 undefined。
FormData 先摊平再序列化,这是个近似值 。真实 multipart 编码还有 boundary 分隔符开销,实际字节数比算出来的大。所以 maxBodyLength 对 FormData 偏宽松。
第三类错位:鸿蒙侧不存在的能力
翻译最难受的情况------源语言有的概念,目标语言里根本没有。这类只能降级或留空。
statusText 恒为空字符串
📄 adapters/ohos/http.js
javascript
httpRequest.request(
buildURL(fullPath, own(config, 'params'), own(config, 'paramsSerializer')), options,
(err, data) => {
if (!err) {
let response = {
data: data && data.result,
status: data && data.responseCode,
statusText: '', // ← 恒为空
headers: data && data.header,
config: config,
request: httpRequest,
performanceTiming: data && data.performanceTiming
};
settle((value) => resolve(value), (err) => reject(err), response);
}
...
}
);
鸿蒙 @ohos.net.http 的回调只给 responseCode,不给 HTTP reason phrase。所以 response.statusText 永远是 '' ------从 Web 迁移过来的代码如果有 if (res.statusText === 'Not Found') 这种判断,会静默失效。改用 res.status === 404。
performanceTiming 只在非流式路径存在
上面那段代码里 performanceTiming: data && data.performanceTiming 看着是有的。但流式路径不一样:
📄 adapters/ohos/httpStream.js
javascript
// 注意:流式模式(requestInStream)回调仅返回 responseCode,
// 不包含 performanceTiming 字段(与 http.js 的 request() 不同)
let response = {
data: resultData,
status: data, // requestInStream 回调的 data 参数为 responseCode (number)
statusText: '',
headers: responseHeaders.header || {},
config: config,
request: httpRequest,
performanceTiming: undefined // ← 硬编码 undefined
};
两个原生 API 的回调签名根本不同:
request() |
requestInStream() |
|
|---|---|---|
回调 data 参数 |
{ result, responseCode, header, performanceTiming } 对象 |
仅 responseCode(number) |
| 响应头 | 在 data 里 | 需订阅 headersReceive 事件 |
| 响应体 | 在 data.result 里 |
需订阅 dataReceive 逐片收集 |
| 性能计时 | ✅ 有 | ❌ 不提供 |
所以文档里"设置进度回调后 performanceTiming 为 undefined,如需获取性能计时数据请勿设置进度回调"不是库的取舍,是底层 API 的能力边界。
CancelToken 不支持
📄 adapters/ohos/http.js
javascript
if (signal) {
signal.aborted ? abort() : signal.addEventListener('abort', abort);
}
取消能力只实现了 signal(AbortController)路径。dispatchRequest.js 里虽然同时检查了 config.cancelToken,但适配层没有任何 CancelToken 的处理逻辑------这就是文档标注"⚠️ 鸿蒙平台不支持 CancelToken,请使用 AbortController 代替"的原因。
maxContentLength 超限的错误消息靠字符串匹配
📄 adapters/ohos/http.js
javascript
} else {
// 发送请求:返回数据大于设置的maxContentLength时与原库保持一致
if(err.message === 'Failed writing received data to disk/application') {
onFinished();
return reject(new AxiosError(
'maxContentLength size of ' + own(config, 'maxContentLength') + ' exceeded',
AxiosError.ERR_BAD_RESPONSE,
config,
httpRequest
));
}
reject(new AxiosError(JSON.stringify(err), err.code, config, httpRequest, httpRequest));
}
响应体超过 maxLimit 时,鸿蒙底层返回的是这个 curl 风格的英文错误文案。适配层靠硬编码字符串匹配 识别它,再翻译成与 Web 版 axios 一致的 maxContentLength size of N exceeded,保证跨平台错误信息统一。
这是适配层最脆弱的一处:如果底层改了错误文案,这个特判就失效 ,用户会看到原始的英文错误而不是 axios 风格的提示。同类实现还有 httpStream.js 里一模一样的一段,注释写着"与原 http.js 保持一致"。
第四类错位:需要版本守卫的新能力
鸿蒙的新 API 是逐个版本开放的。用了当前系统不支持的特性会在运行时崩,所以适配层用 deviceInfo.sdkApiVersion 做前置拦截。
📄 adapters/ohos/index.js
javascript
// interceptorChain ------ API 22+
const interceptorChain = own(config, 'interceptorChain');
if (!utils.isUndefined(interceptorChain)) {
let apiVersion = deviceInfo.sdkApiVersion;
if (apiVersion < 22) {
throw new Error(`interceptorChain need Api >= 22,current api is ${apiVersion}`);
}
options.interceptorChain = interceptorChain;
}
📄 adapters/ohos/index.js · setMaxRedirects
javascript
const setMaxRedirects = (config, options) => {
const maxRedirects = own(config, 'maxRedirects');
if (utils.isNumber(maxRedirects)) {
let apiVersion = deviceInfo.sdkApiVersion;
if (apiVersion < 23) {
throw new Error(`maxRedirects need Api >= 23,current api is ${apiVersion}`);
}
options.maxRedirects = maxRedirects;
}
};
📄 adapters/ohos/index.js · setNewPropertiesOptions
javascript
// reuseConnections ------ API 26+
const reuseConnections = own(config, 'reuseConnections');
if (!utils.isUndefined(reuseConnections)) {
let apiVersion = deviceInfo.sdkApiVersion;
if (apiVersion < 26) {
throw new Error(`reuseConnections need Api >= 26,current api is ${apiVersion}`);
}
options.reuseConnections = reuseConnections;
}
📄 adapters/ohos/index.js · setCertificate
javascript
// sslType TLCP ------ API 20+
const sslType = own(config, 'sslType');
if (sslType) {
let apiVersion = deviceInfo.sdkApiVersion;
if (sslType === 'TLCP' && apiVersion < 20) {
throw new Error(`TLCP need Api >= 20,current api is ${apiVersion}`);
}
options.sslType = sslType;
}
📄 adapters/ohos/index.js · setEnableAutoCookie
javascript
const setEnableAutoCookie = (httpRequest, config, reject) => {
let apiVersion = deviceInfo.sdkApiVersion;
if (apiVersion < 26) {
return reject(new AxiosError(
`enableAutoCookie need Api >= 26,current api is ${apiVersion}`,
AxiosError.ERR_BAD_OPTION, config, httpRequest, httpRequest
));
}
const enableAutoCookie = own(config, 'enableAutoCookie');
if (typeof enableAutoCookie !== 'boolean') {
return reject(new AxiosError('enableAutoCookie must be a boolean value', AxiosError.ERR_BAD_OPTION_VALUE, ...));
}
try {
httpRequest.enableAutoCookie(enableAutoCookie);
} catch (err) {
return reject(new AxiosError(JSON.stringify(err), AxiosError.ERR_BAD_OPTION, ...));
}
};
汇总五处守卫:
| 特性 | 最低 API | 不满足时 | 位置 |
|---|---|---|---|
sslType: 'TLCP'(国密) |
20 | throw |
setCertificate |
interceptorChain(系统级拦截器) |
22 | throw |
setOptions |
maxRedirects |
23 | throw |
setMaxRedirects |
reuseConnections |
26 | throw |
setNewPropertiesOptions |
enableAutoCookie |
26 | reject |
setEnableAutoCookie |
注意失败方式不一致 :前四个 throw,最后一个 reject。
throw 会被 http.js 外层的 try-catch 捕获转成 reject:
📄 adapters/ohos/http.js
javascript
try {
if (options.interceptorChain) { ... }
} catch (err) {
reject(new AxiosError(JSON.stringify(err), err.code, config, httpRequest, httpRequest));
onFinished();
return;
}
但 setOptions 是在 try 之外调用的(let options = setOptions(config); 在函数开头),所以 maxRedirects / reuseConnections / TLCP 的 throw 会一路冒泡到 adapters/ohos/index.js 那个 new Promise 里------Promise 构造函数内抛异常会自动转成 reject,业务侧的 .catch() 能接到,但错误对象是普通 Error 而不是 AxiosError ,isAxiosError() 判断会返回 false。
实践建议:用这些高级特性前先自己判断版本,别依赖库的降级:
💡 业务侧用法示例(非库源码)
typescript
import deviceInfo from '@ohos.deviceInfo'
const config = {
url: '/api/data',
...(deviceInfo.sdkApiVersion >= 23 ? { maxRedirects: 5 } : {}), // 条件展开
}
enableAutoCookie 是唯一走 reject 的,所以它抛出的是标准 AxiosError,isAxiosError() 能正确识别。这个不一致本身值得库后续统一。
翻译的代价:原生资源的生命周期
前面四类错位处理的是"数据怎么翻译"。但翻译过程还会产生原生资源 ------_http.createHttp() 创建的 HttpRequest 是 C++ 层对象,GC 管不到,必须显式 destroy()。
校验必须前置于创建
📄 adapters/ohos/index.js · setHttpConfig
javascript
const setHttpConfig = (config, reject) => {
let fullPath = buildFullPath(own(config, 'baseURL'), own(config, 'url'), own(config, 'allowAbsoluteUrls'), config);
// url校验(在创建 httpRequest 之前,避免校验失败导致原生资源泄露)
if (!fullPath) {
return reject(new AxiosError("Cannot read properties of url!", AxiosError.ERR_BAD_OPTION, config, null, null));
} else if (typeof (fullPath) !== 'string') {
return reject(new AxiosError("Url type should be character type!", AxiosError.ERR_BAD_OPTION_VALUE, config, null, null));
}
// 判断设置的请求体的大小(在创建 httpRequest 之前,避免超限拒绝后原生资源泄露)
if (judgeMaxBodyLength(config, reject)) {
return reject(new AxiosError('Request body larger than maxBodyLength limit', AxiosError.ERR_BAD_REQUEST, config, null, null));
}
// 所有校验通过后才创建 httpRequest,确保错误路径不会产生孤儿原生资源
let httpRequest;
const enableHttpRequestReuse = own(config, 'enableHttpRequestReuse');
const httpRequestInstance = own(config, '__httpRequestInstance');
if (enableHttpRequestReuse && httpRequestInstance) {
httpRequest = httpRequestInstance;
} else {
httpRequest = _http.createHttp();
}
...
}
源码注释把理由写得很直白:"在创建 httpRequest 之前,避免校验失败导致原生资源泄露"。
顺序是刻意的:URL 校验 → 请求体大小校验 → 才 _http.createHttp()。如果先创建再校验,校验失败直接 reject 返回,那个原生对象就没人 destroy()------每次参数错误泄漏一份原生内存。这类 bug 只在压测或异常路径高频触发时暴露,排查极痛苦。把校验前置是最省事的解法。
注意前置校验的 reject 里 httpRequest 参数传的都是 null------此时确实还没有实例。
出口必须全覆盖
创建之后的资源释放靠 onFinished:
📄 adapters/ohos/http.js
javascript
let onFinished = () => {
if (signal) {
signal.removeEventListener('abort', abort);
}
// 复用模式下不销毁 HttpRequest,由实例统一管理生命周期
if (!enableHttpRequestReuse) {
httpRequest.destroy();
}
};
数一下 http.js 里 onFinished() 的调用点:interceptorChain apply 失败、apply 抛异常、abort 触发、请求回调成功、请求回调失败------五条路径全部覆盖。少一条就泄漏一个原生 HttpRequest。
流式路径的清理更复杂,因为订阅了四个事件:
📄 adapters/ohos/httpStream.js
javascript
const removeEvent = (httpRequest, config) => {
httpRequest.off('headersReceive');
httpRequest.off('dataReceive');
httpRequest.off('dataSendProgress');
httpRequest.off('dataReceiveProgress');
if (!config || !own(config, 'enableHttpRequestReuse')) {
httpRequest.destroy();
}
};
enableHttpRequestReuse 模式下不解绑事件会出严重问题:实例不销毁会被复用,上一次的监听器还在,下一次请求会同时触发两次的回调,进度数字直接错乱。
复用模式的资源归属转移
📄 core/Axios.js
javascript
class Axios {
constructor(instanceConfig) {
this.defaults = instanceConfig;
this.interceptors = { request: new InterceptorManager(), response: new InterceptorManager() };
this._httpRequest = null; // 懒加载
}
getHttpRequest() {
if (!this._httpRequest) {
this._httpRequest = _http.createHttp();
}
return this._httpRequest;
}
destroy() {
if (this._httpRequest) {
this._httpRequest.destroy();
this._httpRequest = null;
}
}
}
开启复用后,资源所有权从"单次请求"转移到"Axios 实例":适配层不再销毁,改由实例的 destroy() 统一释放。这个方法通过 axios.js 暴露到实例上:
📄 axios.js
javascript
// 新增:暴露 destroy 方法,用于释放复用的 HttpRequest 资源
instance.destroy = function() {
context.destroy();
};
这就是文档反复强调"开启 enableHttpRequestReuse 时需要主动调用 destroy()"的实现依据------不调就永久泄漏一个原生 HTTP 会话。
小结:四类错位 + 一类代价
| 错位类型 | 处理方式 | 典型例子 |
|---|---|---|
| 改名即可对应 | 直接映射 | headers→header、data→extraData、keyPasswd→keyPassword |
| 语义需要转换 | 取值/优先级转换 | method 转大写、responseType 转数字枚举、timeout 与 readTimeout 的覆盖优先级 |
| 对方没有的能力 | 降级或留空 | statusText 恒空、流式下无 performanceTiming、不支持 CancelToken |
| 需版本守卫的新能力 | sdkApiVersion 前置检查 |
TLCP(20+)、interceptorChain(22+)、maxRedirects(23+)、reuseConnections/enableAutoCookie(26+) |
| 代价:资源生命周期 | 校验前置 + 出口全覆盖 | 防孤儿原生资源、onFinished 五条路径、复用模式所有权转移 |
从业务视角要记住的四条:
- 别依赖
statusText,它恒为空,用status判断。 responseType只支持三种 ,传'blob'不报错但会静默按 object 处理。- 进度回调和
performanceTiming不可兼得,这是底层 API 的能力边界。 - 高级特性先查
deviceInfo.sdkApiVersion,而且throw出来的不是AxiosError,isAxiosError()会返回 false。
翻译工作到这里就完整了:config 已经变成 options,原生 HttpRequest 已经就位。下一篇讲这套翻译在 v2.2.13 遇到的一次挑战------用户要普通请求也有进度回调,而现有路径的底层 API 不支持,于是要新增一条流式路径。那里面有个防重复 settle 的守卫模式,和一个 O(1) 的环形缓冲测速器。
项目地址:@ohos/axios