Base64 中文乱码:btoa 报错、atob 乱码和 UTF-8 的关系
在浏览器控制台里执行 btoa('中文'),会得到 InvalidCharacterError。用某个”偏方”让报错消失之后,解码出来的又常常是乱码。两个现象来自
同一件事:Base64 编码的对象是字节,而 JavaScript 里的字符串不是字节。
Base64 做了什么
它每次读取 3 个字节(24 位),拆成 4 组 6 位,每组映射成 64 个字符的字母表中的一个字符。如果输入长度不是 3 的倍数,最后一组用 = 补齐。
| 输入 | 输出 |
|---|---|
Man | TWFu |
Ma | TWE= |
M | TQ== |
Hello | SGVsbG8= |
输出长度是 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 A9 | w6k= |
中 | E4 B8 AD | 5Lit |
中文 | E4 B8 AD E6 96 87 | 5Lit5paH |
🚀 | F0 9F 9A 80 | 8J+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 中文乱码” 时,按顺序问自己四个问题:
- 编码那一侧,是先转 UTF-8 字节再编码的吗?直接把字符串交给
btoa,或者在 Java、Python 里用了平台默认字符集,都是隐患。 - 解码那一侧,是否把字节按 UTF-8 解成了文本?
atob的结果只是字节串,不是文本。 - 传输途中有没有被改写?
+变成空格、=被百分号编码、换行被插进去、结尾被截断,都会表现为解码失败或乱码。 - 对方到底用的是什么字符集?拿不准的时候,看解码得到的字节:开头出现
E4E9并且后面紧跟80BF的字节,通常是 UTF-8 的汉字;双字节、且高位字节在81~FE的,更像 GBK。