三年了,终于抓到让导出文件变乱码的真凶:mockjs 全局劫持 XHR

6 阅读10分钟

一、症状:导出的 Excel 打不开了

用户反馈:在页面上点「导出表格」,浏览器正常下载了一个 运动员信息1786467007604.xlsx,文件大小看起来正常,但一用 Excel 打开,连续弹两个对话框:

Microsoft Excel
发现 "运动员信息 1786467007604.xlsx" 中的部分内容有问题。
是否让我们尽量尝试恢复?如果您信任此工作簿的源,请单击 "是"。

Microsoft Excel
Excel 无法打开文件 "运动员信息 1786467007604.xlsx",
因为文件格式或文件扩展名无效。请确定文件未损坏,并且文件扩展名与文件的格式匹配。

第二个对话框是致命一击——连"尝试恢复"都救不回来,直接判定"文件格式或扩展名无效"。这时候所有人的第一反应都是:文件坏了,那肯定是后端生成的问题。

最有意思的是这个 bug 不是必现的。今天导出坏了,明天同样的操作又能打开;换台电脑好了,换个人又坏。三年里它反复出现,每次都被当成"偶发问题"糊弄过去。

二、第一轮排错:全踩在直觉的坑里

排错的第一反应必然是查后端,于是三年里我们把能怀疑的都怀疑了一遍:

怀疑后端编码。 xlsx 本质是 zip,会不会是字符集问题?查 Content-Type、Content-Disposition、setCharacterEncoding("UTF-8"),全都没问题。

怀疑导出组件版本。 项目用的是 EasyExcel 4.0.1,很多人第一反应是"POI / EasyExcel 版本太老有 bug",于是升版本、降版本、换写法,症状纹丝不动。

最要命的是:后端测试永远是过的。 用 curl、Postman、脚本直接请求 /reviews/exportExcel,拿到的文件魔数正确(PK 开头)、能解压、Excel 能正常打开。

于是经典的一幕出现了:

  • 前端:文件是你后端生成的,你后端生成的就是坏的 / 我下载没问题啊
  • 后端:我 curl 下来是好的,是你前端下载逻辑坏了

互相甩锅三年,问题被一次又一次地"确认无法复现"。

事后看,这个 bug 真正难的点不在"难找",而在于它把排查方向牢牢焊死在错误的地方。下一节会看到我们是怎么把方向掰回来的。

三、系统性排查:把问题逐层剥开

这次我们换了个打法:不再猜,而是从后端到浏览器,一层一层验证字节。

3.1 后端输出:字节是干净的

直接用脚本登录拿 JWT,请求导出接口,落盘后用 zip 校验:

size=5003  magic=504b0304  解压正常  U+FFFD 数量=0

后端给出的文件完全合法。

3.2 经过 dev 代理:字节还是干净的

前端开发环境通过 localhost:8080/api 代理转发到后端,我们用同样的 JWT 头走一遍代理再落盘:

size=5003  magic=504b0304  解压正常  U+FFFD 数量=0

代理也没问题。

3.3 headless Chrome + fetch:依然是干净的

为了排除"浏览器环境"这个变量,我们用 headless Chrome 直接跑一个页面,用 fetch 请求同一接口、拿 response.blob()、走 file-saver 落盘:

size=5003  magic=504b0304  U+FFFD 数量=0

浏览器 + fetch 也没问题。

3.4 真实应用点击导出:坏了

到这里,后端、代理、浏览器 fetch 三条路全干净。最后用 Playwright 驱动真实的应用页面(登录 → /myInfo → 点「导出表格」按钮),抓取真实下载的文件:

size=8299  magic=504b0304  U+FFFD 数量=1682  ← 乱码!

同一台浏览器、同一个会话、同一个 token、同一个接口:fetch 是干净的,应用的 axios 是坏的。 这一下就把问题范围从"后端/网络"精确锁死到了 axios 这条请求路径上。

3.5 探针:真相在 response.data 的类型里

我们在前端导出处理函数里加了一行探针,打印 axios 返回的 response.data 到底是什么:

exportExcel().then((response) => {
  const d = response && response.data;
  console.log(
    "data ctor:", d.constructor.name,      // String
    "isBlob:", d instanceof Blob,          // false
    "isString:", typeof d === "string",    // true
    "cfg.responseType:", response.config.responseType,     // blob
    "request ctor:", response.request.constructor.name,    // MockXMLHttpRequest
    "request.responseType:", response.request.responseType // (空)
  );
});

输出:

data ctor: String  isBlob: false  isString: true
cfg.responseType: blob
request ctor: MockXMLHttpRequest
request.responseType: (空)

三个关键信息:

  • 配置里明明写了 responseType: 'blob',但 axios 拿到的 response.data 是 String,不是 Blob;
  • 执行请求的对象不是浏览器的原生 XMLHttpRequest,而是 MockXMLHttpRequest;
  • 而这个 Mock XHR 的 responseType 是空的——它根本没兑现 blob 这个类型。

MockXMLHttpRequest 五个字一出来,答案已经写脸上了:mockjs。

四、根因:模板遗留的一行 import,悄悄劫持了全局 XHR

4.1 现场:一个没人记得的 mock

项目 main.js 里有一行模板工程遗留的引入:

// main.js
import store from './store';
import './api/mock';   // ← 这行

api/mock.js 长这样:

// api/mock.js
import mock from "mockjs";
mock.mock('/api/home/getData', 'get', function () {
  return homeApi.getStatisticalData();
});

关键点来了:/api/home/getData 这个 URL,全项目根本没有任何代码调用它。 getData 实际调的是后端真实的 /task/getNewTask/{id}。也就是说这个 mock 本身是死代码——但它有一个所有人都忽略的副作用:

mockjs 一旦通过 Mock.mock() 建立拦截,就会把全局的 window.XMLHttpRequest 替换成它自己的 MockXMLHttpRequest。从这一刻起,所有走 XHR 的请求(包括 axios)全部经过 mockjs 的模拟实现。

4.2 MockXMLHttpRequest 不支持 responseType: 'blob'

axios 在浏览器里默认走 XHR 适配器。正常路径下,axios 的 xhr 适配器会做两件事(axios 1.8.3 源码):

// xhr.js 第 149~150 行:把 responseType 写到 XHR 上
if (responseType && responseType !== 'json') {
  request.responseType = _config.responseType;   // → 'blob'
}

// xhr.js 第 48~49 行:按 responseType 决定读哪个
const responseData = !responseType || responseType === 'text' || responseType === 'json'
  ? request.responseText      // 文本:读 responseText
  : request.response;         // 二进制:读 response(Blob)

对 blob 类型,正常 XHR 会返回一个真正的 Blob,字节原封不动。

但请求一旦被 mockjs 劫持,request 就变成 MockXMLHttpRequest。它对 responseType: 'blob' 的处理是——不处理,直接当普通文本,把响应体用 responseText 以 UTF-8 文本的方式读出来。于是:

  • 二进制字节 → 按 UTF-8 解码;
  • 任何不是合法 UTF-8 序列的字节 → 替换成 U+FFFD(EF BF BD);
  • 一个损坏字节膨胀成 3 个字节。

前端随后执行 new Blob([response.data], { type: ... })——注意这里 response.data 已经是包含大量 U+FFFD 的字符串了,new Blob 只是把这个已经损坏的字符串编码落盘,没有任何办法恢复原始字节。

4.3 用数字还原全过程

我们下载的那个坏文件,特征非常典型:

size=8299,全文件 U+FFFD 数量=1682

原始文件约 5003 字节。1682 × 2 = 3364,恰好是膨胀出来的字节数:5003 + 3364 ≈ 8299。每个被 U+FFFD 替换掉的字节,都让文件多了 2 个字节。 这份文件里遍布 1600 多个替换字符,zip 的中央目录偏移全被打乱,Excel 自然连"尝试恢复"都救不回来。

对比一下同一天、同一接口、干净的 fetch 下载:

size=5003  U+FFFD 数量=0

后端三年来一直产出的是好文件,坏的是浏览器里那条被 mockjs 劫持的下载链路。

五、为什么这个 bug 能藏三年

这是全文最值得琢磨的部分。它不属于"复杂",而属于"狡猾"。狡诈在五个方面:

5.1 副作用是隐形的

import './api/mock' 看起来人畜无害,mockjs patch 全局 XHR 是它内部自动发生的副作用,不在任何配置文件里,不写在任何文档里。项目换了好几批人,没人知道这个 mock 存在。

5.2 它只咬二进制,全站 JSON 都正常

应用里 99% 的接口返回 JSON,mock XHR 对文本 JSON 的处理和原生 XHR 几乎没差别,所有功能都正常。只有「导出 xlsx」这一类二进制响应才会踩雷。一个低频功能坏了三年,很难靠日常回归撞上。

5.3 报错信息把方向焊死在错误处

Excel 的报错是「文件格式或文件扩展名无效」,这会把所有人引向文件本身:后端编码、Content-Type、EasyExcel 版本、扩展名……没有一条指向请求层。错误的表现形式,决定了排查的搜索空间。

5.4 后端测试永远通过,前后端互相甩锅

curl、Postman、测试脚本不走 mockjs 的 XHR,拿到的永远是好文件。于是后端理直气壮:"我这边一直是好的",前端看着命名正确的文件也摸不着头脑。在接口层能验证的环节全部是绿的,真正的问题藏在浏览器内部,跨层验证根本碰不到它。

5.5 fetch 与 axios 行为分裂,制造"时好时坏"

最坑的是:同样在浏览器里,fetch 不受 mockjs 影响(fetch 不走 XHR),只有 axios 中招。所以只要排查时用 fetch 或别的手段复现,就"复现不出来",强化了"偶发问题"的错觉。

一句话总结这套组合拳:一个隐形副作用 + 只咬二进制 + 报错指向文件 + 后端永远绿 + fetch/axios 行为分裂。 任何一条单独出现都不难抓,五条叠在一起,就是三年。

六、修复与验证

修复极其简单——删掉死代码:

// main.js:删掉这行
import './api/mock';

顺手把 api/mock.js 和 mockServeData/ 整个删掉(确认无任何其他引用后)。

然后用同一套 Playwright 真实应用流程再测一次,探针输出变成:

data ctor: Blob  isBlob: true  isString: false  size: 5003
request ctor: XMLHttpRequest    ← 回到原生 XHR
request.responseType: blob
下载文件: size=5003  U+FFFD 数量=0  Excel 打开正常

一行 import 删掉,三年的谜题结束。

七、教训与可复用的排查方法

7.1 全局 patch 原生对象的库,是雷

任何库只要会替换 window.XMLHttpRequest / window.fetch / 全局原型(mockjs、某些拦截器、打点 SDK、旧版 axios-mock-adapter),都要警惕:它会影响所有网络请求,尤其是二进制和流式响应。用之前先问一句:"它动了全局吗?"

7.2 三条可复现的经验

  1. "curl 正常、浏览器坏"≈ 请求层拦截。当命令行/脚本验证全绿、浏览器偏偏坏时,第一嫌疑是浏览器端的拦截器、mock、Service Worker,而不是后端。
  2. 二进制问题,看字节别靠猜。抓文件后检查:魔数(xlsx 是 50 4B 03 04)、能否解压、全文件统计 EF BF BD(U+FFFD)的数量。U+FFFD 数量不为 0,就是某个环节把二进制当文本解码了,铁证。
  3. 做一条"双路径对照"。同一接口,分别用 fetch 和应用的 axios 请求,对比字节。路径之间出现差异,差异就锁定了故障层。

7.3 给二进制下载补上冒烟测试

像导出这种低频、二进制、跨层功能,值得一条自动化冒烟测试:真实浏览器点击导出 → 断言下载文件魔数正确、U+FFFD 为 0、能被解析。这条测试当年如果存在,三天内就会发现,而不是三年。

7.4 模板工程的遗留代码,删或隔离

脚手架带来的 mock、demo 路由、示例接口,上线前要么删除,要么用环境变量隔离(如 process.env.NODE_ENV === 'development' 且显式开启)。别让"永远用不到的 mock"成为生产代码的一部分——它不会自己变好,只会变成下一个三年之谜。


后记:这次排查没有用到任何高深技巧,只有"逐层验证字节 + 对比路径 + 看 response.data 的类型"。大多数"困扰多年的 bug"都长这样——不是难,而是被错误的方向和信息茧房保护了起来。把方向掰回字节层,它们往往脆弱得不堪一击。