Base64 里的中文和 emoji:为什么 btoa 报错、解出来是乱码
贴进来一段中文想编码,浏览器直接报错;或者从接口拿到一串 5L2g5aW9,atob 解出 ä½ å¥½ 这种看着像中文又不是中文的东西。这两个现象是同一个原因:Base64 的输入和输出都是字节,而 JavaScript 的字符串是字符。中间那一步"字符 ↔ 字节"没人替你决定,谁写谁选。
一、先把心智模型摆对:它在重排字节
每 3 个字节(24 bit) 切成 4 组 6 bit,每组去一张固定的 64 字符表里取下标(A-Z、a-z、0-9、+、/)。6 bit 恰好塞得进一个可打印 ASCII 字符,这就是"用纯文本搬运任意字节"的原理。末尾不满 3 字节用 = 补到 4 的倍数:差 1 字节写 ==,差 2 字节写 =。
所以「你好」变成 8 个字符,不是因为它复杂,而是因为它在 UTF-8 里是 6 个字节:
你好 → E4 BD A0 E5 A5 BD (6 字节)
→ 5L2g5aW9 (8 字符,无填充)
一个汉字 3 字节 → 4 字符,正好一整组,一个汉字对应 4 个 Base64 字符;emoji 占 4 字节 → 输出 8 字符(末尾补两个 =),🚀 就是 8J+agA==。
二、btoa("你好") 为什么直接抛错
btoa / atob 收发的是 Latin-1:字符串里每个码点必须不大于 0xFF(也就是"一个字符 = 一个字节")。「你」是 U+4F60,超出范围,浏览器抛 InvalidCharacterError —— 这是 Chrome、Firefox、Safari 和 Node 一致的行为,不是哪个环境坏了。
正确的两步:先字符串 → UTF-8 字节,再在字节上做 Base64。
// 编码
function b64encode(s) {
const bytes = new TextEncoder().encode(s); // 字符串 → UTF-8 字节
let bin = "";
bytes.forEach((b) => { bin += String.fromCharCode(b); });
return btoa(bin); // 每字节一字符,此时全在 0xFF 以下
}
// 解码(fatal: true 很关键,见第四节)
function b64decode(s) {
const bytes = Uint8Array.from(atob(s), (c) => c.charCodeAt(0));
return new TextDecoder("utf-8", { fatal: true }).decode(bytes);
}
b64encode("你好"); // "5L2g5aW9"
b64decode("5L2g5aW9"); // "你好"
别的语言里这一步是显式的,所以不容易踩:
import base64
base64.b64encode("你好".encode("utf-8")) # b'5L2g5aW9'
base64.b64decode("5L2g5aW9").decode("utf-8") # '你好'
Base64.getEncoder().encodeToString("你好".getBytes(StandardCharsets.UTF_8)); // 5L2g5aW9
new String(Base64.getDecoder().decode("5L2g5aW9"), StandardCharsets.UTF_8); // 你好
三、解出来是 ä½ å¥½:字节被当成字符读出来了
atob("5L2g5aW9") 不报错,它返回 "ä½ å¥½"。因为 atob 老实交出 6 个字节,每个字节写成一个 Latin-1 字符:E4 BD A0 三个字节读成 ä、½、空格;E5 A5 BD 读成 å、¥、½。这是典型的 UTF-8 被按 Latin-1 解读,字符数对得上,内容全错。
判断口诀:解出来像欧洲文字,就是编码环节错位;解出来是 ??? 或方块,多半原文压根不是 UTF-8(比如 GBK 导出的老文本)。修法不是再转一次,而是回到产生它的那一端确认原始编码。
四、别把错误吞成假汉字:fatal: true
默认的 TextDecoder 遇到拼不出的字节序列会塞替换字符 U+FFFD 然后继续,于是图片、zip 这类真二进制被"解码成功",你得到一串 nobody 能用的方块字。加上 { fatal: true },坏序列会直接抛 TypeError——报错比给一屏假字更有用。这也是 Base64 文本编码 / 解码 的解码口径:贴进来的是二进制时明确报错,而不是硬拼汉字。
五、长度、填充和"模 4 余 1"
字节数和字符数的对应是死的:
| 原始字节 | 1 | 2 | 3 | 4 | 5 | 6 |
|---|---|---|---|---|---|---|
| Base64 字符 | 4 | 4 | 4 | 8 | 8 | 8 |
填充 = |
2 | 1 | 0 | 2 | 1 | 0 |
两个直接推论:输出长度一定是 4 的倍数(带填充时),膨胀率 33.3%——100 KB 的图片内联成 data:image/png;base64,... 就是 133 KB,还挤占 HTML、丢掉独立缓存,所以只有小图标适合内联。
那"长度不是 4 的倍数"能不能解?能,而且是常态——很多系统省掉 =。解码端按有效字符数模 4 就能推出缺几位:余 2 补 ==、余 3 补 =。余 1 在任何 Base64 里都不可能成立,遇到它只能说明复制时漏了字符或末尾被截断,这时报错比猜一个结果负责任得多。
顺带两个宽容规则,粘邮件里的 Base64 时用得上:换行和空格会被忽略(邮件正文每 76 字符折行是标准做法),YQ、YT==、YQ== 解出来都是 a。但写了填充就得写够:a 的规范形式是 YQ==,只补一个写成 YQ= 时浏览器会抛 InvalidCharacterError——省略填充和填错填充是两回事。
六、+ 和 / 才是让 Base64 出事的字符
标准字符表里有 + 和 /,它们在传输链路上都不安全:/ 是路径分隔符,而 + 在查询串里代表空格。所以把含 + 的 Base64 直接拼进 URL,到服务端常常变成"值里多了个空格、签名对不上"——而且只在原串恰好含 + 时复现,最难查。
RFC 4648 §5 的 base64url 就是为这个准备的变体:
标准 + /
base64url - _
= 通常也一起省掉——它在查询串里还得二次转义,而如上所述按长度就能推回来。JWT 三段(header.payload.signature)用的就是 base64url 无填充形态,query 参数、文件名、cookie 里也该用它。Base64 文本编码 / 解码 有 URL-safe 开关,输出顺带给字节数和膨胀率;解码时两张表都认,所以粘进来的标准 Base64 也解得开。
七、它是编码,不是加密
字符表公开、固定,没有任何密钥:5L2g5aW9 谁都能一步还原成「你好」,MTIzNDU2 就是 123456。别拿它存密码,也别拿它"混淆"敏感字段——配置里写 password: MTIzNDU2 和明文没有实质区别,安全扫描器就靠这一步把它揪出来。要保密请上真正的加密(比如 AES-GCM),Base64 只配当密文的外壳:它的职责是让二进制能安全地穿过只认文本的通道(JSON、邮件、URL、数据库字符列)。
相关问题
emoji 编码为什么要格外小心? 一个 emoji 在 JavaScript 里是两个字符位(UTF-16 代理对),在 UTF-8 里是 4 个字节。任何按 charCodeAt 循环拼字节的写法都有把代理对劈开的风险,走 TextEncoder 就没这个问题。🚀 → 8J+agA==。
能用它解图片吗? 不能。它只做文本:Base64 解出来是任意字节,图片的前几字节喂给 UTF-8 解码器就是坏序列,fatal: true 下会直接报错。要看图片里的拍摄信息,用图片 EXIF 读取。
JWT 那三段怎么解? 它是 base64url,atob 需要先补填充、把 - _ 换回 + /。JWT 解码 直接摊成 header / payload / 签名三段,并明确标出它没有验签——解码能过不代表 token 可信。
会不会把我贴的内容传上去? 不会。字符表写死在脚本里,你的文本不会出现在请求里,输入的内容不会离开这台设备。