精确的体积数学:Base64 是压缩也省不回来的 33% 税
Base64 把每 3 个输入字节映射为 4 个输出字符,因此编码后长度精确等于 4 × ceil(n / 3),末尾最多再加 2 个 = 填充。首先就是固定的 +33.3%:30 KB 的 PNG 变成约 40 KB 的文本,750 KB 的照片变成整整 1 MB。字符表是 A–Z、a–z、0–9、+ 和 /(RFC 4648);注意 + 和 / 在 URL 和查询字符串里不安全 — 这正是 base64url 变体把它们换成 - 和 _ 的原因,把标准字符表的字符串直接粘进 URL 参数是经典的数据损坏来源。
这笔税比看上去更狠,因为传输压缩几乎帮不上忙。PNG、JPEG、WebP 的内容本身已经压缩过,字节在统计上接近随机;经过 Base64 之后仍然随机,只是被摊到了 64 个字符的字母表上。作用于外层 HTML 的 gzip 或 brotli 通常只能从编码后的图片里省回 2–5% — 对比一下普通 HTML/CSS/JavaScript 文本常见的 70–80% 压缩率。换句话说,内联进压缩页面的 100 KB 图片,在网络上仍然要花掉大约 128 KB。
唯一可以忽略的开销是前缀:data:image/png;base64, 只增加约 22 个字符。真正要命的是那 33%,而且每一次包含这段标记的页面浏览都要重新支付这笔成本。
const encodedSize = (bytes) => 4 * Math.ceil(bytes / 3);
encodedSize(30 * 1024); // 30 KB PNG -> 40 960 chars (+33.3%)
encodedSize(750 * 1024); // 750 KB JPEG -> ~1 MB of text
// gzip/brotli barely help: compressed image bytes remain
// statistically random after Base64 — only ~2–5% comes back内联的代价:缓存、懒加载与 Preload Scanner
托管的图片文件独立于 HTML 缓存:改一句文案,回访用户只需重新下载那句话,而不是重新下载 logo。内联的图片则与文档焊死在一起,随每一次 HTML 响应传输,并且每次部署都会失效。跨页面时损失还会叠加 — 在十个模板里重复的 data URL 会被下载十次,而带长 Cache-Control max-age 的托管文件只需获取一次。
懒加载也会悄悄失效。loading="lazy" 推迟的是图片的网络请求,但内联图片根本没有可推迟的请求 — 它的字节就嵌在文档里,会先于其后的所有内容传输,实际上让首屏之下(below-the-fold)的图片变成了阻塞渲染的负载。你还失去了浏览器的 preload scanner(它在解析 HTML 时发现 <img> URL 并并行抓取),以及 CDN 侧的图片优化:格式协商(按 Accept 头返回 AVIF/WebP)、缩放、质量调节,这些都需要一个可寻址的 URL。
内联的历史理由 — 省一个 HTTP 请求 — 随 HTTP/1.1 一起死掉了。在 HTTP/2 和 HTTP/3 上,热连接中的请求是多路复用的,成本只是几十字节的帧开销,而不是一次往返。剩下的只有一个狭窄的甜蜜点:首屏渲染就要用到的、大约 2–5 KB 以下的资源 — 比如一两个 UI 图标,或者在真实照片加载期间占位的模糊 LQIP 占位图。
data: URL 剖析(RFC 2397) — 以及 SVG 为什么应该跳过 Base64
RFC 2397 的语法是 data:[mediatype][;base64],<data>。除了逗号和数据本体,其余部分都可省略,而默认值正是陷阱:省略 media type 时浏览器会按 text/plain;charset=US-ASCII 处理,于是一段完全有效的 Base64 PNG 被当成纯文本解释,渲染成裂图图标。永远把 data:image/png;base64, 写全 — 本工具会自动帮你加上。
SVG 需要特殊对待。SVG 是文本,Base64 对它来说是错误的容器:既要付 33% 的膨胀成本,又摧毁了 gzip 在页面内寻找重复 XML 模式的能力。更好的编码是纯 URL 编码的 data URL:data:image/svg+xml,%3Csvg ... 只需要对会破坏 URL 或 CSS 解析的字符做百分号编码 — <、>、# 和引号;字母和大多数标点都可以原样保留。结果通常在压缩前就比 Base64 版本小 15–30%,压缩后差距更是悬殊,因为 SVG 源码仍是可压缩的文本。# 的规则加倍重要:fill="#f60" 含有裸的 #,不编码的话 URL 会被当作 fragment 截断 — 要编码成 %23。
还有一个字符集陷阱:如果 SVG 里包含非 ASCII 文本(中文标签、箭头、emoji),朴素的 URL 编码 data URL 可能出现乱码。要么把这些字节按 UTF-8 做百分号编码,要么对这一个文件退回 Base64。
/* Raster: Base64 is the only option */
.logo {
background-image: url("data:image/png;base64,iVBORw0KGgoAAAANS...");
}
/* SVG: URL-encode instead — smaller and gzip-friendly */
.icon {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 2 2 22h20z' fill='%23f60'/%3E%3C/svg%3E");
}
/* Missing media type = text/plain assumed = broken image */
/* BAD: data:;base64,iVBORw0KGgo... */data: URL 悄悄失效的地方:CSP、Gmail 与导航拦截
Content Security Policy 是第一个埋伏。data: 协议不包含在 'self' 里,所以 img-src 'self' 这样的策略会拦截页面上所有内联图片 — 往往连裂图图标都没有,只剩一片空白,违规记录埋在控制台深处。如果要内联图片,策略里必须显式写 img-src 'self' data:,而安全评审自然会追问原因 — 因为 script-src 或 object-src 里的 data: 是货真价实的 XSS 向量。只对图片开放即可。
邮件是第二个。Gmail 会把 <img src> 里的 data: URI 整个剥掉,其他多数客户端(桌面版 Outlook、Yahoo)行为相同 — 这和人们的预期恰好相反,毕竟"不发外部请求"听起来很适合邮件。HTML 邮件里的图标要么托管在 HTTPS URL 上,要么作为 CID 引用的内联附件。请在真实客户端里测试;ESP 的预览不代表事实。
浏览器还收紧了导航:自 2017 年起(Chrome 60、Firefox 59),指向 data: URL 的顶级导航会被拦截,因为整页 data: 文档曾是流行的钓鱼外壳。在 <img>、CSS、fetch() 里渲染仍然一切正常。至于长度:现代 Chrome 和 Firefox 在 src 属性和样式表里处理几 MB 的 data URL 毫无怨言,但超过 2 MB 左右的 URL 在 devtools 里复制、diff、检查都会变成折磨 — 而老旧的 IE8 把整个 URL 限制在 32 KB,这就是老教程至今还引用那个数字的原因。
内存成本、atob() 的 Unicode 陷阱,以及把决定交给打包器
内联图片要在 RAM 里付三次钱。浏览器同时持有文档里的 Base64 字符串、解码后的文件字节,以及 — 图片一旦显示 — 宽 × 高 × 4 字节的解码位图。最后一项碾压前两项,而且和文件大小完全无关:一张 1000 × 1000 的 PNG,不管文件是 20 KB 还是 2 MB,位图都要占约 4 MB。烤进 JavaScript bundle 的 Base64 是最糟的变种,因为字符串还要经过 JS 解析并常驻堆内存。
如果你用代码而不是本工具来编码,当心那个经典陷阱:atob() 和 btoa() 只支持 Latin-1 字符串。btoa('中文') 会抛出 InvalidCharacterError,而 Stack Overflow 上所有基于 unescape(encodeURIComponent(...)) 的变通方案都已 deprecated。处理文件时,FileReader.readAsDataURL(本页面用的就是它)是正解,根本不会碰到这个问题;处理字节数组时,新近标准化的 Uint8Array.prototype.toBase64() 和 Uint8Array.fromBase64() 终于能正确处理二进制了。
决策规则可以浓缩为三个条件:只有当文件小于约 5 KB、首屏渲染就要用到、且不会跨页面复用时才内联。其余一律保持为托管文件。另外注意,你的打包器已经实现了这条规则:Vite 默认内联小于 4 KB 的 import 资源(build.assetsInlineLimit: 4096),webpack 5 的 asset modules 通过 parser.dataUrlCondition.maxSize 默认 8 KB。如果你在手工内联构建工具决定输出为文件的图片,通常是工具对。
// vite.config.js — Vite already makes the inline/file call
export default {
build: {
assetsInlineLimit: 4096 // bytes; imports under 4 KB become data: URLs
}
};
// webpack 5 equivalent (asset modules)
module.exports = {
module: {
rules: [{
test: /\.png$/i,
type: 'asset', // data URL under maxSize, separate file above
parser: { dataUrlCondition: { maxSize: 8 * 1024 } }
}]
}
};