Base64 中文乱码:btoa 报错、atob 乱码和 UTF-8 的关系

更新于 5 分钟阅读

在浏览器控制台里执行 btoa('中文'),会得到 InvalidCharacterError。用某个”偏方”让报错消失之后,解码出来的又常常是乱码。两个现象来自 同一件事:Base64 编码的对象是字节,而 JavaScript 里的字符串不是字节。

Base64 做了什么

它每次读取 3 个字节(24 位),拆成 4 组 6 位,每组映射成 64 个字符的字母表中的一个字符。如果输入长度不是 3 的倍数,最后一组用 = 补齐。

输入输出
ManTWFu
MaTWE=
MTQ==
HelloSGVsbG8=

输出长度是 ceil(n / 3) * 4,所以 Base64 的结果总是输入的 4/3 倍左右。它是把字节塞进纯文本通道的一种编码方式,不是压缩,也不是加密。

为什么 btoa 遇到中文会报错

btoa 接收字符串,把每个字符当成一个字节,所以只接受码点不超过 U+00FF 的字符。é(U+00E9)可以通过,变成单字节 E9:btoa('é') 得到 6Q==。中 是 U+4E2D,远大于 255,直接抛异常。emoji 更麻烦,因为 JavaScript 用两个 UTF-16 码元来存放 🚀。

正确做法是先用明确的编码把字符串变成字节,再对字节编码。按 UTF-8:

文本UTF-8 字节Base64
éC3 A9w6k=
中E4 B8 AD5Lit
中文E4 B8 AD E6 96 875Lit5paH
🚀F0 9F 9A 808J+agA==
function toBase64(text) {
  const bytes = new TextEncoder().encode(text);
  let bin = '';
  for (const b of bytes) bin += String.fromCharCode(b);
  return btoa(bin);
}

function fromBase64(b64) {
  const bin = atob(b64);
  return new TextDecoder().decode(Uint8Array.from(bin, (c) => c.charCodeAt(0)));
}

String.fromCharCode 的那个循环并不多余:btoa 要的是”二进制字符串”,其中每个字符代表一个字节,循环构造出来的正是这种形式。在 Node 里, Buffer.from(text, 'utf8').toString('base64') 和 Buffer.from(b64, 'base64').toString('utf8') 一行就能完成同样的事。

为什么 atob 解出来是乱码

只用 atob 解码 5Lit5paH,得到的是 "中æ\u0096\u0087"。atob 每个字节返回一个字符,这 6 个字节被当成了 Latin-1:E4 是 ä, B8 是 ¸,AD 是一个看不见的软连字符。数据本身没有损坏,只是解码少走了一步,也就是上面 fromBase64 里的那次 UTF-8 解码。如果你在解码结果里 看到 中æ、å¼ ä¸ 这种东西,那就是它的特征:合法的 UTF-8 被当成单字节字符读了出来。

数据本来就不是 UTF-8 的情况

反方向的乱码是另一类问题。如果数据来自使用 GBK 的系统,中 是 D6 D0,编码后是 1tA=。按 UTF-8 去解码就是非法序列:D6 开头的是双字节序列, 而 D0 不能作为它的后续字节。严格的解码器会报错,宽松的解码器会用 U+FFFD(�)顶替。Python 的 .decode('gbk')、Java 的 new String(bytes, "GBK") 可以正确读出,而 Java 里的通用原则是每次都显式指定字符集,不要依赖平台默认值。下面链接的 Base64 工具只按 UTF-8 解码;当字节不是合法的 UTF-8 时,它会明确告诉你, 指出第一个非法字节的位置,并给出十六进制视图,你可以据此区分这是 GBK 数据还是二进制文件。

还有一个 UTF-8 方面的陷阱:Windows 上某些编辑器保存的文件开头带有字节顺序标记 EF BB BF,Base64 编码后是 77u/。如果解码出来的每个字符串开头都带一个看不见的 U+FEFF,就去查这个前缀。

URL 安全写法、填充和换行

标准 Base64 使用 + 和 /,它们在 URL 里都有特殊含义,所以 base64url(RFC 4648)把它们换成 - 和 _,JWT 和许多 Web 接口还会去掉结尾的 =。这会带来三个问题:

  • atob 不认 - 和 _。 解码前要先把它们换回 + 和 /。
  • 填充。 HTML 规范里的 atob 能容忍缺少填充,但很多其他解码器不能。Python 的 base64.b64decode 会报 Incorrect padding。把字符串用 = 补到长度为 4 的倍数, 或者补齐后使用 base64.urlsafe_b64decode。
  • 查询字符串里的 +。 表单风格的查询解析会把 + 当成空格,所以未转义就放进 URL 的标准 Base64 到达对方时已经损坏。要么把 +、/、= 做百分号编码,要么改用 base64url。

MIME 每 76 个字符换行(RFC 2045),PEM 每 64 个字符换行。解码器一般会跳过换行,但直接按长度做校验的代码不会。另外,粘贴进来的 data:image/png;base64,... 本身也不是 纯 Base64:逗号之前的前缀必须先去掉。

怎么判断是哪里出了问题

当字符串长达几百个字符时,只告诉你”无效输入”的解码器帮不上忙。去掉空白和填充后,长度对 4 取余等于 1 的数据永远不可能合法:一个字符只有 6 位,凑不出完整的一个字节, 这通常意味着粘贴时被截断了。出现在中间的 = 说明两段字符串被拼在了一起。本页面的工具会指出违反了哪一条规则,并指出第一个出错字符的位置。

编码时它还会把自己的结果解码一遍,告诉你往返之后是否原样得到了你的文本。唯一做不到的情形是单独出现的代理项半区,UTF-8 没有办法表示它,所以编码器会换成 U+FFFD。 编码、解码以及文件选项都在当前标签页里用 TextEncoder 和 TextDecoder 完成,不会上传任何内容。

一份快速自查清单

遇到 “Base64 中文乱码” 时,按顺序问自己四个问题:

  1. 编码那一侧,是先转 UTF-8 字节再编码的吗?直接把字符串交给 btoa,或者在 Java、Python 里用了平台默认字符集,都是隐患。
  2. 解码那一侧,是否把字节按 UTF-8 解成了文本?atob 的结果只是字节串,不是文本。
  3. 传输途中有没有被改写?+ 变成空格、= 被百分号编码、换行被插进去、结尾被截断,都会表现为解码失败或乱码。
  4. 对方到底用的是什么字符集?拿不准的时候,看解码得到的字节:开头出现 E4E9 并且后面紧跟 80BF 的字节,通常是 UTF-8 的汉字;双字节、且高位字节在 81~FE 的,更像 GBK。

打开工具: Base64 编码与解码

返回指南列表

更多指南