Axios 上传大文件崩溃:鸿蒙 RNOH 下 XHR 返回空响应头引发的"假失败"

14 阅读3分钟

React Native + 鸿蒙适配项目中,上传小文件正常、上传大文件崩溃——而且后端其实已经收到了文件。一次诡异的偶发问题,最终靠 9 行 patch 解决。

问题

项目里有个文件上传功能,小图片上传一切正常,换成稍大的文件(~3MB)就崩了。错误信息很明确:

Error: header name must be a non-empty string
  at AxiosHeaders.set (axios/lib/core/AxiosHeaders.js:...)
  at parseHeaders (axios/lib/helpers/parseHeaders.js:...)
  at AxiosHeaders.from (axios/lib/core/AxiosHeaders.js:...)
  at xhrAdapter.onloadend (axios/lib/adapters/xhr.js:97)

注意几个关键点:

  • 栈打在 onloadend——响应已经回来了才报错
  • 后台日志显示文件已经写入成功
  • 同一套代码在 Android 上没问题,只有鸿蒙设备复现
  • 登录等普通 JSON 接口完全正常,只在上传场景出现

一句话总结:HTTP 请求成功了,但客户端在解析响应头时崩了,业务拿到的是"假失败"。

复现

HarmonyOS 6.1.0 (API 23) 模拟器,同一份上传代码:

上传文件getAllResponseHeaders() 返回值结果
~99 KB JPG"content-type: application/json\r\n..."上传成功
~3 MB PNG""(空字符串)崩溃

小文件返回正常 header 字符串,大文件返回空字符串。跟图片格式无关,跟文件大小有关——更像是鸿蒙 XHR 在处理较大 multipart 响应时的时序问题。

根因

axios 的 xhr.js 在拿到响应后这样组装 headers:

const responseHeaders = AxiosHeaders.from(
  'getAllResponseHeaders' in request && request.getAllResponseHeaders(),
);

正常情况下 getAllResponseHeaders() 返回类似 "content-type: application/json\n..." 的字符串,AxiosHeaders.from() 解析成功,一切正常。

但鸿蒙在大 multipart 响应下返回了 ''。追踪 AxiosHeaders.from('') 的调用链:

AxiosHeaders.from('')
  → new AxiosHeaders('')
    → this.set('')
      → 判断是字符串 → parseHeaders('')
        → ''.split('\n') → ['']
          → 循环处理: line = ''
            → i = ''.indexOf(':') → -1
            → key = ''.substring(0, -1).trim() → ''
              → headers.set('', value)
                → normalizeHeader('') → ''
                  → throw 'header name must be a non-empty string'

空字符串按 \n 切割后得到一个空元素,解析出空 key,触发校验异常。

更要命的是这个异常的位置:它发生在 onloadend 内部,axios 的 promise 还没 settle。所以 拦截器也救不了——promise 链断了,interceptors.response 根本不会触发:

正常流程: onloadend → 解析 headers → settle(resolve) → 拦截器 → 业务拿到 response
这次异常: onloadend → 解析 headers → throw → promise 未 settle → 拦截器不触发
                                                               → RN XHR onerror 兜底成错误

修复

patches/axios+1.6.7.patch

       // Prepare the response
-      const responseHeaders = AxiosHeaders.from(
-        'getAllResponseHeaders' in request && request.getAllResponseHeaders()
-      );
+      let responseHeaders;
+      try {
+        const rawHeaders =
+          'getAllResponseHeaders' in request && request.getAllResponseHeaders();
+        responseHeaders =
+          rawHeaders && String(rawHeaders).trim()
+            ? AxiosHeaders.from(rawHeaders)
+            : new AxiosHeaders();
+      } catch (_err) {
+        responseHeaders = new AxiosHeaders();
+      }

做了两件事:

  1. 空值守卫rawHeaders && String(rawHeaders).trim() 确保空字符串、null、纯空白都不进 parseHeaders,直接返回空的 AxiosHeaders
  2. try-catch 兜底:万一遇到畸形字符串(比如 '\r\n\r\n')走到 parseHeaders 抛错,也不会阻断响应体解析

responseDatastatus 等主路径完全不动,补丁只管 headers。

为什么不用其他方案

方案为什么不选
axios.interceptors.response 兜底promise 在 onloadend 里就断了,拦截器作为 promise 链上的节点不会触发
切换 adapter(http adapter)行为差异大,且项目依赖 XHR 特有的 onUploadProgress
升级 axios查了 1.7.x 源码,同样没有防御;这是跨端环境的边界问题,axios 不太可能为此做适配
等 RNOH 修复治本但周期不可控,业务不能等

业务侧配套

patch 解决了崩溃,但业务侧还有两件事需要处理:

  • FormData 的 Content-Type:axios 默认会把 FormData 序列化成 JSON,需要在拦截器里对 FormData 请求设置 AxiosHeaders.setContentType(false),让浏览器/RN 自动处理 boundary
  • 更大文件的"假成功":patch 后不再崩了,但更大文件可能出现"流中断 + 后端返回 200 包裹业务错误码"的情况,业务侧需要主动判断 res.success,不能只看 HTTP status

经验

  1. 不要信任 RN / 鸿蒙的 XHR 实现完全符合 Web 规范——getAllResponseHeaders() 在规范里要求返回空字符串或合法 header 列表,但"空字符串"在 axios 的 parseHeaders 里恰好是个会抛错的值
  2. 偶发崩溃优先查平台差异——同一个 bug 只在特定平台出现,大概率是平台实现与预期不符
  3. patch 是务实的选择——精准改几行代码,最小化维护负担,等上游修复后直接删 patch 文件