fetch请求中文乱码?用arrayBuffer+TextDecoder正确解码GBK 做前端对接老系统接口的时候最怕遇到两件事一是接口文档不写清楚二是响应回来中文乱码。乱码这玩意儿尤其坑因为它不是你代码逻辑错了而是编码链路某处断了。最近处理一个“fetch请求响应用GBK解码”的需求从浏览器到Node都踩了一遍把原理和坑梳理清楚以后其实解法就那几步。如果你也在跟GBK老接口死磕或者看到response.text()出来的中文变成了“锟斤拷”“”这种火星文这篇文章可以直接当工具文收藏。先说结论fetch默认把响应体当作UTF-8解码遇到GBK字节流必然乱码。要让fetch正确解码GBK核心思路是先用response.arrayBuffer()拿到原始字节再用TextDecoder(gbk)手动解码。后面我会解释为什么必须这样做、浏览器和Node有什么区别以及遇到“乱码已经生成”的现场怎么救回来。1. 乱码是怎么发生的先搞懂编码链路1.1 字符编码101GBK和UTF-8的底层差异要解决乱码得先明白乱码是怎么来的。字符编码本质上是“字符”和“字节”之间的映射规则。GBK是一个双字节编码方案它兼容ASCII英文字母和符号占1字节常用中文占2字节也覆盖了GB2312的全部字符。UTF-8是变长编码ASCII字符占1字节中文通常占3字节而且它有非常严格的字节格式校验规则。举个例子“中”这个字GBK编码是D6 D02字节UTF-8编码是E4 B8 AD3字节同一个字符两种编码下字节完全不同。当服务器用GBK输出字节流而客户端却拿着UTF-8的解码表去解析结果就是要么出现“”这样的替换字符要么出现一堆乱码字。你可以把编码想象成一套暗号规则收信人拿错了密码本自然解不出正确信息。HTTP传输的本质就是字节流字符编码的问题只会在“字节变成字符串”这最后一公里爆发。1.2 fetch默认按UTF-8解码问题正在这里浏览器里的fetch提供了一个很方便的response.text()方法这个方法会把响应体整个读出来并且按照某种规则转换成JavaScript字符串。关键点在于这个规则在现代浏览器里基本默认是UTF-8。有人会问如果响应头Content-Type里写了charsetutf-8那我没写charset的时候服务器会发GBK吗实际情况是很多老系统尤其是企业内部系统后端返回application/json或者text/html时压根不带charset甚至带了charsetutf-8但实际输出的是GBK编码。这个时候response.text()就会按UTF-8去解码GBK字节出来的东西当然不能看。这里还要记住一个容易忽略的细节response.text()是一个“消费body”的操作。响应体一旦被读走response.arrayBuffer()和response.blob()就都不能用了因为body流已经被消耗掉。所以如果你想自己解码从一开始就要决定用arrayBuffer()而不是text()。1.3 响应的“编码声明”为什么经常被忽略理论上HTTP响应头里的Content-Type应该告诉我们编码比如Content-Type: application/json; charsetgbk这个声明是标准的但现实里它经常缺席或者不可靠。我见过几种典型情况后端完全不设置charset只写Content-Type: application/json。后端统一返回text/html; charsetutf-8但代码里拿到的字符串是用老系统组件拼出来的GBK。中间有网关或者负载均衡设备重写了响应头把charset丢弃了。接口文档声称GBK实际返回的是UTF-8或者反过来。所以客户端如果把解码这件事完全交给fetch就是把自己的正确显示建立在对方正确的声明之上。而老系统的响应头恰恰是最不靠谱的。这也是为什么我们必须把解码主动权拿回到自己手里先看原始字节再决定用什么编码去解。2. 核心方案fetch配合TextDecoder完成GBK解码2.1 正确姿势用arrayBuffer拿到原始字节要正确处理GBK响应最简单的方案是绕过fetch默认的解码逻辑直接拿原始字节。流程分三步fetch(url)拿到响应对象。response.arrayBuffer()得到ArrayBuffer。new TextDecoder(gbk).decode(new Uint8Array(buffer))手动解码。代码长这样async function fetchGBK(url, options {}) { const response await fetch(url, options); if (!response.ok) { throw new Error(HTTP error: ${response.status}); } const buffer await response.arrayBuffer(); const decoder new TextDecoder(gbk); const text decoder.decode(new Uint8Array(buffer)); return text; }这里有几个关键点。第一TextDecoder.decode()接收的是BufferSource所以必须把ArrayBuffer用Uint8Array包一层。第二TextDecoder(gbk)在浏览器里是合法的构造参数按照WHATWG Encoding规范gbk是一个有效的编码标签。第三如果在调用fetch之后、arrayBuffer()之前有人先调用了response.text()那么body已经被消费后面的解码会直接抛异常或者拿到空结果。这就是我前面强调的一旦决定自己解码就要彻底放弃response.text()别两头都不靠。2.2 TextDecoder(gbk)的浏览器支持与兼容性边界TextDecoder是现代浏览器都支持的API但“支持到什么编码”有讲究。按规范new TextDecoder(gbk)在Chrome、Firefox、Safari、Edge里都能正常工作读取的编码映射也覆盖GBK字符集。如果你在老一点的环境里遇到不支持可以考虑用gb2312作为标签或者引入text-encoding这个polyfill。有一点需要特别说明GBK是GB2312的超集GB2312缺一些生僻字、繁体字和符号。所以能写gbk就不要写gb2312否则遇到冷门字符解码结果可能会变“”或者缺字。另外Node.js环境要特别小心。Node自带的util.TextDecoder依赖ICUInternational Components for Unicode。如果Node编译时用的是small-icugbk不一定支持完整版ICU可能支持。但为了保险Node后端请直接使用iconv-lite这个库专门处理各种非UTF-8编码API简单稳定。这一部分我会在第3章详细展开。2.3 自动识别charset并封装通用函数实际开发中我们不可能每次都硬编码GBK。更好的做法是先从响应头里尝试拿到charset拿到就按它解码拿不到或者解码结果看起来不对再回退到GBK。解析charset的正则很简单function getCharset(contentType) { const match (contentType || ).match(/charset\s*\s*?([^;\s])?/i); return match ? match[1].toLowerCase() : null; }然后做一个自动解码函数async function fetchTextAuto(url, options {}) { const response await fetch(url, options); const buffer await response.arrayBuffer(); const bytes new Uint8Array(buffer); const contentType response.headers.get(content-type) || ; let charset getCharset(contentType); // 优先按声明解码 if (charset) { try { const text new TextDecoder(charset).decode(bytes); // 如果出现替换字符说明声明很可能不对 if (!text.includes(\uFFFD)) { return text; } } catch (e) { // 不支持的编码继续往下走 } } // 回退到GBK return new TextDecoder(gbk).decode(bytes); }这里用\uFFFD就是来检测解码错误算是一种常见经验。如果charset声明是utf-8但实际字节是GBKUTF-8解码器遇到非法字节就会产生这时候说明声明不可信于是回退到GBK再试一次。当然这个检测不是万无一失的因为某些双字节组合在UTF-8里可能恰好合法但出来的是别的字。但在我处理老系统接口的真实场景里这种“声明优先触发回退”的策略已经能覆盖90%的情况。如果仍不放心就在调用时显式传入charset让使用者自己定。3. 多环境实战Node端、乱码已发生和未知编码场景3.1 Node环境下的fetch/axios GBK解码Node 18以后有了全局fetch但它的实现undici在解码GBK上并不友好。Node自带的TextDecoder如果你能确认完整ICU支持也可以试试const { TextDecoder } require(util); const decoder new TextDecoder(gbk); const text decoder.decode(new Uint8Array(buffer));但注意很多默认编译的Node并不支持gbk运行时会抛TypeError: The gbk encoding is not supported。这时候最稳妥的方案是用iconv-litenpm install iconv-lite然后这样解码const iconv require(iconv-lite); async function fetchGBKNode(url, options {}) { const response await fetch(url, options); const arrayBuffer await response.arrayBuffer(); const buffer Buffer.from(arrayBuffer); return iconv.decode(buffer, gbk); }如果你用的是axios问题会更隐蔽。axios默认会把响应按UTF-8解析成字符串你拿到response.data的时候可能已经乱码了。解决方法是把responseType设置成arraybuffer然后再自己解码const axios require(axios); const iconv require(iconv-lite); const response await axios.get(url, { responseType: arraybuffer }); const text iconv.decode(Buffer.from(response.data), gbk);之所以要Buffer.from(response.data)是因为axios的arraybuffer在Node里可能表现为Buffer也可能表现为ArrayBuffer统一转一下更保险。直接把ArrayBuffer丢给iconv.decode是不行的它只认Buffer或字符串。3.2 乱码字符的逆向修复从“锟斤拷”救回数据有一种让人头疼的情况不是从接口直接拿乱码而是已经从数据库、日志或者消息队列里存了一段乱码字符串比如“锟斤拷”。你有没有想过这三个字为什么成了乱码的代名词其实原因很经典假设原始字符是“中文”它被UTF-8编码成字节流后如果某个环节用GBK去解码遇到无法识别的字节解码器就会用UFFFD替换字符。这个替换字符再经过GBK编码就会变成EF BF BD对应的汉字——恰好就是“锟斤拷”。所以“锟斤拷”实际上是“UTF-8字节流被GBK解码并多次替换”的痕迹。如果我手里只有乱码字符串原始字节已经丢了能救回来吗有一种场景可以救乱码字符串本身仍然是合法字符我们可以用UTF-8编码器把它重新变成字节再用GBK解码。因为乱码字符串对应的字节可能正好是原始字节的一部分。示例function recoverMojibake(mojibake) { const bytes new TextEncoder().encode(mojibake); return new TextDecoder(gbk).decode(bytes); }这个函数的原理就是“把当前字符串按UTF-8编码成字节再用GBK解回来”。它只适用于部分单层乱码。如果经历过多次编码转换、丢字节或者被替换成“锟斤拷”这种占位符信息已经损失就救不回了。所以我的态度很明确宁可源头用arrayBuffer()保存字节也不要事后在乱码字符串上做逆向工程。逆向只是应急手段不是常规方案。3.3 编码自动检测不知道源编码时怎么办如果响应头没有charset我们也不确定到底是GBK还是UTF-8这时候可以做一次“盲测”。常见做法是先尝试UTF-8合法性校验。如果字节流能通过UTF-8的格式要求大概率是UTF-8。如果大量字节落在GBK汉字区且能按GBK解出通顺中文就是GBK。这种人工判断可以用库来完成。浏览器端可以用jschardetNode端也能用chardet或jschardet。示例const jschardet require(jschardet); const iconv require(iconv-lite); const buffer Buffer.from(await response.arrayBuffer()); const result jschardet.detect(buffer); const charset result.encoding.toLowerCase(); const text iconv.decode(buffer, charset);但我要提醒一句编码检测不是100%可靠尤其是文本很短的时候误判率会明显上升。生产环境最好还是让后端把charset修好或者先确认接口文档别把检测库当成银弹。检测库更多是“辅助排查”的角色而不是“最终方案”。4. 完整示例封装一个好用的fetchGBK模块4.1 从零实现fetchGBK函数代码与说明综合前面的原理我们可以封装一个比较完整的前端公共模块。它应该支持默认按GBK解码。允许调用方显式指定charset。返回原始response和解析后的文本。方便解析JSON时清理BOM。代码如下export async function fetchGBK(url, options {}) { const { charset gbk, responseType text, ...fetchOptions } options; const response await fetch(url, fetchOptions); const buffer await response.arrayBuffer(); const bytes new Uint8Array(buffer); let text; try { text new TextDecoder(charset).decode(bytes); } catch (e) { throw new Error(不支持的字符集: ${charset}); } if (responseType json) { try { // 去掉BOM后解析避免JSON.parse报错 const data JSON.parse(text.replace(/^\uFEFF/, )); return { response, data }; } catch (e) { throw new Error(JSON解析失败前200字符为: ${text.slice(0, 200)}); } } return { response, text }; }用法很简单const { text } await fetchGBK(/api/legacy/list, { charset: gbk });如果接口返回的是JSONconst { data } await fetchGBK(/api/legacy/detail, { responseType: json });这里responseType参数是我自己设计的用来告诉模块是否进一步做JSON解析。注意JSON.parse之前剔除\uFEFF很重要。因为某些老系统会在文件开头输出BOM也就是EF BB BF解码成字符串后会有不可见的\uFEFF字符直接JSON.parse会报错。4.2 处理JSON和HTML不同内容的细节GBK接口最常见的就是两种返回JSON和HTML。先聊JSON。很多人以为JSON必须是UTF-8其实不是。JSON作为一种文本格式只要最后能被解析成合法字符串就行。我们拿GBK字节流用TextDecoder(gbk)解出字符串再JSON.parse完全没问题。唯一的坑就是BOM上面已经讲了。再聊HTML。如果你拿到的是GBK编码的HTML正确做法是先用fetchGBK把文本解出来再用DOMParser解析。不要直接把这个HTML字符串交给DOMParser让它自动检测编码因为你已经手动解码过一次HTML里的meta charsetgbk可能会干扰后续处理。示例const { text } await fetchGBK(/legacy/page.html, { charset: gbk }); const doc new DOMParser().parseFromString(text, text/html);这时文档里的中文已经是正确字符串DOMParser再解析时不会重新经历编码转换。如果你反过来直接fetch(/legacy/page.html).then(r r.text())那么很可能在r.text()那一步就已经乱码了后面做什么都晚了。4.3 性能、缓存与异常处理建议用arrayBuffer()会把整个响应体一次性读入内存。对小接口无所谓但如果是一个几MB的GBK文本内存占用会比较明显。此时可以用流式解码配合TextDecoder的stream选项把字节分块读取、分块解码async function fetchGBKStream(url, options {}) { const response await fetch(url, options); const reader response.body.getReader(); const decoder new TextDecoder(gbk); let result ; while (true) { const { value, done } await reader.read(); if (done) break; // stream: true 让解码器保留跨块的不完整字节 result decoder.decode(value, { stream: true }); } result decoder.decode(); // flush return result; }这个方案有两个好处第一不用等整个响应体加载完才开始处理第二GBK多字节字符可能被分块切断stream模式会帮我们缓存不完整的字节下一块到达时自动拼接。异常处理方面我建议至少做到对response.ok做判断HTTP非2xx直接抛出或单独处理。对TextDecoder不支持指定charset的情况做兜底。对JSON解析失败把前200字符带进错误信息方便排查。如果response.bodyUsed为true说明body已经被消费要立刻定位代码顺序问题。5. 常见问题与排查技巧实录5.1 遇到“”替换字符怎么办“”的学名是UFFFD是解码器遇到无法映射的字节时放在结果里的“占位符”。如果你看到每个中文位置都是或者中文后面跟一串那基本可以断定字节流被错误的解码方式处理了。排查步骤按顺序来先确认响应头里有没有charset以及它写的是什么。把响应体拿到手别用text()用arrayBuffer()存住字节。用十六进制查看前几十个字节如果看到大量D6 D0这类GBK汉字区字节基本是GBK。用new TextDecoder(gbk)重新解码如果消失说明就是UTF-8解码器硬解GBK导致的。这个“先看字节再选解码器”的思路能解决一大半乱码问题。你不需要背编码表只要记住字节是客观存在的乱码是“解码器选错”的主观结果。5.2 遇到“锟斤拷”怎么办“锟斤拷”不是一种普通乱码它几乎成了编码事故的代名词。它出现的本质是UTF-8字节被GBK解码后无效字节被替换成UFFFD再经过编码转换变成“锟斤拷”等汉字。遇到这种情况最稳妥的办法是回到源头重新用arrayBuffer()取字节然后用正确的编码解码。如果源头数据已经丢失只能尝试第3章的逆向修复函数但成功率取决于实际转换链路。我还要强调一点存储数据时一定要保留编码信息。比如在数据库里存文本时统一转成UTF-8并标记在日志里输出时看到乱码就不要再写到库里了不然后面根本无从查起。我见过很多团队为了图快把已经乱码的字符串存进数据库结果后续排查的人越看越乱。5.3 排查思路速查表现象可能原因推荐方案中文全部变成“”用UTF-8解码GBK字节遇到非法字符被替换改用TextDecoder(gbk)重新解码出现“锟斤拷”UTF-8字节被GBK解码再经历替换字符编码回到源头保存字节重新解码或尝试逆向修复中文正常但JSON.parse报错文本开头有BOMtext.replace(/^\uFEFF/, )后再parseNode端TextDecoder抛编码不支持Node未启用完整ICU改用iconv-lite解码Buffer接口头写utf-8但实际GBK后端配置错误或网关改写响应头检测到后回退GBK或让后端修正大响应解码时卡顿/内存高一次性arrayBuffer()读取改流式解码配合{ stream: true }这张表是我实际排查问题时的“第一反应”。如果你看到某个现象先对号入座能省很多时间。5.4 几个容易忽略的编码坑最后再列几个我踩过或者说常见的坑都是细节但都可能导致乱码。第一个坑response.bodyUsed。body只能读一次。如果你想在读取后既保留文本又想拿buffer做别的事对不起做不到。正确做法是在读取buffer后自己存一份副本。第二个坑请求头里的Accept-Charset并不能强制服务器返回指定编码。它只是告诉服务器“我优先能处理哪些字符集”而老后端很可能根本无视它。别指望设置这个头就能让服务器吐GBK给你。第三个坑console.log显示正常不代表真的正常。很多浏览器的控制台会自己尝试美化输出有些情况下会把乱码字符悄悄转换或显示成别的样子。要以字节和TextDecoder的结果为准。第四个坑如果你在中间处理环节把文本转成Base64再解码出来编码信息可能已经被破坏。遇到GBK接口尽量保持“原始字节 - 字符串”的单向一步转换不要中间插入“先转Base64再回来”这种多余动作。第五个坑有些老系统会返回Content-Type: text/plain; charsetgb2312但实际可能包含GBK扩展字符。别因为写了gb2312就死咬着不换如果解码出来有缺字立刻试试用gbk标签解码。做这行越久越觉得编码问题不是靠背编码表解决的。核心只有一条原则永远把“字节”和“字符串”拆开看。字节是流字符串是解码后的视图。你用哪张解码表决定了看到的是中文还是天书。我在实际处理里只要对接外部接口涉及中文一律先拿arrayBuffer再决定解码方式绝不让框架替我做这个决定。最后再分享一个小技巧调试编码问题时别只盯着console。打开DevTools的Network面板看看响应头里的charset再用fetch断点看arrayBuffer的十六进制。比如“中”字的GBK字节是D6 D0看到这个基本就可以断定该用GBK解码。这个方法比任何工具都直接也是我快速判断老接口编码的首选。