JavaScript/Node.js 中的 Base64 解码:完整指南
你的应用程序收到一串 Base64。它可能是某个传入请求的 Authorization 头,JSON 载荷里的一个字段,藏在 data URL 里的一张图片,或者被粘贴进配置文件里的证书。它们都是同一件事:穿着 ASCII 外衣的原始字节。这篇文章讲的是如何在 JavaScript 和 Node.js 里把这层外衣脱掉,而且在中途一个字节都不丢。
先快速说两句这个格式本身:Base64 是一种文本编码,把每三个输入字节映射成四个可打印字符。本站首页已经详细解释了字母表、数学和填充,所以这里只留一句话。有一个推论值得收进口袋:编码后的数据比它承载的字节大约大 33%,也就是说解码是个缩水的过程,而这篇文章不会增加或减少任何保密性。你是在拆包,不是在拆封。
好消息是:你什么都不用安装。浏览器已经随附 atob() 两十年了,Node.js 有内置 base64 模式的 Buffer 类,现代运行时现在还带来了 Uint8Array.fromBase64(),一位来自 ES2026 规范的严格又可配置的新成员。讲究在于为任务挑选趁手的工具,并且确切知道每件工具会原谅什么,因为在服务器上你解码的是陌生人的数据,而出问题往往就出在"原谅"上。
挑选一个解码器
三个 API 覆盖了绝大多数解码工作。它们的脾气各不相同,而这份差异就是全部故事:
| 解码器 | 可用环境 | 脾气 |
|---|---|---|
Buffer.from(string, 'base64') |
Node.js(每个称得上重要的版本) | 宽松:跳过未知字符,在第一个 = 处停下,从不抛错 |
atob(string) |
所有浏览器,Node.js 16 及以上 | 严格:遇到坏输入抛出 InvalidCharacterError,跳过 ASCII 空白,原谅缺失的填充 |
Uint8Array.fromBase64(string) |
Chrome 140+、Firefox 133+、Safari 18.2+、Node.js 25+ | 可配置:字母表由你挑,最后一组必须多严格也由你定 |
三个 API 打开同一段经典载荷的方式一模一样:
// Node.js 的主力
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVsbG8gd29ybGQ=', 'base64').toString('utf8')); // "hello world"
// 老牌搭档(所有浏览器,Node.js 16+)
console.log(atob('aGVsbG8gd29ybGQ=')); // "hello world",以二进制字符串形式
// 现代的 ES2026 方法(Chrome 140+,Node.js 25+)
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8gd29ybGQ='))); // "hello world"
在依赖 atob() 之前提个醒:它返回字符串,但返回的是二进制字符串,一个每个字符都承载一个原始字节(码点从 0 到 255)的字符串。打印出来没问题。可如果把它存进 JSON、数据库或 Cookie,那些原始字节值就会跟着一起上路,所以解码后要立刻把它转成真正的字节或真正的文本。
宽松解码器,以及它吞掉的东西
Node 的 Buffer 是个宽容的读者,而这是一把双刃剑。对于走过坎坷路的数据,它棒极了:带着换行的 MIME 邮件、手工复制的字符串、夹着零星空格的日志输出。但对于不是你亲手产生的数据,它是危险的,因为它从不抱怨。下面就是真实发生的事情:
| 输入 | Buffer.from(input, 'base64') 的行为 |
|---|---|
'!!!' |
返回空 Buffer。所有垃圾都被跳过,什么都没解出来,也没有错误。 |
'aGVsbG8== garbage' |
返回 "hello"。第一个 = 结束了解码,其余部分被忽略。 |
'aG!VsbG8' |
返回 "hello"。感叹号被跳过,不算错误。 |
'aGVs=bG8' |
返回 "hel"。字符串中间的 = 让演出提前落幕。 |
'aGVsbG8====' |
返回 "hello"。末尾多余的填充被忽略。 |
'=aGVsbG8' |
返回空 Buffer。数据之前的填充毫无意义。 |
对付不可信输入的解药是校验器,而 Base64 的文法小到可以装进一条正则表达式:
const STRICT = /^([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==)$/;
function decodeStrict (base64) {
if (!STRICT.test(base64)) {
throw new TypeError('Not a valid base64 string');
}
return Buffer.from(base64, 'base64');
}
console.log(decodeStrict('aGVsbG8gd29ybGQ=').toString('utf8')); // "hello world"
try {
decodeStrict('aGVs!bG8');
} catch (error) {
console.log(error.message); // "Not a valid base64 string"
}
正则检查的是形状:成组的四个字符加上正确的填充。它有一条规则检查不了,就是 RFC 4648 的规范编码规则:最后一组中未使用的填充位必须是零。Uint8Array.fromBase64() 的严格模式会检查这一点,所以在 Node.js 25 或任何现代浏览器上,你可以完全跳过正则,让平台来做审计:
console.log(new TextDecoder().decode(Uint8Array.fromBase64('aGVsbG8'))); // "hello",宽松模式原谅缺失的填充
try {
Uint8Array.fromBase64('QQB=', { lastChunkHandling: 'strict' });
} catch (error) {
console.log(error.name); // "SyntaxError",填充位不是零
}
lastChunkHandling 选项有三个值得知道的设置。"loose"(默认)跳过空白、接受缺失的填充并忽略残留的填充位。"strict" 要求一个完整的、带填充的最后一组,且所有填充位都为零。而 "stop-before-partial" 只解码完整的四字符分组,把末尾的碎片留给你携带到下一轮,这正是让流式解码变得舒服的那块拼图,后文会看到。
从字节到文本:字符集的抉择
解码 Base64 递到你手上的是字节。只有当你选定一个字符集时,字节才变成文本,而这个选择权在你,通常依据是发送方承诺了什么。Node 的默认值正是你多数时候想要的:
const { Buffer } = require('node:buffer');
const bytes = Buffer.from('w6k=', 'base64'); // 两个字节 C3 A9
console.log(bytes.toString('utf8')); // "é",两个字节合成一个字符
console.log(bytes.toString('latin1')); // "é",同样的字节逐字节当字符读
UTF-8 有一个小刺:当字节序列不是合法的 UTF-8 时,Node 不会抛错。它会换上 Unicode 替换字符(U+FFFD,那个带问号的菱形)然后继续,这意味着一段损坏的载荷可以一路顺风穿过你的管道,直接开进你的数据库。平台真正的文本解码器 TextDecoder(Node.js 和所有浏览器里都是全局对象)有一个 fatal 选项,能把损坏变成一个你可以捕获的 TypeError:
const stray = new Uint8Array([0xe9]); // 一个孤零零的字节,不是合法 UTF-8
console.log(new TextDecoder().decode(stray)); // 替换字符,没有错误
try {
new TextDecoder('utf-8', { fatal: true }).decode(stray);
} catch (error) {
console.log(error.name); // "TypeError"
}
老系统从不消亡,而 TextDecoder 至今还认得它们。它接受 WHATWG 编码标准的完整标签表,所以来自 1990 年代 Windows 应用、日本大型机或老 FTP 镜像的 Base64 载荷,依然可以用 'windows-1250'、'shift_jis'、'euc-kr' 或 'gb18030' 这样的标签解码,而且全部不区分大小写。有一个标签值得提个醒,因为它确实烧掉过调试时间:规范把 'iso-8859-1'、'latin1' 甚至 'us-ascii' 都别名到了 Windows-1252 解码器上。字节 0x80 在真正的 Latin-1 里是个控制字符,出来却变成了欧元符号:
console.log(new TextDecoder('iso-8859-1').decode(new Uint8Array([0x80]))); // "€",不是你要求的那个 Latin-1
// 想要真正的逐字节 Latin-1 读取,走 Buffer 这边:
console.log(Buffer.from('gA==', 'base64').toString('latin1')); // 原始的 0x80 控制字符
如果你确实需要那种原始映射,Buffer 的 'latin1' 编码(它的遗留别名 'binary' 用 Node 文档的话说,是个非常误导性的名字)会把字节 N 映射到码点 N,没有 Windows 绕路。对于一切现代场景,UTF-8 加上 fatal: true 才是稳妥的组合。
撬开一个 JWT
JavaScript 服务解码的最常见的 Base64 载荷,是 JSON Web Token,也就是骑在互联网上一半请求的 Authorization 头里的那串 xxxxx.yyyyy.zzzzz。按照 RFC 7515,紧凑 JWS 由三个用点分隔的部分组成,前两个是用不带填充的 base64url 编码的 JSON 对象。在 Node.js 里读取它们毫无仪式可言,因为 base64url 模式是一等公民级别的编码:
const { Buffer } = require('node:buffer');
const token = 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjMiLCJuYW1lIjoiQWRhIn0.JMjpmDdNzQZpTuUO1H33GJsj7nWhBu-qxkPD0GL2uaA';
const [head, body, signature] = token.split('.');
console.log(JSON.parse(Buffer.from(head, 'base64url').toString('utf8'))); // { alg: 'HS256', typ: 'JWT' }
console.log(JSON.parse(Buffer.from(body, 'base64url').toString('utf8'))); // { sub: '123', name: 'Ada' }
这句话说得够多了,但值得再说一遍:解码不是验证。头和载荷只是穿了戏服,并没有加密,任何持有令牌的人都能读到两者。你必须检查的是第三部分,也就是签名。对于经典的 HMAC-SHA256 令牌,整个检查就是内置 crypto 模块的几行代码,唯一微妙之处在于用 timingSafeEqual 做比较,好让攻击者无法对你的逐字节比较做计时分析:
const crypto = require('node:crypto');
const expected = crypto.createHmac('sha256', 'topsecret').update(head + '.' + body).digest();
const actual = Buffer.from(signature, 'base64url');
console.log(crypto.timingSafeEqual(expected, actual)); // true
console.log(crypto.timingSafeEqual(crypto.createHmac('sha256', 'wrong-secret').update(head + '.' + body).digest(), actual)); // false
在真实的服务里,你通常不会手写这一套。jose 包(零依赖,可在 Node.js、浏览器和边缘运行时运行)和资历深厚的 jsonwebtoken 包(Node.js)把这套舞步打包好了,处理 RSA 和 ECDSA 算法家族,并强制校验 exp、aud 和 iss 声明。无论你选哪个库,底下的 Base64 管道都是你刚才看到的同样两个调用。
HTTP:请求头、查询字符串与 Cookie
线上世界的三个角落到处是 Base64。最老的一个是 HTTP Basic 认证,定义于 RFC 7617:客户端发送 Authorization: Basic 加上 user-id:password 的 Base64。在服务端,切一刀、解码一次就够了,只有一个小小的协议细节:只有第一个冒号分隔用户名和密码,所以密码可以合法地包含更多冒号,而用户名不行:
const { Buffer } = require('node:buffer');
const header = 'Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ==';
const credentials = Buffer.from(header.slice(6), 'base64').toString('utf8');
const [user, ...rest] = credentials.split(':');
console.log(user, rest.join(':')); // "Aladdin" "open sesame"
再记住 Basic 认证到底是什么:混淆,不是安全。凭据是穿着外衣过网的,所以这套机制只在 HTTPS 上才可接受。第二个角落是查询字符串,它藏着 Base64 世界里最阴的一枚地雷:
const params = new URLSearchParams('token=aGVs+bG8=');
console.log(params.get('token')); // "aGVs bG8=",加号变成了空格
你的 Base64 没有自己损坏。是 URL 层替表单编码规则礼貌地动的手,而这套规则把 + 当作空格。这正是活在查询字符串里的令牌改用 URL 安全字母表的原因,下一节会讲。第三个角落是 Cookie:Cookie 只收 ASCII,所以存进去的任何非 ASCII 值几乎可以肯定是 Base64,而"把 JSON 小块 Base64 进 Cookie"这个老套路,在多得惊人的生产系统里依然活着。解码还是你熟悉的那一套;只是要先校验形状,因为 Cookie 这种地方,用户或者浏览器扩展随时可以塞给你一堆垃圾。
文件、图片与 data URL
Node 的文件系统直接讲 Base64,所以整个文件一行代码就能越过 JSON 的边界:
const fs = require('node:fs');
const base64 = fs.readFileSync('./photo.png', 'base64');
console.log(base64.length); // 文件本身,大约重了 33%
const bytes = Buffer.from(base64, 'base64');
fs.writeFileSync('./photo.copy.png', bytes);
另一种文件形状的载荷是 data URL,也就是前端拿来内嵌图片的那串 data:image/png;base64,...。在任何运行时里做法都一样:在第一个逗号处切开,解析它之前的元数据,解码剩下的部分。下面是一像素真 PNG 重获新生的样子:
const dataUrl = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=';
const comma = dataUrl.indexOf(',');
const meta = dataUrl.slice(5, comma);
const bytes = Buffer.from(dataUrl.slice(comma + 1), 'base64');
console.log(meta); // "image/png;base64"
console.log(bytes.subarray(0, 8).toString('hex')); // "89504e470d0a1a0a", PNG 的签名
检查签名是个低成本的好习惯。PNG 的前八个字节永远是 89 50 4E 47 0D 0A 1A 0A,JPEG 则以 FF D8 FF 开头。如果客户端送来的"base64 图片"没有以它承诺的魔数开头,你如今能在对它做任何昂贵操作之前就知道。
URL 安全的 Base64:为令牌而生的字母表
经典 Base64 用 + 和 / 作为它的两个特殊字符(RFC 4648 第 4 节),而这俩在 URL 里都是麻烦:+ 在表单解码时会变成空格,/ 是路径分隔符。第 5 节的 URL 和文件名安全变体,大家都叫它 base64url,把这两个字符换成 - 和 _,并且当长度能从上下文得知时,可以干脆丢掉末尾的 = 填充。这恰恰是 JWT、OAuth 令牌和深度链接需要的组合,所以 base64url 是你在真实世界里最常碰到的字母表。
Node 的 Buffer 让整件事变得不值一提。'base64' 和 'base64url' 两种解码模式都接受全部四个特殊字符,并把它们映射到相同的值,所以 JWT 的部分、OAuth 令牌和经典 Base64 大块都可以直接解码,不需要任何字符互换的仪式:
const { Buffer } = require('node:buffer');
console.log(Buffer.from('aGVs-bG8', 'base64').toString('hex')); // "68656cf9b1bc"
console.log(Buffer.from('aGVs+bG8', 'base64url').toString('hex')); // "68656cf9b1bc",完全相同的六个字节
ES2026 API 故意为之更挑剔,并且用一个显式的旋钮给你同样的灵活性。alphabet 选项在 "base64"(默认,+ 和 /)和 "base64url"(- 和 _)之间选择,而喂入一个来自错误字母表的字符会得到 SyntaxError,而不是悄悄地进行跨字母表解码:
console.log(Uint8Array.fromBase64('aGVs-bG8', { alphabet: 'base64url' }).length); // 6
try {
Uint8Array.fromBase64('aGVs-bG8'); // 默认字母表是经典那个
} catch (error) {
console.log(error.name); // "SyntaxError",短横线不是经典字符
}
在还没有这些新方法的浏览器里,绕路就是在把字符串交给 atob() 之前做一次小小的字符互换,因为它只认识经典字母表。如果发送方丢掉了填充,你还得把它补回来,这对令牌类的载荷来说是常态:
function decodeBase64Url (value) {
const classic = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = classic + '='.repeat((4 - (classic.length % 4)) % 4);
const binary = atob(padded);
const bytes = new Uint8Array(binary.length);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return bytes;
}
console.log(new TextDecoder().decode(decodeBase64Url('aGVsbG8gd29ybGQ'))); // "hello world"
野生 Base64:载荷藏在哪里
在 JavaScript 世界里,Base64 就是字节的邮政服务。一次它出现的巡礼,每站附上解码配方:
- JSON API 字段,迄今为止最常见的载体:头像、缩略图、生成的文档和上传文件,都以 Base64 字符串的形式出现在普通 JSON 里,因为 JSON 没有"这些是字节"这个词。在对它做任何其他事情之前,先解码这个字段。
- 环境变量与配置文件:一些密钥管理器、CI 系统乃至 npm CLI 本身都会递给你 Base64 大块(老版本 npm 把注册表凭据存成
user:password的 Base64 放在.npmrc里;现代 npm 则在_authToken里直接写裸的 bearer 令牌)。启动时解码一次,明文在内存里只保留你需要的时长。 - Kubernetes 与集群工具:k8s 的 secret 众所周知地在 API 和
etcd里以 Base64 编码存储,官方文档也不断重复:那是编码,不是加密。你的解码代码应该把结果当秘密,而不是当作安全的证明。 - 数据库:存在 JSON 列(Postgres 的
jsonb、MongoDB 文档、Redis)里的任何二进制,经常就是一串 Base64。在读路径上把它解码成 Buffer 或 Uint8Array,让数据库保持纯文本。 - 邮件:带 76 字符折行的 MIME Base64,是附件和二进制头穿越 SMTP 的方式,而 SMTP 最初是纯 7 位协议。Node 的解码器会替你跳过换行,所以整个正文一次调用就解完,无需清理。
- CI 与 CD 流水线:构建系统和密钥注入器把令牌当作 Base64 环境变量传递;在流水线脚本里解码,并且绝不把解码后的值回显进日志。
- 目录与 SAML 数据:LDIF 文件把二进制属性(想想:证书)存成 Base64,SAML 响应在穿越 HTTP 边界之前,也常常先被 deflate 压缩再 Base64 编码。
- Worker 线程与边缘运行时:Base64 字符串以普通的可结构化克隆字符串的形式跨越
worker_threads边界,所以繁重的解码可以放在 worker 上,主线程的事件循环依然空闲。
这些站点中有两个值得凑近看,因为它们既出现在面试里,也出现在生产环境里:
const { Buffer } = require('node:buffer');
// 环境变量:秘密以 Base64 编码到达
const token = Buffer.from(process.env.REGISTRY_TOKEN_B64, 'base64').toString('utf8');
// JSON API 字段:先解包再干别的
const body = { attachment: 'iVBORw0KGgo...' };
const imageBytes = Buffer.from(body.attachment, 'base64');
console.log(imageBytes.subarray(0, 4).toString('hex')); // "89504e47", PNG 签名又来了
// MIME 邮件:换行被跳过,无需清理
const mimeBody = 'SGVsbG8sIHdyYXBw\nZWQgYmFzZTY0IQ==';
console.log(Buffer.from(mimeBody, 'base64').toString('utf8')); // "Hello, wrapped base64!"
这次巡礼要盯住的反模式处处相同:在本来就允许原始字节的地方放 Base64。WebSocket 帧、文件流、Postgres 的 bytea 列,它们全都原生携带字节,所以在那里搞 Base64 往返就是纯开销,33% 的尺寸税换不来任何好处。当原生二进制通路存在时,走它。
分块解码:流与大数据
Base64 用四个字符一组编码三个字节,所以一串数据块完全可能把一组拦腰截断。天真的做法,每块都解、然后祈祷,会在随机的边界上弄坏输出。ES2026 API 正是为这个场景设计的:setFromBase64() 写入预分配的数组,并报告它消耗了多少输入字符,而 "stop-before-partial" 模式让它停在最后一组完整分组处,把碎片留给下一块。这个模式与 TextDecoder 的流 API 如出一辙:
const { Buffer } = require('node:buffer');
const chunks = ['aGVsbG8', 'gd29ybGQ='];
let leftover = '';
const parts = [];
for (const chunk of chunks) {
const pending = leftover + chunk;
const space = new Uint8Array(Math.ceil(pending.length * 3 / 4));
const { read, written } = space.setFromBase64(pending, { lastChunkHandling: 'stop-before-partial' });
parts.push(Buffer.from(space.buffer, space.byteOffset, written));
leftover = pending.slice(read);
}
parts.push(Buffer.from(Uint8Array.fromBase64(leftover)));
console.log(Buffer.concat(parts).toString('utf8')); // "hello world"
在没有这些新方法的运行时上(Node 的 LTS 线有一段时间也没有),同样的循环可以配合一个小的用户态解码器来工作,它会跟踪不完整的分组;或者干脆把进来的块缓冲起来,直到你能按分组边界切分。重要的思想是"进位":永远不要单独解码一个碎片。
大载荷还会把另外两个限制推到你面前。第一,字符串本身:Node 的 buffer.constants.MAX_STRING_LENGTH 是 536870888 个字符,大约 512 MiB 的文本,解码出来约 400 MB 字节。比这大的"base64 文件"需要流式处理,而不是单次 readFileSync。第二,内存:编码后的字符串以 UTF-16 形式住在 JavaScript 堆里,每个字符两个字节,而解码后的 Buffer 是这份数据的第二份拷贝。处理大载荷时你会短暂地同时持有两者,所以让编码形式存活的时间尽可能短,对任何文件级的东西都优先用流。
从终端出发
Node 还能客串一个相当体面的命令行 Base64 解码器,调试请求或检查配置值的时候很方便:
# 解码作为参数传入的经典 Base64 字符串
node -e 'console.log(Buffer.from(process.argv[1], "base64").toString("utf8"))' "aGVsbG8gd29ybGQ="
# URL 安全变体,填充可选
node -e 'console.log(Buffer.from(process.argv[1], "base64url").toString("utf8"))' "aGVsbG8gd29ybGQ"
# 从 stdin 解码,管道存在的意义
echo -n "aGVsbG8gd29ybGQ=" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>console.log(Buffer.from(d.trim(),"base64").toString("utf8")))'
三条命令都打印 hello world。如果机器上还有 coreutils 的经典 base64 命令,它用 base64 -d 干的是同一件事,但 Node 版本认识 base64url,而传统工具不认识。
带 JavaScript 口音的陷阱
这里每一条,都曾是 JavaScript 或 Node.js 里某个人的整个下午:
- 沉默的解码器:
Buffer.from('!!!', 'base64')返回空 Buffer,而不是错误。半损坏的输入会解出半损坏的数据,且毫无警告。用严格正则(或严格的fromBase64模式)校验不可信输入,并把"非空字符串解出空 Buffer"当作危险信号。 - 缺失的编码参数:
Buffer.from('aGVsbG8=')不带第二个参数,什么都不会解码。它用那些字母的 UTF-8 字节构建 Buffer,所以你"解码"出来的数据就是那些字母本身,重新打包成字节。整个戏法就在'base64'这个参数上。 - 二进制字符串外衣:
atob()的输出,在你开口说它是文本之前都不是文本。把它塞进 JSON 响应、Cookie 或日志行"能用",而且它还会原样保住每一个空字节,这同样会让日志收集和序列化器大跌眼镜。立刻用charCodeAt()把它转成 Uint8Array,或转成 UTF-8 文本。 - 查询字符串里的加号:表单解码后的查询值里的
+,等URLSearchParams递到你手上时已经是空格。凡是要活在 URL 里的东西都优先用 base64url,永远不要把经典 Base64 令牌不转义地粘进查询字符串。 - 替换字符:在 Buffer 的 UTF-8 模式里,非法 UTF-8 会变成一声不吭的菱形问号,而不是错误,所以损坏的载荷可以穿过你的管道,落进数据库。在损坏必须大声失败的地方,给
TextDecoder打开fatal: true。 - Windows 绕路:向
TextDecoder要'iso-8859-1'或'latin1',拿到的是 Windows-1252 解码器,在那里字节 0x80 变成了欧元符号。想要真正的逐字节 Latin-1,改用toString('latin1')读 Buffer。记住'binary'只是同一个 Latin-1 映射的误导性别名。 - 尺寸天花板:
buffer.constants.MAX_LENGTH在 64 位系统上是 9007199254740991 字节(2 的 53 次方减 1),但承载 Base64 的字符串长不过 536870888 个字符的MAX_STRING_LENGTH。所以单个字符串只能携带略多于 400 MB 的解码数据;再大,就用流。 - 内存账单:一个 Base64 字符串每个字符要两个堆字节(UTF-16),解码后的 Buffer 是完整的一份第二拷贝。一个 100 MB 的文件,在你进程里短暂地变成大约 133 MB 字符串加 100 MB Buffer。缩小编码形式保持被引用的窗口。
- 远端严格、本地宽松的错位:你的 Node 解码器会原谅别处严格解码器拒绝的东西(一个 Python 脚本、一个 Go 服务、一个移动应用)。如果你系统的一边严格、另一边宽容,bug 就只在某些载荷长度上出现,而那是最坏的 bug。在协议层面约定严格程度,别只在脑子里约定。
JavaScript 如何长出自己的解码器
浏览器一侧的历史很长、很枯燥,也很可靠。atob() 和 btoa() 在 2011 年初的 HTML5 草案中被写进规范(浏览器比规范更早拥有它们),此后一直坐在所有主流浏览器里,行为十多年来未曾改变。它们早于语言标准中的类型化数组(ES2015),所以它们说的是"二进制字符串",而不是字节。
Node.js 在另一条时间线上长出了自己的解码器。Buffer 类在 2010 年夏天的 0.1.103 版本成为全局对象,比 Node 1.0 早了近五年,并且从第一天起就带着 'base64' 模式。在 Node 生命的大部分时间里,它是城里唯一的解码器。然后 Web 标准浪潮来了:2021 年的 Node 16 把 atob() 和 btoa() 加为全局对象,让为浏览器写的代码不用 polyfill 就能跑在服务端,并从第一天起把两者都标记为 Legacy。2025 年 10 月 15 日发布的 Node 25 把 V8 升级到 14.1,把 ES2026 方法 Uint8Array.fromBase64()、setFromBase64() 和它们的十六进制兄弟带进了运行时。在此期间,老的 new Buffer() 构造器被弃用(Node 10 从 2018 年开始警告),改用 Buffer.from()、alloc() 和 allocUnsafe(),部分原因是未初始化的分配可能泄露那里之前驻留的任意内存。
在浏览器里,同一波浪潮稍早一点登陆:Firefox 133 和 Safari 18.2 在 2024 年上线了这些新方法,Chrome 140(2025 年 9 月 2 日稳定版)补齐了最后一块,此时该特性在浏览器厂商的 Baseline 项目里被宣布为 Baseline Newly available。一体化的 JavaScript 运行时 Bun 在 2024 年 8 月的 1.1.22 版本里也拿到了它们。如果你无法要求使用新运行时,core-js 和 es-shims 项目的 es-arraybuffer-base64 包提供这一切的 polyfill,这也是大多数框架内部走的路。
它们服务的格式,谱系还要更老。这套字母表最早在 1987 年为 Privacy-Enhanced Mail 标准化(RFC 989),1993 年的修订版(RFC 1421)沿用了同一套字母表,MIME 在 1996 年(RFC 2045)接过了它,比那次修订晚了大约三年,并带来了 76 字符的折行;2003 年的 RFC 3548 把 base16、base32 和 base64 合并进一份文档,2006 年的 RFC 4648 重新发布它,保留了 RFC 3548 添加的 URL 安全字母表 - 也就是十年后落进每个 JWT 的那一套。URL 安全变体是个不错的冷知识:它在 2001 年的一篇关于对等标识符的邮件列表帖子里被提出,那时它还没有见过任何令牌。
下次站会上的趣味冷知识
- WebSocket RFC 里的示例密钥
dGhlIHNhbXBsZSBub25jZQ==,解码出来是 "the sample nonce" 这几个词。标准委员会在自己的示例里藏了一个眨眼,而 Node 的atob()一次调用就道破了这个笑话。 Buffer.from('!!!', 'base64')返回一个长度为零的 Buffer。一次真实的分配,里面什么都没有。什么都没有。这是 Node 最接近耸肩的动作。- Node 的 Base64 解码器是双语的,而规范从没要求过:在
'base64'和'base64url'两种模式里,+、-、/和_都受欢迎,每对字符映射到相同的值。 atob()的 Node 文档里有一句话:"Use Buffer.from(data, 'base64') instead"。一个运行时在叫你别用它自己的全局函数,还附送一个官方 codemod(npx codemod@latest @nodejs/buffer-atob-btoa)替你做迁移。- 小 Buffer 是从一块共享的内存块上切出来的:
Buffer.poolSize是 65536 字节,每一次小分配都复用那个池子的片段。这就是 Buffer 创建快的原因,也是"unsafe" 分配这个说法你应该知道其含义的原因。 - 小小的
base64-js包,三个函数、零依赖,在 npm 上每周下载量轻松破一亿,其中几乎全是作为隐藏依赖藏在别的包里。Base64 是生态里被"走私"得最多的代码。 Uint8Array.fromBase64()有一个叫"stop-before-partial"的模式,它存在的全部意义,就是让你解码流时永远不用把一个四字符分组拆散。一个以"自己拒绝做的事"命名的模式,是稀有的 API 诗。- Unix 密码世界用自己的 Base64 风味字母表,没有填充,而且令人困惑的是,它们的顺序并不都一样。经典
crypt(3)的 "hash64" 字母表是./0-9A-Za-z,但 bcrypt 把同样 64 个字符排成了./A-Za-z0-9。你会在很多 JavaScript 项目为用户密码存储的$2b$哈希里见到 bcrypt 版本,这也是为什么安全语境下的 "base64" 可能指好几种不同的字母表,而不只是两种。
还差一个方向
在 JavaScript 和 Node.js 里解码 Base64,就是三件诚实工具的堆叠:Buffer.from(string, 'base64'),那匹宽容的主力,接受两种字母表、跳过每一个捣乱的字符,最好由一条严格正则把守;TextDecoder,负责用老 Web 发明过的任何字符集读出真正的文本,损坏应该疼的时候有 fatal 模式;还有新的 Uint8Array.fromBase64(),为字节优先的代码而生,要严格的字母表、严格的填充位,还有不用走钢丝的流。钉死字符集,校验陌生人发给你的东西,用 timingSafeEqual 比较签名,这个格式就不再是浏览器/服务端分界两侧的谜团。
等你拆完包裹,别忘了总得有人先把它们封好。编码那一侧也有自己的陷阱:让 btoa() 说到一半卡住的 Unicode 高墙、MIME 折行、base64url 的填充规则,还有带着 omitPadding 选项的新方法 Uint8Array.toBase64()。那个故事,每个步骤都配有代码示例,在我们姊妹站点的 Base64 编码相关文章里讲得很深。接下来读它,因为字母表那一侧的陷阱不一样,也更好笑。
最后更新: 2026-09-07