什么是 Base64 Data URI,以及为什么有些图片解不开
2026-09-30 · 1413 词
Data URI 是一张活在字符串里、而不是文件里的图片。整件事 —— 头、编码标志、载荷 —— 就是一行文本,你可以粘进 CSS 规则、JSON 字段或地址栏:
data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8DwHwAFAAH/q842iQAAAABJRU5ErkJggg==
这一行就是一张 1×1 的透明 PNG。它的每一部分都在起作用,而几乎所有"这段 Base64 解不开"的上报都可以追到三个地方之一:头写坏了、载荷被损坏了,或者这些字节本来就不是图片。这是三个不同的 bug,对应三种不同的修法 —— 知道该看哪里之后,区分它们大约只要十秒。
语法比人们以为的短得多
这个格式由 RFC 2397 定义,全部内容就是:
data:[<mediatype>][;base64],<data>
只有逗号是必需的。 把逗号之前的部分全省掉,data:,Hello 也是合法的 data URI,媒体类型默认是 text/plain;charset=US-ASCII。;base64 是一个标志,不是 MIME 参数 —— 它告诉解析器"载荷是 base64 编码的",没有它,载荷会按百分号编码的文本解读。
这个区别解释了一个大家经常踩的坑。拿一份合法的图片载荷,但去掉 ;base64:
data:image/png,iVBORw0KGgoAAAANSUhEUg...
浏览器现在把 iVBORw0KGgo... 当字面文本读,什么也不显示。没有报错、没有警告 —— 图片就是不出现,因为在解析器看来,你要的是一个内容碰巧像乱码的文本文件。
语法的另一半是那个逗号。它把头和载荷分开,也是手工编辑过的字符串最常坏掉的地方。data:image/png;base64 后面没有逗号,就完全不是 data URI,它后面的字符串只是一个后缀。如果你把它交给一个"剥掉第一个逗号之前所有内容"的解码器,剥离会静默地什么都没做,于是你把头也一起解码了 —— 而头会在 : 和 / 上立刻失败,这两个字符都不在 Base64 字母表里。
头是一个声明,不是证据
头里的媒体类型是生产者写下的一句提示。它经常是错的,而浏览器在行内渲染图片时从不检查它,因为浏览器相信你给的 data: 类型。这就产生了这个领域里最令人困惑的一类 bug:能"用"但是错的 data URI。
data:image/jpeg;base64,iVBORw0KGgoAAAANSUhEUg...
上面这条在多数浏览器里能渲染 —— 字节是 PNG,浏览器会嗅探并自愈 —— 但声明的类型是 image/jpeg。把它存成 .jpg 文件,你有一半的图片工具会拒绝打开;而一条相信这个头的构建流水线,会在下游把它错标到所有地方。
稳妥的做法是读签名(最开始那几个字节),把头当成待验证的声明而不是事实。PNG 永远以 89 50 4E 47 0D 0A 1A 0A 开头,JPEG 是 FF D8 FF,GIF 是 47 49 46 38,WebP 是 52 49 46 46,并在偏移 8 处跟着 57 45 42 50。解码器真正使用的就是这些字节。当声明的类型与真实字节不一致时,Base64 转图片 会同时告诉你两者,并按字节渲染 —— 这正是它存在的意义。
"解不开"背后的三种 bug
1. 载荷已经不是 Base64 了。 把字符串从日志文件里复制出来,你可能把头一起带上、可能丢了填充、也可能把本该是 = 的地方粘成了 URL 编码的 %3D。只接受 64 字符字母表的解码器会在第一个非法字符处停下,客气的做法是告诉你位置:第几行、第几列、哪个码点。如果它只说"输入无效",你就只能猜了。裸字符串解码器 就是为这种情况准备的 —— 它接受完全没有前缀的载荷。
2. 载荷被以改变长度的方式损坏了。 Base64 把 3 字节编成 4 个字符,所以合法字符串的长度应该能被 4 整除 —— 或者余 2、余 3,由 = 填充补足。余 1 对一个完整字符串来说是不可能的。它意味着有字符丢失,几乎总是因为日志把某行截断了、或者复制提前中止了。丢掉的字节无法靠猜找回来,而一个"顺手帮你补上填充"的解码器,交给你的是坏掉的图片。
3. 它完美解码了 —— 只是不是图片。 这是人们最不期待的一种。解码 200 KB 完美的 Base64,你得到的可能是 gzip 流、PDF、ZIP 包,或者一段本来就是文本的十六进制字符串。线索在最开始的字节里:1F 8B 是 gzip,25 50 44 46 是 PDF,50 4B 03 04 是 ZIP 条目,而只由 0-9a-f 组成的载荷是 hex,不是"看起来奇怪的 Base64"。把 gzip 过的 JSON 灌进 Base64 编码器,是在你以为会看到照片的地方最常见的东西之一。
十秒钟,自己能做的诊断
有浏览器控制台或终端就行,三步可以把几种情况分开。第一,这个字符串的形状合法吗?
const s = 'data:image/png;base64,iVBORw0KGgo...';
const payload = s.includes(',') ? s.slice(s.indexOf(',') + 1) : s;
console.log('有前缀:', s !== payload);
console.log('长度 % 4:', payload.length % 4); // 1 意味着被截断
console.log('非法字符:', (payload.match(/[^A-Za-z0-9+/=]/g) || []).join(''));
第二,它到底能不能解?在浏览器里,atob 遇到字母表之外的任何字符都会抛错 —— 也就是说它比 Node 的 Buffer.from 更严格,这一点值得记住:
try {
const bytes = Uint8Array.from(atob(payload), (c) => c.charCodeAt(0));
console.log('解出字节:', bytes.length, '前 8 个:', [...bytes.slice(0, 8)].map((b) => b.toString(16).padStart(2, '0')).join(' '));
} catch (e) {
console.log('atob 拒绝了它:', e.name);
}
第三,这些起始字节是不是已知图片格式?89 50 是 PNG,ff d8 是 JPEG,47 49 是 GIF,52 49 46 是 WebP。如果开头是 1f 8b 或 50 4b,你手里是压缩包,不是图片。
在终端里,同样三步各一行:
printf '%s' "$B64" | base64 -d | xxd | head -2
在每种语言里正确地解码
浏览器需要两步,因为 atob 返回的是二进制字符串而不是字节 —— 把这些字符直接塞进 Blob,任何大于 0x7F 的字节都会被破坏:
const b64 = uri.slice(uri.indexOf(',') + 1);
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
const blob = new Blob([bytes], { type: 'image/png' });
img.src = URL.createObjectURL(blob);
Node 一步到位,但它会刻意忽略不认识的字符 —— 所以它会愉快地"解码"一个含空格或多余引号的字符串:
const buf = Buffer.from(b64, 'base64'); // 宽容:垃圾字符被跳过,不报错
console.log(buf.subarray(0, 8).toString('hex'));
Python 又是这一家里严格的那位,它会抛错而不是猜:
import base64
raw = base64.b64decode(payload, validate=True) # 输入有问题时抛 binascii.Error
open('out.png', 'wb').write(raw)
如果你希望修好填充而不是拒绝输入,请显式补填充而不是关掉校验 —— payload + '=' * (-len(payload) % 4) —— 并且只在补完之后字节仍然像你预期的图片时才接受结果。
每周都会被问到的问题
data URI 是不是省掉了一次 HTTP 请求? 对图片本身,是的。但它现在成了包含它的那个文件的一部分。放在 CSS 里,意味着它随样式表一起阻塞渲染、不能被单独缓存、并且每次 CSS 变化都要被重新下载。对一个 2 KB 的图标这是划算的;对一张 300 KB 的照片不划算。
图片会变大吗? 总是会,大约三分之一,因为 Base64 把 3 字节装进 4 个字符。与文本不同,已经被压缩过的图片字节在 gzip 下不会变小,所以这份开销拿不回来 —— 具体数字见 为什么 Base64 会让页面大 33%。
data URI 能携带脚本吗? 放在 img 元素里不能:浏览器不会执行图片里的脚本,SVG 也一样。但把 SVG data URI 当作文档打开是另一回事,这也是你不该导航到它的原因。
前缀完全没有,字符串还能用吗? 通常能,这正是"看字节"的意义。把它交给 裸 Base64 解码器,格式就会从签名而不是标签读出来 —— 而签名是 data URI 里唯一不会撒谎的部分。