前端处理 Base64,英文字母怎么编都没事,一碰中文和 emoji 就开始出状况:要么 btoa 直接抛异常,要么编出来的串后端解不开,要么解回来是一堆 ç§æ。
这些问题的根子是同一个:Base64 编的是字节,不是字符 。JS 字符串是 UTF-16,而 btoa / atob 这对老 API 只认「每个字符一个字节」的 Latin-1 字符串。中间差的那一步 UTF-8 转换,谁来做、在哪做,决定了结果对不对。
下面六个坑,每个都附了能直接跑的代码和实际输出(Node 25.8 实测,浏览器里行为一致)。
坑 1:btoa 直接吃中文,抛 InvalidCharacterError
js
const s = '秋招 offer 🎉';
btoa(s);
// InvalidCharacterError
btoa 要求每个字符的码点都在 0--255 之间。「秋」是 U+79CB,超了,直接抛错。这个坑至少会报错,算友好的。
坑 2:用 encodeURIComponent「修」好了,其实编的是另一个东西
网上流传很广的一种写法:
js
const wrong = btoa(encodeURIComponent(s));
console.log(wrong);
// JUU3JUE3JThCJUU2JThCJTlCJTIwb2ZmZXIlMjAlRjAlOUYlOEUlODk= (56 个字符)
不报错了,前端自己再 decodeURIComponent(atob(...)) 也能还原,看起来一切正常。但把这个串交给后端(Go、Java、Python 任何一种按标准 Base64 + UTF-8 解码的语言),拿到的是:
%E7%A7%8B%E6%8B%9B%20offer%20%F0%9F%8E%89
它编码的是「百分号转义后的 ASCII 文本」,不是原文的 UTF-8 字节。结果只有自己的前端认识,而且体积还大了一截:正确的编码只有 24 个字符(见下面),这里是 56 个。
老代码里还有一个变体 btoa(unescape(encodeURIComponent(s))),多了个 unescape 把 %E7 这种转义还原成单字节字符,结果其实是对的。只是 unescape / escape 早就被标记为废弃,新代码没必要再用。
坑 3:正确做法是先拿 UTF-8 字节
js
function encodeUtf8(str) {
const bytes = new TextEncoder().encode(str); // UTF-8 字节
let bin = '';
for (let i = 0; i < bytes.length; i++) bin += String.fromCharCode(bytes[i]);
return btoa(bin);
}
function decodeUtf8(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
const ok = encodeUtf8('秋招 offer 🎉');
console.log(ok); // 56eL5oubIG9mZmVyIPCfjok=
console.log(ok === Buffer.from('秋招 offer 🎉').toString('base64')); // true
console.log(decodeUtf8(ok)); // 秋招 offer 🎉
思路就是把「字符 → 字节」这一步显式交给 TextEncoder,btoa 只负责「字节 → Base64」。和 Node 的 Buffer 结果一致,意味着和各语言后端也一致。emoji 是 4 字节的 UTF-8,同样没问题。
坑 4:解码忘了 TextDecoder,得到一串 ç§æ
js
console.log(atob('56eL5oubIG9mZmVyIPCfjok='));
// ç§æ offer ð...
atob 返回的是「每个字符代表一个字节」的二进制字符串,不是文本。直接显示就是把 UTF-8 字节当 Latin-1 读,典型的乱码。看到 ç、æ、ð 开头的乱码,基本可以断定是少了 TextDecoder 那一步。
坑 5:URL-safe Base64 和 URL 里的加号
标准 Base64 字母表里有 + 和 /,放进 URL 会出事:
js
const std = encodeUtf8('面试'); // 6Z2i6K+V
new URLSearchParams('t=' + std).get('t');
// '6Z2i6K V' 加号被当成了空格
所以 JWT、很多签名参数用的是 URL-safe 变体:+ 换成 -,/ 换成 _,通常还去掉末尾的 =。问题是 atob 不认这套字母表:
js
const urlsafe = '6Z2i6K-V';
atob(urlsafe); // InvalidCharacterError
function fromUrlSafe(b) {
b = b.replace(/-/g, '+').replace(/_/g, '/');
while (b.length % 4) b += '=';
return b;
}
decodeUtf8(fromUrlSafe(urlsafe)); // 面试
排查时有个很快的判断:串里出现 - 或 _,就是 URL-safe;出现空格,多半是某个环节把 + 当成了空格,要回头查是谁没做 URL 编码。
顺带一提,缺 padding 本身不一定报错,atob('5bCPPw') 在 Node 和 Chrome 里都能正常解出「小?」,规范允许省略末尾的 =。真正报错的是字母表不对,别把两件事混在一起。
坑 6:大文件 String.fromCharCode(...bytes) 爆栈
处理图片、文件时,很多人会图省事这样写:
js
const big = new Uint8Array(1_000_000);
String.fromCharCode(...big);
// RangeError: Maximum call stack size exceeded
展开运算符会把一百万个字节当成一百万个函数参数,直接超出调用栈。改成分块:
js
function bytesToB64(bytes) {
let bin = '';
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
console.log(bytesToB64(big).length); // 1333336
1MB 编完是 1333336 个字符,符合 Base64「4/3 倍」的体积膨胀。如果只是要在页面上显示图片,其实没必要转 Base64,URL.createObjectURL(blob) 更省内存。
新 API:Uint8Array.toBase64 / fromBase64
较新的运行时已经有原生方法了,上面那些手写的转换都可以省掉:
js
const bytes = new TextEncoder().encode('秋招 offer 🎉');
bytes.toBase64(); // 56eL5oubIG9mZmVyIPCfjok=
new TextEncoder().encode('面试')
.toBase64({ alphabet: 'base64url', omitPadding: true }); // 6Z2i6K-V
new TextDecoder().decode(
Uint8Array.fromBase64('6Z2i6K-V', { alphabet: 'base64url' })
); // 面试
URL-safe 和 padding 都是参数,不用自己替换字符。我在 Node 25.8 上跑通了。不过要兼容旧浏览器的项目,暂时还得保留坑 3 那套写法做兜底,上线前先查一下你的目标浏览器支不支持。
小结一下排查顺序
- 报
InvalidCharacterError:看是编码时有非 Latin-1 字符(坑 1),还是解码时混进了-、_、空格(坑 5)。 - 解出来是
%E7%A7...:编码端用了btoa(encodeURIComponent())(坑 2)。 - 解出来是
ç§æ:解码端少了TextDecoder(坑 4)。 - 大文件卡死或爆栈:别用展开运算符(坑 6)。
平时临时核对一个串对不对,我会直接丢到 福兮的 Base64 编解码工具(forxi.cn)里看一眼,中文和 emoji 编出来的结果和上面 TextEncoder 的写法一致,拿来和后端对数很方便。它按标准 Base64 处理,遇到 URL-safe 的串要先把 -、_ 换回 +、/ 再贴进去,这点和 atob 一样。