Base64 不是压缩也不是加密:33% 膨胀和 data URL 的真实代价
见过有人把密码 Base64 一下当"加密"存进库,也见过把首屏所有图标内联成 data URL "省请求",结果首屏更慢了。Base64 是个被严重误解的东西——它不缩小数据,反而撑大约 33%;它不保护数据,谁都能一秒解开。它只解决一个问题:让二进制安全地走过只认文本的通道。这篇说清楚它的代价该花在哪。
Base64 干的事只有一件:把任意二进制数据,重新编码成 64 个"安全"可见字符(A-Z a-z 0-9 + /)。这样它能塞进 JSON 字符串、URL、邮件正文、HTML 这些只接受文本的地方而不被破坏。仅此而已。它不是给人看的,也不是给数据上锁的。
33% 从哪来:4 个字符装 3 个字节
原理是进制重组。原始数据按 8 位一字节,Base64 按 6 位一组(2⁶=64 正好对应 64 个字符)。每 3 字节(24 位)切成 4 组 6 位,编成 4 个字符。
3 字节 → 4 字符 膨胀率 4/3 ≈ 1.333
即每 100KB 的原始数据,Base64 后约 133KB
当原始字节数不是 3 的倍数时,末尾用 = 补齐到 4 的倍数——这就是 Base64 串结尾常见一两个 = 的原因。它是填充,不是数据。
别在 Base64 之后再指望 gzip/brotli 帮你省回来。压缩算法吃的是原始字节里的规律,Base64 重组后规律被打散,压缩率反而比直接压原始二进制更差。正确顺序是先压缩、再 Base64(如果非传文本不可),不是反过来。
base64url:URL 和 JWT 里的那个变种
标准 Base64 的 +、/、= 在 URL 里有特殊含义:+ 会被解析成空格,/ 是路径分隔,= 是查询参数赋值。直接把 Base64 塞进 URL 会被搅乱。于是有了 base64url(RFC 4648 的 URL-safe 变体):
标准: + / =(填充)
URL 安全: - _ (通常直接省略填充)
JWT 就是典型——它的三段都是 base64url 且不带填充。这也是为什么你把 JWT 中段直接拿标准 Base64 解码偶尔会失败:字符集和填充都不一样。处理 JWT、URL 参数里的 token 时,认准 base64url。要拆开看一个 JWT 三段分别编码了什么,用 JWT 编辑器 比手动解码省事。
data URL 内联图片:HTTP/2 之后大多是亏的
把小图标编成 data:image/png;base64,... 直接写进 HTML/CSS,曾经是性能优化的常规操作——省掉一次 HTTP 请求。在 HTTP/1.1 时代,每个请求都有排队和连接开销,这么换划算。
但到了 HTTP/2,多路复用让同一连接上并发几十个请求几乎没有额外开销,"省一个请求"这点收益基本归零。而 Base64 内联的代价却一个没少,还更明显了:
- 体积涨 33%,而且这部分撑大的是 HTML/CSS 本身——首屏关键资源变大,直接拖慢首次渲染。
- 无法被单独缓存。外链图片浏览器能缓存、能复用;内联进 HTML 的图,每次 HTML 变了就得整个重新下载,跨页面也没法共享。
- CSS 里的 data URL 会阻塞渲染。CSS 是渲染阻塞资源,把大图塞进 CSS 等于让渲染等这坨 Base64 下载完。
- 不能并行解码、不利于懒加载。
现在的经验法则:只对极小(约 1–2KB 以内)、且几乎每页都要、又不常变的资源考虑内联,比如一两个关键 SVG 图标。稍大的图老老实实走外链,交给 CDN 和浏览器缓存。别再无脑"内联省请求"了——HTTP/2 之后那笔账已经反过来了。
btoa 遇到中文直接抛错
浏览器自带 btoa/atob,但它们只认 Latin1(每字符 0–255)。传个中文或 emoji 进去,直接报错:
btoa("hello") // "aGVsbG8=" ✓
btoa("你好") // ❌ InvalidCharacterError: 字符超出 Latin1 范围
正确做法是先用 TextEncoder 把字符串编成 UTF-8 字节,再对字节做 Base64:
// 编码:字符串 → UTF-8 字节 → Base64
function b64encode(str) {
const bytes = new TextEncoder().encode(str);
let bin = "";
bytes.forEach(b => bin += String.fromCharCode(b));
return btoa(bin);
}
// 解码反过来:Base64 → 字节 → 字符串
function b64decode(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
b64encode("你好") // "5L2g5aW9"
b64decode("5L2g5aW9") // "你好"
这个坑的根源还是字符和字节的混淆——btoa 要的是字节,你给了它字符。Node 里则用 Buffer.from(str, "utf8").toString("base64"),没这个问题。
最后纠正那个最危险的误解
Base64 不提供任何安全性。它是公开、可逆、无密钥的编码,任何人拿到都能一行命令还原:
echo "cGFzc3dvcmQxMjM=" | base64 -d
# password123
把密码、token、敏感配置 Base64 一下就当"处理过了",等于没处理。要保密用加密(AES 等),要校验完整性用哈希/HMAC。Base64 只负责"能安全地放进文本通道",安全性这词跟它没关系。需要快速编解码一段文本或文件,用 在线 Base64 工具,本地计算不上传。