@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('/user', cfg)

站点① 方法别名
axios.js

站点② 配置合并
core/mergeConfig.js

站点③ 拦截器链组装
core/Axios.js + InterceptorManager.js

站点④ 请求分发
core/dispatchRequest.js

站点⑤ 适配器选择
adapters/adapters.js

站点⑥ 四路分流
adapters/ohos/index.js

原生调用
httpRequest.request()


站点①: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)都拷贝到了这个函数对象上。

这解释了两件事:

  1. axios(config) 和 axios.request(config) 完全等价——前者就是在调那个函数本体。
  2. 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只认本次请求的值,没有就是 undefinedurl 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/PUTadapters/ohos/upload.js
② 下载传了 filePathadapters/ohos/download.js
③ 流式设了 onUploadProgress 或 onDownloadProgressadapters/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

六个站点里最值得记住的三件事:

  1. axios 本体是个函数,bind(request, context) 的产物,属性是 extend 挂上去的。
  2. 请求拦截器逆注册序执行(unshift 决定),README 的描述与代码相反,以代码为准。这直接影响多拦截器的注册顺序。
  3. 进度回调会切换适配器实现(http.js ↔ httpStream.js),代价是拿不到 performanceTiming——因为两条路径调的是不同的原生 API。

第 3 点的具体原因,以及站点⑥里 setHttpConfig 到底做了哪些翻译工作,是下一篇的内容:适配层的本质是两套 API 之间的翻译,而翻译要处理四类不同的情况。


项目地址:@ohos/axios

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐