@ohos/axios 源码解读(一):跟着一次 axios.get() 走完整个调用链
@ohos/axios 源码解读(一):跟着一次 axios.get() 走完整个调用链
@ohos/axios 是 Axios v1.3.4 在 OpenHarmony 上的适配版本:对外保持与 Web 端一致的 API——Promise 链式调用、请求响应拦截器、文件上传下载、取消请求,底层则把请求交给鸿蒙的 @ohos.net.http 执行。因为调用方式和前端 axios 完全一致,它成了鸿蒙应用网络层的主流选择。
它的源码分两部分:core/ 目录基本沿用 Web 版实现,adapters/ohos/ 是为鸿蒙重写的适配层。理解这个库,最好的切入点是看清一次请求在这两部分之间如何流动。
本文内容:跟着一次 axios.get('/user') 从业务代码走到原生 HTTP 调用,中间经过六个站点。每一站回答同一个问题——上一站交给我什么、我做了什么、我把它交给了谁。走完这一趟,三个常见疑问会有确切答案:拦截器为什么是这个执行顺序、多层配置的优先级怎么算、适配器是怎么被选中的。
📦 仓库地址 | 本文基于 v2.2.13
源码根目录:library/src/main/ets/components/lib/,下文路径均相对于此。
调用链全貌:
站点①:axios.get() 其实是个函数别名
先解决一个很多人没想过的问题:axios 到底是什么?它既能 axios('/url') 当函数调,又能 axios.get()、axios.create() 当对象用。
📄 axios.js
function createInstance(defaultConfig) {
const context = new Axios(defaultConfig);
const instance = bind(Axios.prototype.request, context); // ← instance 是个函数
utils.extend(instance, Axios.prototype, context, {allOwnKeys: true}); // 挂上 get/post/create...
utils.extend(instance, context, null, {allOwnKeys: true}); // 挂上 defaults/interceptors
instance.destroy = function() { context.destroy(); };
instance.create = function create(instanceConfig) {
return createInstance(mergeConfig(defaultConfig, instanceConfig));
};
return instance;
}
const axios = createInstance(defaults);
axios 的本体是 bind(Axios.prototype.request, context)——一个绑定了 this 的 request 函数。所谓"axios 是个对象"是假象:utils.extend 把 Axios.prototype 上的方法和 context 上的属性(defaults、interceptors)都拷贝到了这个函数对象上。
这解释了两件事:
axios(config)和axios.request(config)完全等价——前者就是在调那个函数本体。axios.create()返回的新实例也是这么造出来的,所以它同样"既能当函数又能当对象"。
那 axios.get() 呢?它是原型上的别名方法:
📄 core/Axios.js(文件末尾)
utils.forEach(['delete', 'get', 'head', 'options'], function forEachMethodNoData(method) {
Axios.prototype[method] = function(url, config) {
return this.request(mergeConfig(config || {}, {
method,
url,
data: (config || {}).data // ← 注意:GET 也传了 data
}));
};
});
utils.forEach(['post', 'put', 'patch'], function forEachMethodWithData(method) {
function generateHTTPMethod(isForm) {
return function httpMethod(url, data, config) {
return this.request(mergeConfig(config || {}, {
method,
headers: isForm ? { 'Content-Type': 'multipart/form-data' } : {},
url,
data
}));
};
}
Axios.prototype[method] = generateHTTPMethod();
Axios.prototype[method + 'Form'] = generateHTTPMethod(true); // postForm / putForm
});
所以 axios.get('/user', cfg) 做的事是:把 cfg 和 {method:'get', url:'/user'} 合并,然后调 this.request()。
一个容易忽略的细节:forEachMethodNoData 这个函数名说"无 data 的方法",但代码里明确传了 data: (config || {}).data。所以 GET 请求是可以带 body 的——虽然不符合 HTTP 语义惯例,但 axios 不拦你。postForm 则是通过 generateHTTPMethod(true) 预置了 Content-Type: multipart/form-data。
本站产出:所有请求方法最终都汇入 Axios.prototype.request(mergedConfig)。下一站从这里开始。
站点②:request() 入口与三层配置合并
📄 core/Axios.js
request(configOrUrl, config) {
// 支持 axios('url', config) 这种 fetch 风格
if (typeof configOrUrl === 'string') {
config = config || {};
config.url = configOrUrl;
} else {
config = configOrUrl || {};
}
config = mergeConfig(this.defaults, config); // ← 本站核心
// 复用模式下把 HttpRequest 实例通过内部通道传下去
if (this.defaults.enableHttpRequestReuse) {
config.__httpRequestInstance = this.getHttpRequest();
}
...
}
mergeConfig(this.defaults, config) 是配置优先级的唯一裁决点:config1 是实例默认值,config2 是本次请求的配置。
📄 core/mergeConfig.js
const mergeMap = {
url: valueFromConfig2,
method: valueFromConfig2,
data: valueFromConfig2,
baseURL: defaultToConfig2,
transformRequest: defaultToConfig2,
timeout: defaultToConfig2,
adapter: defaultToConfig2,
responseType: defaultToConfig2,
onUploadProgress: defaultToConfig2,
onDownloadProgress: defaultToConfig2,
maxContentLength: defaultToConfig2,
maxBodyLength: defaultToConfig2,
cancelToken: defaultToConfig2,
validateStatus: mergeDirectKeys, // ← 唯一一个特殊处理
headers: (a, b, prop) => mergeDeepProperties(headersToObject(a), headersToObject(b), prop, true)
};
utils.forEach(Object.keys({...config1, ...config2}), function computeConfigValue(prop) {
if (prop === '__proto__' || prop === 'constructor' || prop === 'prototype') return; // ← 原型污染防护
const merge = utils.hasOwnProp(mergeMap, prop) ? mergeMap[prop] : mergeDeepProperties;
...
});
四种合并策略,对应四种字段语义:
| 策略 | 行为 | 用于 |
|---|---|---|
valueFromConfig2 | 只认本次请求的值,没有就是 undefined | url method data |
defaultToConfig2 | 本次请求优先,没传则回落实例默认值 | baseURL timeout 等大部分字段 |
mergeDirectKeys | 只要 config2 存在这个 key 就用它(哪怕值是 undefined) | validateStatus |
mergeDeepProperties | 深合并两层对象 | headers,以及所有未在 mergeMap 中列出的字段 |
为什么 url/method/data 用 valueFromConfig2 而不是 defaultToConfig2?因为这三个字段不应该有"实例级默认值"。如果 axios.create({ url: '/api' }) 能生效,那 instance.get('/user') 到底请求哪个 URL 就说不清了。用 valueFromConfig2 从机制上杜绝了这种歧义。
为什么 validateStatus 要单独用 mergeDirectKeys?因为它需要支持显式传 null 来关闭状态码校验:
💡 业务侧用法示例(非库源码)
axios.get('/user', { validateStatus: null }) // 任何状态码都 resolve,不抛错
defaultToConfig2 无法区分"用户传了 null"和"用户没传"——两者都是 falsy,会回落到默认值,导致 null 失效。mergeDirectKeys 用 hasOwnProp 判断 key 是否存在,就能区分这两种情况。这是个只有读源码才能理解的设计。
headers 用深合并且 caseless: true,所以 axios.defaults.headers.common['Authorization'] 和单次请求的 headers: {'X-Trace': '1'} 会合到一起,且大小写不敏感。
那行 __proto__ / constructor / prototype 的跳过是原型污染防护——如果攻击者能控制 config 对象(比如把用户输入直接展开进 config),不加这道防线就能污染 Object.prototype。仓库里多个 CVE 修复(#462)都在这条线上。
本站产出:一个合并完成的、字段语义明确的 config 对象。
站点③:拦截器链的组装与两条执行路径
回到 request(),配置合并之后开始组装拦截器链。
📄 core/InterceptorManager.js
class InterceptorManager {
constructor() { this.handlers = []; }
use(fulfilled, rejected, options) {
this.handlers.push({
fulfilled, rejected,
synchronous: options ? options.synchronous : false,
runWhen: options ? options.runWhen : null
});
return this.handlers.length - 1; // 返回 id 供 eject 使用
}
eject(id) {
if (this.handlers[id]) {
this.handlers[id] = null; // 置 null,不是 splice
}
}
forEach(fn) {
utils.forEach(this.handlers, function forEachHandler(h) {
if (h !== null) fn(h); // 跳过已 eject 的
});
}
}
eject 用置 null 而非 splice:splice 会让后续元素索引前移,之前 use() 返回的 id 全部失效。置 null 保持索引稳定,代价是数组留空洞,由 forEach 里一句 h !== null 过滤。典型的"用一点内存换 API 正确性"。
然后是链的组装——请求拦截器 unshift,响应拦截器 push:
📄 core/Axios.js
const requestInterceptorChain = [];
let synchronousRequestInterceptors = true;
this.interceptors.request.forEach(function unshiftRequestInterceptors(interceptor) {
if (typeof interceptor.runWhen === 'function' && interceptor.runWhen(config) === false) {
return; // runWhen 决定本次是否参与
}
synchronousRequestInterceptors = synchronousRequestInterceptors && interceptor.synchronous;
requestInterceptorChain.unshift(interceptor.fulfilled, interceptor.rejected);
});
const responseInterceptorChain = [];
this.interceptors.response.forEach(function pushResponseInterceptors(interceptor) {
responseInterceptorChain.push(interceptor.fulfilled, interceptor.rejected);
});
按注册顺序 A → B → C 手工推演:
请求链 unshift 累积过程:
处理 A → [A.f, A.r]
处理 B → [B.f, B.r, A.f, A.r]
处理 C → [C.f, C.r, B.f, B.r, A.f, A.r]
实际执行:C → B → A → dispatchRequest (逆注册序)
响应链 push 累积过程:
处理 A → [A.f, A.r]
处理 B → [A.f, A.r, B.f, B.r]
处理 C → [A.f, A.r, B.f, B.r, C.f, C.r]
实际执行:dispatchRequest → A → B → C (注册序)
结论:请求拦截器 LIFO(后注册先执行),响应拦截器 FIFO(先注册先执行)。
⚠️ 仓库
README_zh.md第 524 行写的是"请求拦截器按 FIFO 顺序执行(先添加先执行);响应拦截器按 LIFO 顺序执行",与代码实际行为相反。上面是从unshift/push逐行推演的结果,可以自己验证:
💡 可自行验证(非库源码)
axios.interceptors.request.use((c) => { console.info('req-1'); return c })
axios.interceptors.request.use((c) => { console.info('req-2'); return c })
axios.interceptors.response.use((r) => { console.info('resp-1'); return r })
axios.interceptors.response.use((r) => { console.info('resp-2'); return r })
axios.get('/test')
// 实际输出:req-2 → req-1 → resp-1 → resp-2
这个顺序有实际影响:想让日志拦截器记录到"已加好 Token"的最终请求,日志拦截器必须先注册(先注册 → 后执行 → 看到的是鉴权拦截器改完的结果)。
组装完链之后,request() 要选执行路径。判据是 synchronousRequestInterceptors——逻辑与累积,只要有一个拦截器没声明 synchronous: true,整体就走异步路径:
📄 core/Axios.js
if (!synchronousRequestInterceptors) {
// 路径一:全异步
const chain = [dispatchRequest.bind(this), undefined];
chain.unshift.apply(chain, requestInterceptorChain);
chain.push.apply(chain, responseInterceptorChain);
len = chain.length;
promise = Promise.resolve(config);
while (i < len) {
promise = promise.then(chain[i++], chain[i++]); // 成对取出
}
return promise;
}
// 路径二:同步快路径
len = requestInterceptorChain.length;
let newConfig = config;
i = 0;
while (i < len) {
const onFulfilled = requestInterceptorChain[i++];
const onRejected = requestInterceptorChain[i++];
try {
newConfig = onFulfilled(newConfig); // 纯函数调用,不走 Promise
} catch (error) {
onRejected.call(this, error);
break;
}
}
try {
promise = dispatchRequest.call(this, newConfig); // 到这里才产生第一个 Promise
} catch (error) {
return Promise.reject(error);
}
数组结构是 [fulfilled, rejected, fulfilled, rejected, ...] 成对排列,while 循环每次 i++ 两下取一对传给 then。dispatchRequest 后面配的 undefined 是因为发请求本身不需要 rejected 处理器,错误由后面的响应拦截器接。
两条路径的差别是微任务数量:异步路径下每个拦截器占一个 then,三个请求拦截器就是三轮事件循环;同步路径是纯函数调用栈,config 一路传递到 dispatchRequest 才产生第一个 Promise。
如果你的拦截器只是同步改 header、加签名,声明 synchronous: true 就能走快路径:
💡 业务侧用法示例(非库源码)
axios.interceptors.request.use(
(config) => { config.headers['X-Sign'] = sign(config.url); return config },
undefined,
{ synchronous: true }
)
在高频请求场景(列表预加载、埋点上报)下,省掉的微任务调度是可测量的。
本站产出:一个已经过所有请求拦截器加工的 config,交给 dispatchRequest。
站点④:dispatchRequest 的三段式
📄 core/dispatchRequest.js
export default function dispatchRequest(config) {
throwIfCancellationRequested(config); // ① 发送前查取消
config.headers = AxiosHeaders.from(config.headers);
config.data = transformData.call(config, config.transformRequest); // ② 请求数据转换
if (['post', 'put', 'patch'].indexOf(config.method) !== -1) {
config.headers.setContentType('application/x-www-form-urlencoded', false);
}
const adapter = adapters.getAdapter(config.adapter || defaults.adapter); // ③ 选适配器
return adapter(config).then(function onAdapterResolution(response) {
throwIfCancellationRequested(config); // ④ 响应后再查一次取消
response.data = transformData.call(config, config.transformResponse, response);
response.headers = AxiosHeaders.from(response.headers);
return response;
}, function onAdapterRejection(reason) {
if (!isCancel(reason)) {
throwIfCancellationRequested(config);
if (reason && reason.response) {
reason.response.data = transformData.call(config, config.transformResponse, reason.response);
reason.response.headers = AxiosHeaders.from(reason.response.headers);
}
}
return Promise.reject(reason);
});
}
结构是"转换请求 → 调适配器 → 转换响应",但有四个细节:
取消检查做了两次(① 和 ④)。第二次是为了处理"请求已发出、等响应期间用户调了 abort()"——这时不该把响应交给业务代码。
📄 core/dispatchRequest.js
function throwIfCancellationRequested(config) {
if (config.cancelToken) {
config.cancelToken.throwIfRequested();
}
if (config.signal && config.signal.aborted) {
throw new CanceledError(null, config);
}
}
同时兼容 cancelToken(旧 API,鸿蒙上不推荐)和 signal(AbortController)。
setContentType(..., false) 第二个参数是 false,意为"不覆盖已有值"。所以你自己设的 Content-Type: application/json 不会被冲掉,只有完全没设时才用默认的 x-www-form-urlencoded。
失败分支也要跑 transformResponse。HTTP 500 的错误响应体同样会被转换——所以在响应拦截器里统一解析错误结构是可行的。
本站产出:适配器返回的 Promise,resolve 出的是已经过 transformResponse 的 response 对象。
站点⑤:适配器选择——这里只有一个候选
📄 adapters/adapters.js
const knownAdapters = {
ohos: ohosAdapter // ← 只有这一个
}
utils.forEach(knownAdapters, (fn, value) => {
if(fn) {
try {
Object.defineProperty(fn, 'name', {value}); // 重命名函数,便于错误信息定位
} catch (e) { }
Object.defineProperty(fn, 'adapterName', {value});
}
});
export default {
getAdapter: (adapters) => {
adapters = utils.isArray(adapters) ? adapters : [adapters]; // 支持传数组做降级列表
const {length} = adapters;
let nameOrAdapter, adapter;
for (let i = 0; i < length; i++) {
nameOrAdapter = adapters[i];
// 字符串则查表,函数则直接用
if((adapter = utils.isString(nameOrAdapter) ? knownAdapters[nameOrAdapter.toLowerCase()] : nameOrAdapter)) {
break;
}
}
if (!adapter) {
if (adapter === false) {
throw new AxiosError(`Adapter ${nameOrAdapter} is not supported by the environment`, 'ERR_NOT_SUPPORT');
}
throw new Error(
utils.hasOwnProp(knownAdapters, nameOrAdapter) ?
`Adapter '${nameOrAdapter}' is not available in the build` :
`Unknown adapter '${nameOrAdapter}'`
);
}
if (!utils.isFunction(adapter)) throw new TypeError('adapter is not a function');
return adapter;
},
adapters: knownAdapters
}
Web 版 axios 的 knownAdapters 里有 xhr、http、fetch 三个,按环境自动挑。鸿蒙版只剩 ohos 一个——因为运行环境是确定的,没有浏览器/Node 之分。
但 getAdapter 的能力保留了:
- 传字符串
'ohos'→ 查表拿到适配器 - 传函数 → 直接用(这就是文档里
adapter: (config) => {...}自定义适配器的入口) - 传数组 → 依次尝试,第一个可用的胜出(降级列表)
Object.defineProperty(fn, 'name', {value}) 是为了让函数在错误堆栈和日志里显示成 ohos 而不是 anonymous——小细节,但排查问题时很有用。
本站产出:adapters/ohos/index.js 导出的那个函数。
站点⑥:鸿蒙适配层的四路分流
旅程最后一站。适配器拿到的还是那个 axios config,它要决定这次请求走哪条实现路径。
📄 adapters/ohos/index.js
export default isXHRAdapterSupported && function (config) {
return new Promise(function (resolve, reject) {
const httpConfig = setHttpConfig(config, reject); // 校验 + 创建原生 HttpRequest
if (!httpConfig) return;
const { httpRequest } = httpConfig;
const data = own(config, 'data');
const method = own(config, 'method');
const filePath = own(config, 'filePath');
if (data && data instanceof FormData && (method.toUpperCase() === 'POST' || method.toUpperCase() === 'PUT')) {
upload(httpConfig, resolve, reject); // ① 上传
} else if (filePath) {
let res = fs.accessSync(path);
if (!res) {
download(httpConfig, resolve, reject); // ② 下载
} else {
if (!own(config, 'enableHttpRequestReuse')) httpRequest.destroy();
reject(new AxiosError('The file already exist, please delete the file first!', ...));
}
} else {
if (hasProgressCallback(config)) {
httpStream(httpConfig, resolve, reject); // ③ 流式(带进度)
} else {
http(httpConfig, resolve, reject); // ④ 普通
}
}
})
}
判断优先级是 上传 > 下载 > 流式 > 普通,四个条件互斥:
| 分支 | 触发条件 | 底层实现文件 |
|---|---|---|
| ① 上传 | data instanceof FormData 且方法是 POST/PUT | adapters/ohos/upload.js |
| ② 下载 | 传了 filePath | adapters/ohos/download.js |
| ③ 流式 | 设了 onUploadProgress 或 onDownloadProgress | adapters/ohos/httpStream.js |
| ④ 普通 | 以上都不满足 | adapters/ohos/http.js |
流式的判据就一个函数:
📄 adapters/ohos/index.js
const hasProgressCallback = (config) => {
return typeof own(config, 'onUploadProgress') === 'function' ||
typeof own(config, 'onDownloadProgress') === 'function';
};
只要设了任一进度回调就切到 httpStream(底层用 requestInStream()),否则走 http.js(底层用 request())。这是 v2.2.13 "普通请求也支持进度"特性的开关,也是"无 Breaking Change"的关键——没设进度回调的请求,走的还是原来那条路径,行为和性能完全不变。
下载分支里那句 The file already exist, please delete the file first! 是文档中"若 filePath 指定的文件已存在,下载失败,需提前删除文件"的来源:fs.accessSync 检查到文件存在就直接 reject,不做覆盖。
四个分支最终都会调到鸿蒙的原生 API。以普通请求为例,http.js 里就是:
📄 adapters/ohos/http.js
httpRequest.request(
buildURL(fullPath, own(config, 'params'), own(config, 'paramsSerializer')),
options,
(err, data) => { ... }
);
旅程到此结束——@ohos.net.http 的 request() 被调用,请求真正发出去了。
全程回顾
把六个站点串起来,一次 axios.get('/user') 的完整路径:
axios.get('/user', cfg)
↓ 站点① axios.js / core/Axios.js
方法别名 → mergeConfig(cfg, {method:'get', url:'/user'}) → this.request(...)
↓ 站点② core/mergeConfig.js
实例默认值 + 本次配置,按 mergeMap 的四种策略合并
(url/method/data 只认本次;validateStatus 支持显式 null;headers 深合并)
↓ 站点③ core/Axios.js + core/InterceptorManager.js
请求拦截器 unshift 成链(逆注册序执行)
全 synchronous 则走同步快路径,否则走 Promise 链
↓ 站点④ core/dispatchRequest.js
查取消 → transformRequest → 选适配器 → 调用 → transformResponse
↓ 站点⑤ adapters/adapters.js
knownAdapters 只有 ohos 一项;支持字符串/函数/数组三种传法
↓ 站点⑥ adapters/ohos/index.js
setHttpConfig 校验并创建原生 HttpRequest
按 上传 > 下载 > 流式 > 普通 四路分流
↓
httpRequest.request(url, options, callback) ← @ohos.net.http
六个站点里最值得记住的三件事:
axios本体是个函数,bind(request, context)的产物,属性是extend挂上去的。- 请求拦截器逆注册序执行(
unshift决定),README 的描述与代码相反,以代码为准。这直接影响多拦截器的注册顺序。 - 进度回调会切换适配器实现(
http.js↔httpStream.js),代价是拿不到performanceTiming——因为两条路径调的是不同的原生 API。
第 3 点的具体原因,以及站点⑥里 setHttpConfig 到底做了哪些翻译工作,是下一篇的内容:适配层的本质是两套 API 之间的翻译,而翻译要处理四类不同的情况。
项目地址:@ohos/axios
更多推荐



所有评论(0)