@ohos/axios 源码解读(二):适配层是一场翻译,而翻译要处理四类错位

@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/,下文路径均相对于此。

四类错位与本文结构:

flowchart LR A[&#34;axios config<br/>(跨平台抽象)&#34;] --> B{四类错位} B --> C[&#34;第一类<br/>改名即可对应&#34;] B --> D[&#34;第二类<br/>语义需要转换&#34;] B --> E[&#34;第三类<br/>对方没有的能力&#34;] B --> F[&#34;第四类<br/>需版本守卫的新能力&#34;] C --> G[&#34;鸿蒙 options&#34;] D --> G E --> G F --> G G --> H[&#34;代价<br/>原生资源生命周期&#34;]

翻译的两端:先看两套 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 五条路径、复用模式所有权转移

从业务视角要记住的四条:

  1. 别依赖 statusText ,它恒为空,用 status 判断。
  2. responseType 只支持三种 ,传 'blob' 不报错但会静默按 object 处理。
  3. 进度回调和 performanceTiming 不可兼得,这是底层 API 的能力边界。
  4. 高级特性先查 deviceInfo.sdkApiVersion ,而且 throw 出来的不是 AxiosError,isAxiosError() 会返回 false。

翻译工作到这里就完整了:config 已经变成 options,原生 HttpRequest 已经就位。下一篇讲这套翻译在 v2.2.13 遇到的一次挑战------用户要普通请求也有进度回调,而现有路径的底层 API 不支持,于是要新增一条流式路径。那里面有个防重复 settle 的守卫模式,和一个 O(1) 的环形缓冲测速器。


项目地址:@ohos/axios

相关推荐
CV工程师丁Sir6 小时前
ArkWeb 手记 11:Web 组件与原生动画的协同
harmonyos·混合开发·arkweb
CV工程师丁Sir7 小时前
ArkWeb 手记 12:Web 组件内存泄漏排查实录
性能优化·harmonyos·arkweb
用户29469405448161 天前
DSH Desktop - Electron on HarmonyOS Edition
harmonyos·deepseek
IsITGirl1 天前
HarmonyOS 鸿蒙 WuKong 稳定性测试报错 Failed to connect to AAMS(已解决)
华为·harmonyos
万物智能信息科技1 天前
鸿蒙RS232驱动开发—【万物智能之开源鸿蒙OpenHarmony系统实战开发系列教程】
驱动开发·华为·开源·harmonyos·鸿蒙
less_121381 天前
鸿蒙 WPS Open SDK 接入指南:凭据申请、HAR 集成与 registerApp 校验
华为·harmonyos·wps
威哥爱编程2 天前
HarmonyOS 7 Agent 开发实战 07:多 Agent 任务分解与反思
agent·harmonyos
威哥爱编程2 天前
HarmonyOS 7 Agent 开发实战 06:Agent 记忆与上下文管理
agent·harmonyos
HarmonyOS_SDK2 天前
【HarmonyOS SDK Skills】扫码能力一键构建,系统级高精度扫码秒实现
harmonyos