JavaScript/Browser 中的 Base64 解码:完整指南
它披着十几种不同的伪装登场:藏在 Authorization 头里的一个 JWT、JSON 响应里的一个 image/png 二进制块、握手日志里的一个 Sec-WebSocket-Accept 值、用 MIME 打包的邮件附件,还有你的后端客客气气塞进查询字符串里的一个值。字符串本身看起来永远一样:一长串字母、数字,偶尔夹杂 + 或 /,末尾也许还有一两个 =。如果本站首页已经教会你 Base64 是什么 - 四个可打印字符代表每三个字节,再用 = 填充给最后一组收尾 - 那么这篇文章讲的就是你真正要在代码里做的那部分:把这些字符变回字节,再把字节变回含义,全程只用浏览器已经自带的那些东西。
开始之前,先立两条快速的基本规则。第一,解码是缩水的那个方向:每读进四个字符,就出来三个字节,所以输出永远比输入占更少的内存。第二,解码出来的 Base64 字符串不会自动变成文本。它是字节,而这些字节可能是 UTF-8、Windows-1252、一个 PNG 头,或者一个加密签名。Base64 代码里最常见的那一个 bug,就是忘了自己手里拿的到底是哪一种,所以下面的章节都围绕这个问题来组织。
解码的三个层次
现代浏览器给了你三个原生层次,好消息是从来不需要任何包。每一层回答的是略有不同的问题,选对的那一个,能帮你省下大量到处复制粘贴的 Stack Overflow 片段:
| 层次 | 它吃什么 | 它递给你什么 | 性格 | 可用范围 |
|---|---|---|---|---|
atob() |
标准 Base64 字符串 | 一个"二进制字符串"(每个字符一个字节) | 非常宽容:跳过 ASCII 空白,接受缺失的填充 | 2000 年代起的每个浏览器,IE 10+,Node 16+ |
TextDecoder |
字节(Uint8Array) |
可读的 JavaScript 文本 | 可配置:charset 的 label,控制严格程度的 fatal 标志 |
Firefox 18、Chrome 38、Safari 10.1 及以上(IE 从来没有) |
Uint8Array.fromBase64() |
Base64 字符串加 options | 一个真正的 Uint8Array |
严格且带旋钮:alphabet 和末尾块的处理 | Baseline 2025:Chrome 140、Firefox 133、Safari 18.2、Node 25 |
整篇文章的骨架就来自那张表。atob() 是你到处都会遇到的主力,老代码里也有它。TextDecoder 是从字节到词语的桥。而 Uint8Array.fromBase64() 是 2025 年的升级:当你从头到尾只想要字节时,它把中间那一步整个跳过。
atob:快速、宽容,而且非常老
整个约定装得进一行:atob(encodedData)。它接收一个 Base64 编码的字符串,返回一个"二进制字符串":一个普通的 JavaScript 字符串,其中每个字符恰好装着一个解码后的字节,码点在 0 到 255 之间。这个返回类型很重要,因为它和可读文本不是一回事(下面会细说)。函数本身快到了极点,而且存在了非常非常久:Chrome 4、Firefox 1、Safari 3,还有 - 这是大多数人记得的那一个 - Internet Explorer 只从第 10 版开始才有,所以 2012 年之前写的代码里满是手搓的 Base64 查表。
让 atob() 用起来舒坦的,是它在放弃之前原谅了多少东西。WHATWG HTML 标准说解码之前要先忽略所有 ASCII 空白 - 空格、制表符、换行符、换页符、回车符 - 所以一个每 76 个字符就换行的 MIME 包裹字符串,不需要你做任何清理就能解码。缺失的填充同样会被原谅。但一旦它看到字母表之外的字符,或者一个永远不可能有效的长度,就会抛出一个名叫 InvalidCharacterError 的 DOMException。没有静默的垃圾,也没有部分结果。
下面是损害报告,逐行来看:
| 输入 | 结果 |
|---|---|
"SGVsbG8sIFdvcmxkIQ==" |
"Hello, World!" - 教科书案例 |
"aGVsbG8"(没有填充) |
"hello" - 缺失的 = 被原谅了 |
"SGVs\nbG8s\nIFdvcmxkIQ=="(换行包裹) |
"Hello, World!" - ASCII 空白先被跳过 |
""(空字符串) |
"" - 空输入是有效的,而且可以原样往返 |
"A"(一个剩下的字符) |
抛出 InvalidCharacterError - 一个字符什么都编码不了 |
"Zm9vYmFy!"(混入一个 !) |
抛出 InvalidCharacterError - 在字母表之外 |
"ZGFua29nYWk-"(混入 URL 安全字符) |
抛出 InvalidCharacterError - 两种字母表不能混用 |
"Zm9v===="(填充过多) |
抛出 InvalidCharacterError - 末尾最多两个 = |
一条实用备注:错误消息本身在不同引擎之间不一样(Firefox 会说 "字符串包含无效字符",Chrome 对非 Latin1 输入会说字符串 "包含 Latin1 范围之外的字符",对无效的 base64 会说 "没有正确编码"),所以要按异常名捕获,而不是按消息文本。
从原始字节到真正的文本
那个"二进制字符串"返回类型值得单独停下来讲,因为它是大多数解码混乱的源头。JavaScript 字符串是 UTF-16 的,所以 atob() 递给你的字符串,里面的字符是字节值,而不是可读的字形。如果你的载荷是文本 "hello 你好" 的 UTF-8 编码,直接打印结果会给你一堆乱码。解决办法是两步解码:先从 Base64 到字节,再从字节到文本。
第一步,Base64 到字节。这个小助手是经典配方,值得装进口袋,因为它是本文大多数例子里的承重件:
function base64ToBytes (base64) {
const binary = atob(base64);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i += 1) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
然后是用 TextDecoder 的字节到文本这一步。对 UTF-8(默认选项,也是 JSON、JWT 载荷和大多数 Web 数据的正确选择)来说,调用只有一行:
const bytes = base64ToBytes('aGVsbG8g5L2g5aW9');
const text = new TextDecoder('utf-8').decode(bytes);
console.log(text); // "hello 你好"
为什么要分两步?因为 atob() 完全不知道这些字节是用什么字符集产生的。它是一个纯粹的比特转换器。TextDecoder 才是把字节按某个字符集来解释的那个组件,而且它接受一个 label 来指定这项工作的字符集:utf-8、windows-1252、iso-8859-1、utf-16le,另外还有大约 220 个 label。从 1990 年代应用里出来的数据通常是 Windows-1252,而一个构造器参数就搞定了:
const decoder = new TextDecoder('windows-1252');
const text = decoder.decode(bytes); // 同样的字节,不同的解释
TextDecoder 的构造器还接受一个 fatal 标志,只要解码出来的文本会喂给什么重要的东西,就值得把它设成 true。默认情况下解码器是宽松的:无效的字节序列会被悄悄替换成 Unicode 替换字符 U+FFFD,而且永远不会告诉你。有了 fatal: true,同样的损坏会抛出 TypeError,而不是藏起来:
const strict = new TextDecoder('utf-8', { fatal: true });
try {
strict.decode(corruptedBytes);
} catch (error) {
console.log(error.name); // "TypeError"
}
这是那种在文档里看起来不起眼、到了生产环境却像一场数据事故的开关。如果你的输入来自用户或来自网络,就严格解码,并且有意识地处理错误。
URL 安全的输入需要绕个弯
Base64 有一个变体值得单独一节,因为它在真实世界里到处出现,而 atob() 读不了它。它就是 RFC 4648 第 5 节里 URL 和文件名安全的那套字母表,通常叫 base64url:同样的 64 个字符,只是 + 和 / 被换成了 - 和 _,而且 = 填充经常被丢掉,因为数据长度是隐含已知的。这个替换有具体的理由:在 URL 里,+ 表示空格,/ 开始一个路径段,所以标准字母表得逐个字符做百分号编码。Base64url 在查询字符串、路径段、片段和文件名里都能干净地旅行。
坑在于两套字母表不能互换,而 atob() 只说标准的那一套。给它一个 - 或 _,你得到的就是 InvalidCharacterError。你有两个干净的选择。
选择一,到处都能用:在调用 atob() 之前先转换字母表、恢复填充:
function fromUrlBase64 (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
const missing = (4 - (s.length % 4)) % 4;
return atob(s + '='.repeat(missing));
}
console.log(fromUrlBase64('aGVsbG8')); // "hello"
(4 - (s.length % 4)) % 4 这个表达式就是整个技巧:它算出一个长度如此、填充完善的字符串需要几个 =,从零到二。
选择二,在 2025 年以后的浏览器里:新的原生解码器把字母表作为一个选项,所以完全不需要做字符串手术:
const bytes = Uint8Array.fromBase64('P3-0', { alphabet: 'base64url' });
console.log(Array.from(bytes).join(', ')); // "63, 127, 180"
两条规则让你免于惹麻烦。永远不要在单个值里混用字母表 - 一个同时看到 + 和 - 的解码器,没有任何办法知道自己在读哪一家,而符合规范的行为就是失败。还要和线的另一边就填充是否存在达成约定:丢掉填充对 base64url 来说是合法的,所以接收方必须对两种形状都做好准备。atob() 已经准备好了;下面的原生选项则给你一个可以调节的旋钮。
2025 年的捷径:Uint8Array.fromBase64
回头看看 base64ToBytes 助手,你会发现它干了两件事:解码 Base64,然后在 JavaScript 里把字符一个一个拷贝进字节数组。那个拷贝循环就是又慢又本可避免的部分,而这正是新的 ECMAScript 方法删掉的东西。Uint8Array.fromBase64(string, options) 直接从编码字符串到字节数组,它随 Chrome 140、Edge 140、Firefox 133、Safari 18.2、Node 25 和 Deno 2.5 一起发布 - 是第一个落地的同类 JavaScript 平台特性,在浏览器厂商的 Baseline 计划里被标记为 Baseline Newly available。
options 对象有两个旋钮。第一个是 alphabet:"base64"(默认)或 "base64url"。第二个是 lastChunkHandling,它控制最后一组不完整的字符会发生什么:
| 模式 | 最后一个块的处理规则 |
|---|---|
"loose"(默认) |
两三个字符,或带填充的四个;多余的溢出比特被忽略 |
"strict" |
恰好四个字符(只在长度要求的地方带填充),而且溢出比特必须全为零 |
"stop-before-partial" |
只解码完整的四字符组;不完整的尾部保持未读 |
和 atob() 一样,这个方法忽略输入里的 ASCII 空白,所以换行包裹没问题。和 atob() 不同的是,它对其他一切都很讲究:选定的字母表之外的字符,或者违反所选模式的最后一个块,会抛出 SyntaxError;传入不是字符串的东西会抛出 TypeError。下面看看 strict 模式的工作状态,拒绝一个缺失填充的块:
const ok = Uint8Array.fromBase64('SGVsbG8=', { lastChunkHandling: 'strict' });
try {
Uint8Array.fromBase64('VR', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError"
}
性能是偏爱它的另一个理由。在作者机器上较新的 Firefox 里,解码一个 10 兆字节的载荷,用 fromBase64 只需个位数毫秒,而经典的 atob 加逐字符字节映射要慢大约二十倍,因为慢的部分是 JavaScript 层的循环,不是 Base64 的数学。如果你的数据是字节,就干脆把字符串这一步整个跳过。
对较老的浏览器,情况很简单:保留上面那个 base64ToBytes 助手,或者如果你想到处都写新风格的代码,就引入一个小 polyfill(core-js 和 es-shims 项目的 es-arraybuffer-base64 包都为 fromBase64 带了一个)。这个 API 是稳定的 - 它现在已经写进了 ECMAScript 规范 - 所以你按它写的任何东西都不会被弃用。
读取一个 JWT
应用日志里最常见的 "神秘字符串" 是 JSON Web Token:三个用点号分隔的段,header.payload.signature,其中前两个是 base64url 编码的 JSON 对象。解码一个只需五行代码,也是给前面所有内容的完美热身:
function jwtSegmentToBytes (segment) {
let s = segment.replace(/-/g, '+').replace(/_/g, '/');
s += '='.repeat((4 - (s.length % 4)) % 4);
return base64ToBytes(s);
}
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c';
const [header64, payload64] = token.split('.');
const payload = JSON.parse(new TextDecoder().decode(jwtSegmentToBytes(payload64)));
console.log(payload.name); // "John Doe"
接下来是初学者跳过、生产系统却会用惨痛代价学到的那部分:载荷的可解码性并不是对它的验证。任何人都可以写一个装着任何想装内容的 JWT;把载荷和密钥绑在一起的是签名段。在浏览器里验证 HS256 令牌用的是 Web Crypto API,它需要签名以字节形式提供 - 这又是段到字节助手值回票价的一个理由:
const encoder = new TextEncoder();
const key = await crypto.subtle.importKey(
'raw',
encoder.encode('shared-secret'),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['verify']
);
const [h, p, sig64] = token.split('.');
const valid = await crypto.subtle.verify(
'HMAC',
key,
jwtSegmentToBytes(sig64),
encoder.encode(h + '.' + p)
);
console.log(valid); // 只有当签名与密钥匹配时才为 true
有三个坑值得点名。第一,验证之前先检查头:一个声称 alg: "none" 的令牌是让你在没有签名的情况下信任载荷,而天真的代码确实被骗着这么干过。第二,时间声明 - exp、nbf、iat - 要在验证之后才去遵守,而不是之前。第三,经典的密钥混淆攻击:一个为 RS256 配置、却也接受 HS256 的服务器,会让攻击者拿公钥(公钥本来就是公开的)当 HMAC 密钥来给令牌签名。一句话:随便解码,什么都不信,一切都要验证。
打开 Data URL
data URL 把一整个文件嵌在 URL 里面:data:、一个可选的媒体类型、一个可选的 ;base64 标志、一个逗号,然后是载荷。文本载荷做百分号编码,二进制载荷用 Base64,浏览器渲染它们时不需要任何 HTTP 请求 - 没有 fetch、没有服务器往返、也没有任何东西需要缓存。浏览器把每个 data URL 当作一个唯一的、不透明的源,这也是它们成为夹带内容的热门载体的原因:一个在 iframe 里打开的 data:text/html 文档会运行它的脚本,而一个严格的内容安全策略可以把 data URL 整个拦下来。如果你开始把这些东西交给用户可控的标记,就把你的 CSP 放在心上。
解码一个 data URL 基本就是字符串手术,然后是和前面一样的字节流水线:
const url = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADgQFY/fWoOgAAAABJRU5ErkJggg==';
const comma = url.indexOf(',');
const meta = url.slice(5, comma); // "image/png;base64"
const bytes = base64ToBytes(url.slice(comma + 1));
const blob = new Blob([bytes], { type: 'image/png' });
const objectUrl = URL.createObjectURL(blob);
meta 那一段告诉你媒体类型(这里是 image/png,;base64 标记确认了载荷是 Base64)。一旦载荷变成了 Blob,一切常规手段都适用:给 <img> 用 object URL、下载链接、或者 POST 到服务器。data URL 路线唯一的真实代价是尺寸 - 载荷比原文件大约大 33% - 而 URL 里塞一张大图还会挤压页面的字符串长度上限,这又给 object URL 投了一票:当文件根本不需要离开浏览器的时候,就用它。
解码以文本形式到达的文件
文件到达浏览器有两条路。现代那条路是原始字节:一个你读成 ArrayBuffer 的 fetch,或者一个你用 file.arrayBuffer() 读取的文件选择器 File。如果你走的是这条路,恭喜 - 全程压根没有 Base64 的事,而且你应该留在路上,因为字节随身携带毫不费钱,而 Base64 得为这份特权额外支付三分之一的带宽和内存。另一条路是通道纯文本的时候:一个返回 {"attachment": "data:application/pdf;base64,JVBERi..."} 的 JSON API、一个邮件附件、一个配置字符串、数据库列里的一个值。这时 Base64 就是协议本身,你的工作只是把字节取出来:
async function loadRemoteBytes (fileUrl) {
const response = await fetch(fileUrl);
return new Uint8Array(await response.arrayBuffer());
}
const record = JSON.parse(await (await fetch('/api/record/42')).text());
const pdfBytes = base64ToBytes(record.attachment.split(',')[1]);
对那段代码有三个备注。在第一个逗号处切开,就足以剥掉 data URL 的头(媒体类型里不可能含逗号,所以第一个逗号永远是分隔符)。而且如果值是纯 Base64、没有 data URL 前缀,就直接跳过切分。最后,那个光秃秃的 await 是顶层 await,而浏览器只允许它出现在模块里,所以这段代码需要 <script type="module"> 标签,或者给那两行套一个 async 包装。邮件的 MIME 部分是同一个故事多加几步:附件体是每 76 个字符换行的 Base64,但因为 atob() 会跳过空白,你可以把换行包裹的文本原封不动地(就像它出现在原始报文里那样)交给它 - 不需要解包。这一个行为悄悄地省下了大量正则表达式。
验证 WebSocket 握手
在浏览器里做解码,更有魅力的用途之一就是检查 WebSocket 握手本身。RFC 6455 要求客户端发送一个 Sec-WebSocket-Key 头(16 个随机字节,Base64 编码),服务器则用 Sec-WebSocket-Accept 应答:密钥与一个固定魔法 GUID 拼接后的 SHA-1 哈希,再 Base64 编码。如果值对不上,握手失败,连接不会被升级。这一整套仪式的全部要点在于:一个只会说 HTTP 的服务器不可能意外地完成它 - 魔法 GUID 的存在就是为了让这个计算看起来故意过度复杂。而且因为浏览器既有哈希又有编码,你可以自己算出期望的答案,这让调试代理和网关变成了一行代码的事:
const MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
async function expectedAccept (clientKey) {
const digest = await crypto.subtle.digest(
'SHA-1',
new TextEncoder().encode(clientKey + MAGIC)
);
return btoa(String.fromCharCode(...new Uint8Array(digest)));
}
const accept = await expectedAccept('dGhlIHNhbXBsZSBub25jZQ==');
console.log(accept); // "s3pPLMBiTxaQ9kYGzzhZRbK+xOo="
最后那行不是巧合 - 它就是 RFC 里的原始示例,逐字节复现。当你的网关用别的东西应答时,你现在能精确知道等式哪一边在说谎。
HTTP 头和查询字符串
Base64 是 HTTP 头的宠儿,因为头必须是 ASCII,而最著名的例子就是 Basic 认证:Authorization: Basic 后面跟着 username:password 的 Base64 编码。读取这样一个头(比如展示请求携带了什么的时候)就是一次切分加一次解码:
const header = 'Basic YWxpY2U6c2VjcmV0MTIz';
const [user, ...rest] = atob(header.slice(6)).split(':');
const password = rest.join(':');
console.log(user, password); // "alice secret123"
展开再重新拼接的模式,处理了密码里含冒号这种别扭但合法的情况,因为切分点永远是用户名之后的第一个冒号。同样的模式适用于任何头里夹带结构化值的地方:Proxy-Authorization、某些厂商专属的头,以及偶尔出现的 cookie。在查询字符串和深度链接里,当应用想在不经过服务器的情况下共享状态时,Base64 就出现了:一个 OAuth state 值、一个还原的搜索表单、一个 "接着上次继续" 的标记。防御性地解码 - 包在 try/catch 里,因为这个值跨过了网络边界,它身上可能发生过任何事情 - 并且把你得到的东西当作不受信任的输入,句号。
这就把我们带到了应该钉在每台终端上方的一句话:Base64 不是加密。在任何实际意义上它甚至都不是混淆,因为所谓的 "解密" 只是一个地球上每种语言都实现了的函数调用。如果一个值需要保密,先做 Base64 编码只会让它更不安全,而不是更安全 - 它制造了隐私的错觉,并且给任何想要原文的人恰好增加了一步微不足道的工序。
URL 和存储里的状态
同样的逻辑延伸到任何必须挺过页面刷新或分享链接的东西。惯常的嫌疑人:携带结构化或二进制数据的 localStorage 和 sessionStorage 值、单页应用路由状态用的 URL hash 片段,还有构建工具嵌进页面里的配置块。存储这个故事值得一看具体例子,因为读取端和你会想记住的写入端是成对出现的:
const raw = localStorage.getItem('profile');
const profile = JSON.parse(new TextDecoder().decode(base64ToBytes(raw)));
有三件事要放在心上。第一,预算:浏览器给每个源大约 5 兆字节的 localStorage,而你存的 Base64 字符串比原始数据多吃大约 33%,所以一个 3.5 兆字节的文件会悄悄变成 4.6 兆字节的存储 - 而且这个字符串在内存里以 UTF-16 存在,页面打开期间占位再次翻倍。第二,一致性:两边都用同一个字符集编码和解码,否则你会存下完好的字节、读出乱码。第三,分享链接:如果状态跟着 URL 走,就用 URL 安全的字母表,让值挺过复制粘贴,并且保持简短,因为超过一两千字符的 URL 长度会让老客户端和日志工具开始紧张。
当数据分片到达时
有时候 Base64 不是一个字符串整块到达的:WebSocket 消息边界把它拦腰切断,服务器推送事件流一滴滴地渗进来,分块上传一次喂几个千字节。你不能对一个片段调用 atob(),因为 Base64 的组是以四个字符块表示的三字节单位,在组中间切一刀会留下一个悬空的不完整片段。老派的修法是缓冲字符直到凑成四的倍数,再分片解码缓冲区。2025 年的 API 让这件事变得干净:Uint8Array.prototype.setFromBase64(string, options) 把解码出的字节写进一个已存在的数组,并返回一个带两个数字的对象,read(它消费了多少字符)和 written(它产出了多少字节)。配上 lastChunkHandling: "stop-before-partial",它只解码完整的组,把不完整的尾部留作未读,而这正是流解码器想要的行为:
const parts = [];
let carry = '';
for (const piece of incomingPieces) {
let pending = carry + piece;
for (;;) {
const room = new Uint8Array(8);
const result = room.setFromBase64(pending, {
lastChunkHandling: 'stop-before-partial'
});
parts.push(room.subarray(0, result.written));
pending = pending.slice(result.read);
if (result.read === 0) {
carry = pending;
break;
}
}
}
const size = parts.reduce((sum, part) => sum + part.length, 0);
const bytes = new Uint8Array(size);
let at = 0;
for (const part of parts) {
bytes.set(part, at);
at += part.length;
}
const text = new TextDecoder().decode(bytes);
慢慢读那个内层循环,因为它就是整个模式:喂进带过来的余数加上新片段,让解码器吃掉能装下的完整组,通过切掉 result.read 个字符记住剩下了多少,而当没有任何完整组剩下时(result.read === 0),把余数存为新的 carry,等待下一个片段。Uint8Array(8) 只是一个临时缓冲区 - 一组四个字符最多产出三个字节,所以八很宽裕。到最后,carry 里放着流从未完成的那部分,它要么是你的错误信号,要么是你 "连接干净结束" 的检查。
何时不该解码 Base64
一份有用的参考资料会教你什么时候该放下这件工具。如果你控制着通道两端,那就改拿原始字节:下载用带 response.arrayBuffer() 的 fetch,选择器文件用 file.arrayBuffer(),WebSocket 用 ArrayBuffer 载荷,上传用多部分 FormData。这些全都不碰 Base64,你能以全速拿到数据,既没有尺寸税,也没有字符串驻留内存的占位。Base64 恰恰在通道纯文本的时候挣回身价:JSON 体、查询字符串、邮件、存储、遗留 API,以及任何契约写着 "要 ASCII,否则免谈" 的东西。只要一个字节就够用的时刻到了,Base64 字符串就在为 "可打印" 的特权支付 33% 的附加费,而这笔附加费是按带宽、内存和 CPU 收取的 - 三张账单你全都可以避免。
常见的解码陷阱
讲完了所有顺路,下面列一下它咬人的各种方式,大致按你会遇到的顺序排列:
- 把
atob()的结果当作文本。它是一个二进制字符串。经过TextDecoder它才变成文本;直接打印,它就变成乱码。单是这一个混淆就导致了大多数 "Base64 不工作" 的报告。 - 指望 Unicode 自然而然就能工作。"你好" 的字节解码起来毫无怨言,但在解码器告诉你是 UTF-8 之前,它们仍然是字节。两边都用同一个字符集编码和解码。
- 把 base64url 喂给
atob()。哪怕一个-或_都会抛错。先转换字母表,或者用带正确选项的fromBase64。 - 相信任何长字符串都是 Base64。一个有效的带填充 Base64 字符串的长度是四的倍数(无填充的 base64url 可以以余 2 或余 3 收尾),而且至多使用一种字母表。长度模四余一是立即失败 - 在为一个 try/catch 花力气之前先检查它。
- 信任你没约定过的填充。有些系统剥掉
=,有些保留,还有些把它加在换行包裹字符串的中间,而那里本不该有它。和发送方约定好,再决定是宽松(atob)还是严格(fromBase64)。 - 宽松解码器的静默损坏。默认的
TextDecoder把无效字节替换成 U+FFFD,而且什么都不说。数据重要的时候,设fatal: true。 - 以为 Base64 能保护任何东西。它不能。它是一种序列化格式,距离纯文本只有一个函数调用,而 "我们 Base64 它所以用户读不了" 是一种安全姿态,不是一道控制措施。
- 忘了内存。一兆字节解码出的二进制字符串,作为 UTF-16 字符串占两兆字节,而同样数据的
Uint8Array只占一兆。对大载荷,直接走fromBase64。 - 每次渲染都重新解码。解码几兆字节很快,但不是免费 - 也不是每帧做一次的事。解码一次,缓存字节,从缓存渲染。
性能备注
简短版本:原生解码器很快,老代码里慢的部分通常是围绕它们的 JavaScript,而不是 Base64 本身。在真正要紧的尺寸上,画面是一样的:一个 10 兆字节的载荷用 Uint8Array.fromBase64 解码只需个位数毫秒;atob 单独用会慢几倍,而经典的跟进循环 - 把字符映射进字节数组 - 对同样的输入大约比 fromBase64 慢二十倍,因为它在主线程上跑了一千三百万次属性写入。实际后果:受众有 fromBase64 的地方就优先用它;没有的地方就保留 atob 助手;永远不要用循环拼接字符串来构造字节数组;而如果你必须处理超大载荷,考虑把解码后的 Uint8Array 交给一个 Web Worker - 字节无需拷贝就能转移,主线程也得以空闲,让 UI 保持每秒 60 帧。还要记住计算的方向:解码是缩水的,所以解码后的缓冲区永远比它来源的字符串占更少内存。解码永远不会让你内存耗尽;只有把字符串和字节都留得比必要时间更长,才会让你内存耗尽。
浏览器解码的简短历史
Base64 比现代 Web 的大部分内容都老,但浏览器的解码器有一段值得了解的故事,因为它解释了为什么生态里满是遗迹。atob 和它的兄弟 btoa 早于现在覆盖它们的那份规范:WHATWG HTML 标准直到 2011 年 2 月才定义它们,那时它们在浏览器里长期存在的行为才被逆向工程进了标准。引擎们早就把它们发货了:Firefox 从 2004 年的 1 版开始,Safari 3,Chrome 4。Internet Explorer 则完全跳过了它们,直到 2012 年的 IE 10 才有,所以 2012 年之前的 JavaScript 是一座手搓 Base64 的博物馆 - 查表、String.fromCharCode 体操,还有那句臭名昭著的 Unicode 咒语 unescape(encodeURIComponent()),一对在语言里已被弃用、却凭借纯粹的惯性在浏览器里又活了十年的函数。然后是字符集层:来自 Encoding 标准的 TextEncoder 和 TextDecoder 在 2013 到 2017 年间到达(Firefox 18、Chrome 38、Safari 10.1,任何 IE 里都没有),终于给了平台一个有原则的把字节变成词语的方式。Node.js 直到 2021 年的 16 版之前从来没有任何 atob 或 btoa 全局函数,它的前半生靠 Buffer 和一对小小的 npm shim 度过。然后闭环完成了:Firefox 133(2024 年 11 月)和 Safari 18.2(2024 年 12 月)率先发货 Uint8Array.fromBase64、toBase64 和朋友们,2025 年下半年补齐了全套,Chrome 140(9 月)和 Node 25(10 月中旬)落地,Baseline 计划把它们标记为 Newly available - 这是语言本身(而不是 Web 平台)第一次内置 Base64。一种几十岁高龄的格式刚刚变成了语言标准库的特性,未来十年的代码终于可以不用再到处拷贝助手函数了。
趣味事实
- 现存最快的 "这到底是 Base64 吗?" 测试就是
string.length % 4 === 0。每个有效的带填充 Base64 字符串都能通过;其他一切都是陌生人。 atob('')返回''。空字符串是唯一没有任何字节的输入,而且它能干净地通过整条流水线往返 - 永远不需要特殊情况处理。- WebSocket 魔法 GUID
258EAFA5-E914-47DA-95CA-C5AB0DC85B11是一个烤进 RFC 的固定值,被选出来就是为了让一台只会 HTTP 的服务器永远不可能意外地完成握手。它是协议工程里最著名、却从来没人会生成的那个常量。 - Chrome 和 Firefox 对同样的失败抛出同样的异常,但消息不同。要按
error.name捕获,而不是按消息字符串,否则你的错误处理会带着一股浏览器口音。 - 一兆字节的二进制字符串在内存里重两兆字节,因为 JavaScript 字符串是 UTF-16:每个解码出的字节都带着一字节用不上的余量同行。
Uint8Array没有这种税。 - "Data URI" 是个退休的名字。WHATWG 把它改名成 "data URL",作为那场宏大的 URI 到 URL 协调运动的一部分,所以你会在规范、帖子和包名里遇到两种拼法。
- RFC 4648 附带一张测试向量表 - "f"、"fo"、"foo"、"foob"、"fooba"、"foobar" 和朋友们,每个都带着它已知的编码 - 解码器作者们对照它检查了二十年。如果你的解码器通过了那些行,它几乎可以肯定是正确的。
- 计算史上产量最高的 Base64 字符串几乎可以肯定是
aGVsbG8=,即 "hello" 的编码。地球上每个 "快速上手" 教程、测试套件和 Stack Overflow 答案都投了自己的一票。
收尾
所以,在浏览器里做解码的整套手艺装得进一页:atob() 负责快速、宽容、万能的解码;TextDecoder 负责把字节变成你真正想要的词语,数据要紧时配上 fatal: true;Uint8Array.fromBase64 负责那条现代的、严格的、快的路径,把字符串整个跳过。中间这些变体各有名字和规则:base64url 用于一切在 URL 里旅行的东西,填充可以有也可以没有,空白则被老解码器悄悄吃掉。而在这一切底下,是两种态度:字节不是文本,文本也不是秘密。有目的地解码,信任之前先验证,当通道允许时,跳过 Base64,直接拿字节。
旅程的另一半 - 拿着你的字节和文本,把它们变成启动这一切的那个可打印字符串 - 在下文链接的 JavaScript 中 Base64 编码伴读指南里有详细覆盖。
最后更新: 2026-09-08