JavaScript/Node.js 中的 Base64 编码:完整指南
你手里有一份需要变成文本的数据。一个必须骑进 JSON 字段的文件,一张想住在 CSS 文件里的图片,一个要坐在环境变量里的秘密,一个要穿过查询字符串旅行的令牌。在 JavaScript 和 Node.js 里,答案几乎总是同一个:Base64。这篇文章就是打包手册,从你握住的第一个字节,讲到你的编码字符串离开机器的那一刻。
本站首页已经详细解释了这个格式,字母表、数学、填充,所以这里一句话带过:每三个字节变成四个可打印字符,这就是你的输出会比输入大约大 33% 的原因。把这个数收进口袋,因为它是这篇文章每一节存在的原因,也是你的存储账单的计价单位。
让人安心的部分:你什么都不用安装。每个现代浏览器都随附 btoa() 和更新的 Uint8Array.toBase64(),每个称得上重要的 Node.js 版本都带着 Buffer 类,有 'base64' 模式,自 15.7.0 版本起还有一等公民级别的 'base64url' 模式。艺术在于知道:你握的是哪种输入形状,目的地要求哪种字母表,老格式还在强制执行哪些折行规则。
编码之前,先认清你的输入
每个编码问题都从同一个问题开始:你到底握着什么?JavaScript 字符串是 UTF-16 文本,Buffer 是字节数组,正确的调用取决于你手里是哪个:
| 你握着的是 | 调用这个 | 备注 |
|---|---|---|
| 纯 ASCII 字符串(字符低于 256) | btoa(string) |
浏览器和 Node.js 16+ 里的最快路径,但它在第一个塞不进字节的字符处停下 |
| 任意 Unicode 字符串 | TextEncoder 转字节,再调一个 base64 |
UTF-8 桥梁;带重音字母和表情符号唯一安全的路径 |
| 一个 Buffer 或 Uint8Array | buffer.toString('base64') 或 bytes.toBase64() |
Node.js 的主力,以及现代浏览器和 Node.js 25+ 里的 ES2026 方法 |
三个例子,表格每一行一个:
// 纯 ASCII 文本:老牌捷径(浏览器和 Node.js 16+)
console.log(btoa('hello world')); // "aGVsbG8gd29ybGQ="
// Node.js 里的任意文本:Buffer 默认按 UTF-8 读取
const { Buffer } = require('node:buffer');
console.log(Buffer.from('héllo ⛳', 'utf8').toString('base64')); // "aMOpbGxvIOKbsw=="
// 你已经拥有的字节
console.log(Buffer.from([1, 2, 3, 4]).toString('base64')); // "AQIDBA=="
console.log(new Uint8Array([1, 2, 3, 4]).toBase64()); // "AQIDBA=="(ES2026 运行时)
注意第二个例子:同样的文本,取决于你用哪个字符集编码它,会产生不同的 Base64 字符串。这不是 bug - 这就是整盘棋。Base64 这一层编码的是字节,字符串只有在你选定字符集之后才变成字节,所以"编码这段文本"总是暗指"编码这段文本的 UTF-8 字节"(如果你说了算,也可以是 Latin-1 字节)。
Unicode 高墙与跨越它的桥梁
btoa() 是屋里最老的 API,它的契约是 1990 年代的东西:输入字符串的每个字符都必须装进一个字节,码点在 0 到 255 之间。高于此的任何东西,一个表情符号、一个带重音的西里尔字母、一个汉字,都会抛错:
try {
btoa('héllo ⛳');
} catch (error) {
console.log(error.name); // "InvalidCharacterError"
console.log(error.message); // Node 里是 "Invalid character";浏览器里是 Latin1 范围的措辞
}
解法是停止用字符思考,开始用字节思考。TextEncoder(每个浏览器和 Node.js 里的全局对象)把字符串变成它的 UTF-8 字节序列;你把那些字节抬进一个 Latin-1 字符串,btoa() 拿到的就正是它承诺要处理的东西:
function encodeUnicode (text) {
const bytes = new TextEncoder().encode(text);
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
console.log(encodeUnicode('héllo ⛳')); // "aMOpbGxvIOKbsw=="
console.log(encodeUnicode('héllo ⛳') === Buffer.from('héllo ⛳', 'utf8').toString('base64')); // true,同样的字节
你还会在代码库里见到更老的惯用法,它底层的工作原理相同:btoa(unescape(encodeURIComponent(text)))。encodeURIComponent 调用产生百分号编码的 UTF-8 字节,unescape 把百分号转义还原成原始字符。escape 和 unescape 都是遗留函数,所以新代码应该优先用 TextEncoder 桥梁,但当你继承旧形式时,如今你知道它到底在干什么,而不是耸耸肩了。
在 Node.js 里这堵墙基本不是事件,因为 Buffer.from(text) 假定 UTF-8,并在同一个调用里替你完成字节转换。桥梁在浏览器里最重要,那里 btoa() 是遗留选项,UTF-8 这一步得由你显式来做。
字节进、字母出:Buffer、填充与风味
一旦有了字节,Node.js 的编码侧就是一个方法:toString('base64')。它处理分组数学、填充,一切,而且始终以 RFC 4648 意义上的规范方式产生输出,也就是说最后一组未使用的填充位是零:
const { Buffer } = require('node:buffer');
const fox = Buffer.from('The quick brown fox jumps over the lazy dog');
console.log(fox.length); // 43 个字节
console.log(fox.toString('base64')); // "VGhlIHF1aWNrIGJyb3duIGZveCBqdW1wcyBvdmVyIHRoZSBsYXp5IGRvZw=="
console.log(fox.toString('base64').length); // 60 个字符,33% 的税正在生效
末尾的填充在干真正的活,不是装饰。只有一个剩余字节的最后一组变成两个 Base64 字符加两个 =,有两个剩余字节的组变成三个字符加一个 =。你的输出能不能带着这个填充,取决于目的地,而这正是你日常使用的两种 Base64 风味之间的区别:
const one = new Uint8Array([72]);
console.log(one.toBase64()); // "SA=="(ES2026,包含填充)
console.log(one.toBase64({ omitPadding: true })); // "SA"
console.log(Buffer.from([72]).toString('base64url')); // "SA",Node 在 base64url 模式下丢掉填充
估算尺寸时记住这个比例:三个字节进,四个字符出,所以 1 MB 数据变成大约 1.33 MB 文本,如果你为邮件或 PEM 把文本折成行,换行还会再加上几个百分比。
让字节过网
JavaScript 里最常见的过网问题,是 JSON 没有字节。它有的是字符串,而能安全穿过任何 JSON 解析器、任何 HTTP 代理和任何日志系统的字符串,就是 Base64 那一种。这个模式在连接两端是一样的:在边界处编码,在边界处解码,中间持有字节:
const fs = require('node:fs');
const photo = fs.readFileSync('./photo.jpg', 'base64');
const payload = JSON.stringify({
name: 'photo.jpg',
contentType: 'image/jpeg',
data: photo
});
console.log(payload.startsWith('{"name":"photo.jpg"')); // true,文件现在骑在普通 JSON 里
知道什么时候该和这个模式作对。如果你的传输层已经支持二进制,就用它:multipart/form-data 上传发送原始文件、没有尺寸税,WebSocket 帧携带原始字节,Postgres 的 bytea 列原生存储它们。在本来就允许原始字节的地方用 Base64 是纯开销,33% 的税换不来任何东西。当通道纯文本时,Base64 才挣回身价:JSON API、邮件正文、环境变量、URL 查询字符串,以及众多的桥梁(移动 SDK、桌面应用、聊天系统),它们只放文本通过。
data URL:活在文本里的图片
data URL,也就是 data:image/png;base64,... 字符串,是穿着 MIME 标签的 Base64,它让你能把一整张图片塞进单个 HTML 属性里。1998 年定义这个方案的 RFC 甚至说它"只对短值有用",因为早期 HTML 对属性值有 1024 字符的限制。现代浏览器对那个限制付之一笑,乐呵呵地渲染兆字节大小的 data URL,这既是超能力,也是陷阱。
在浏览器里,canvas API 替你把整个活儿干了,像素进去,data URL 出来:
const canvas = document.createElement('canvas');
canvas.width = 1;
canvas.height = 1;
const context = canvas.getContext('2d');
context.fillStyle = '#ff0000';
context.fillRect(0, 0, 1, 1);
const dataUrl = canvas.toDataURL('image/png'); // "data:image/png;base64,iVBOR..."
console.log(dataUrl.slice(0, 24)); // "data:image/png;base64,iV"
而在任何运行时,包括 Node.js,构造一个只是字符串拼接,元数据放在正确的位置:data: 前缀、媒体类型、可选的 ;base64 标记、一个逗号,然后是载荷。没有 ;base64 标记,载荷就被期望是百分号编码的文本,这正是这个标记存在的原因:
const { Buffer } = require('node:buffer');
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAAB', 'base64');
const dataUrl = 'data:image/png;base64,' + png.toString('base64');
console.log(dataUrl.startsWith('data:image/png;base64,')); // true
诚实的权衡:data URL 是文档的一部分,所以它不能作为独立资源被缓存,它计入所在的 HTML 或 CSS 的尺寸,而且 DOM 必须解析并持有它。对一个 20 千字节的图标,这是一笔划算的买卖。对一张 4 兆字节的主视觉图片,把文件通过 HTTP 发出去,让缓存和压缩都生效,data URL 留给小东西。
以字符串旅行的文件
文件转 Base64 是一支两步舞,两个运行时都把它压缩进单次调用。在 Node.js 里,文件系统接受 'base64' 作为读取编码,写入侧也接受:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
const base64 = fs.readFileSync('./report.pdf', 'base64');
console.log(base64.length); // 文件本身,大约重了 33%
fs.writeFileSync('./report.pdf.b64', base64, 'utf8');
const copy = Buffer.from(base64, 'base64');
fs.writeFileSync('./report.copy.pdf', copy);
在浏览器里 FileReader 干同样的活,带一个转折:它的数据读取模式递给你的是一个 data URL,所以你得把前缀切掉,拿到裸的 Base64 载荷:
const fileInput = document.querySelector('input[type="file"]');
fileInput.addEventListener('change', () => {
const reader = new FileReader();
reader.onload = () => {
const dataUrl = reader.result; // "data:application/pdf;base64,..."
const payload = {
name: fileInput.files[0].name,
data: dataUrl.slice(dataUrl.indexOf(',') + 1)
};
console.log(payload.data.length); // 文件,已准备好进 JSON 请求
};
reader.readAsDataURL(fileInput.files[0]);
});
如果你需要在浏览器里拿到不带 data URL 前缀的裸 Base64,file.arrayBuffer() 接 Uint8Array.toBase64()(在有它的运行时上)直接跳过前缀,对上传管道来说是更干净的路径。
base64url:能在 URL 里存活的字母表
经典 Base64 带着两个让 URL 过敏的字符。+ 在查询字符串被表单解码时总变成空格,/ 是路径分隔符,= 填充看起来像赋值。RFC 4648 第 5 节的 URL 和文件名安全变体 base64url,把两个特殊字符换成 - 和 _,并在长度能从上下文得知时丢掉填充。它是 JWT、OAuth 令牌和深度链接的字母表,配得上在你心智工具包里占一个专门位置。
Node 的 Buffer 从 15.7.0 版本起就讲这种方言,编码侧只是一个参数:
const { Buffer } = require('node:buffer');
const classic = 'k+XS/B4=';
console.log(Buffer.from(classic, 'base64').toString('base64url')); // "k-XS_B4"
有两件事值得注意。+ 变成了 -,/ 变成了 _,填充消失了,因为 base64url 模式按设计就省略它。而且 IETF 明确说过,这是一种不同的编码,不是同一种穿了戏服,所以规范说 "base64url" 时,你应该产出 base64url,而不是做了查找替换的经典 Base64。ES2026 方法把同样的选择做成显式选项,它的 omitPadding 标志在上下文要求时把填充还给你:
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url', omitPadding: true })); // "q_8"
console.log(new Uint8Array([0xab, 0xff]).toBase64({ alphabet: 'base64url' })); // "q_8=",两个字节需要一个填充字符
在两者都没有的运行时上,转换就是两个字符互换加一次填充修剪,它是 JavaScript 世界里被复制粘贴最多的代码片段之一:
const toUrlSafe = (value) => value.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
console.log(toUrlSafe('k+XS/B4=')); // "k-XS_B4"
凡是要活在 URL、查询字符串、文件名或令牌标准里的东西,用 base64url。MIME 正文、data URL 和永远见不到百分号解码器的东西,用经典 Base64。把两者混用,是整个格式里最常见的互操作 bug。
封装凭据:Basic 认证、JWT 与 PKCE
Web 认证有三个角落建立在 Base64 之上,而且这三个都可以低成本地亲手搭建一次,这是好事,因为知道库底层发生了什么,才能在库给你惊喜时保持镇定。
第一,HTTP Basic 认证(RFC 7617):客户端发送方案词 Basic 加上 user-id:password 的 Base64。一行代码,附带一条认真的警告:
const { Buffer } = require('node:buffer');
console.log('Basic ' + Buffer.from('octo:cat').toString('base64')); // "Basic b2N0bzpjYXQ="
这里的 Base64 是混淆,不是安全。任何能读到头的人都能读到密码,所以这套机制只在 HTTPS 上才可接受,而且即便那样它也是遗留模式:优先用令牌。第二,JWT:前两个点分隔的部分是普通 JSON 的 base64url,第三个是签名。手写一个 HMAC-SHA256 令牌就是内置 crypto 模块的一小把代码:
const crypto = require('node:crypto');
const header = Buffer.from(JSON.stringify({ alg: 'HS256', typ: 'JWT' })).toString('base64url');
const payload = Buffer.from(JSON.stringify({ sub: 'octocat', exp: 1893456000 })).toString('base64url');
const signature = crypto.createHmac('sha256', 'topsecret').update(header + '.' + payload).digest('base64url');
const token = header + '.' + payload + '.' + signature;
console.log(token.split('.').length); // 3 部分,全程无填充的 base64url
注意这些决定令牌成败的细节:任何地方都没有填充(RFC 7515 省略它),签名是对字面字符串 header + '.' + payload 计算的,而不是对解析后的对象,而且整个东西只有和密钥一样机密。在生产里你会用库,jose(零依赖,浏览器和 Node.js)或 jsonwebtoken(Node.js),但它们底层跑的就是这些调用。第三,PKCE(RFC 7636),让 SPA 和移动应用这样的公共客户端能安全登录的扩展:客户端生成一个高熵的 code_verifier,把 BASE64URL(SHA256(verifier)) 作为挑战发布,并在令牌交换时证明持有 verifier。随机性很重要,所以 verifier 来自 crypto 模块,永远不要来自 Math.random():
const verifier = crypto.randomBytes(32).toString('base64url');
const challenge = crypto.createHash('sha256').update(verifier).digest('base64url');
console.log(verifier.length, challenge.length); // 43 43,都在允许的 43-128 范围内
老邮件需要折行:MIME 与 PEM 护甲
世界上最老的两种 Base64 格式至今还在强制执行行长度,而且它们都有大约 30 岁了。MIME,RFC 2045 的邮件标准,把 Base64 折为每行 76 个字符,并要求行尾是 CRLF,那是 8 位干净 SMTP 年代的遗物,当时过长的行会弄坏真实的邮件服务器。RFC 7468 把证书和密钥的 PEM 规则写了下来,它更严格:生成器必须正好按每行 64 个字符折行,最后一行更短,外面框着标明内容的 -----BEGIN 和 -----END 护甲行。
折行本身是一行代码,护甲是一个模板:
const wrap = (base64, width) => base64.match(new RegExp('.{1,' + width + '}', 'g')).join('\r\n');
const certBase64 = Buffer.from('x'.repeat(150), 'utf8').toString('base64'); // 200 个字符
console.log(wrap(certBase64, 76).split('\r\n').map((line) => line.length).join(', ')); // "76, 76, 48"
console.log(wrap(certBase64, 64).split('\r\n').map((line) => line.length).join(', ')); // "64, 64, 64, 8"
const armor = (label, body) => '-----BEGIN ' + label + '-----\r\n' + wrap(body, 64) + '\r\n-----END ' + label + '-----\r\n';
console.log(armor('CERTIFICATE', 'QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo='));
// -----BEGIN CERTIFICATE-----
// QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVo=
// -----END CERTIFICATE-----
两条实用提醒。当你生产 MIME 或 PEM 时,要折行,因为严格的消费方(邮件网关、OpenSSL 时代的老工具、Java 密钥库)会拒绝 4000 字符的单行 Base64 大块。当你消费时,通常不需要,因为 Node 的解码器跳过空白,Buffer.from 替你处理了换行 - 但护甲行本身不是 Base64,所以解码前要剥掉 -----BEGIN/-----END 行(或者只匹配正文):Buffer.from(pem.replace(/-----[A-Z ]+-----/g, ''), 'base64')。这种不对称是一份礼物,但它不意味着当 Base64 要去了一个什么都不跳过的地方(比如 DER 解析器)时,你可以跳过剥护甲这一步。
编码数据住在哪里:环境变量、配置与数据库
Base64 也是一种存储格式,这既方便,又危险地容易被当成安全。环境变量是经典的家:一些密钥管理器和 CI 系统递给你 Base64 编码的值,解码就是一行代码:
const { Buffer } = require('node:buffer');
const stored = process.env.API_KEY_B64; // "c3VwZXItc2VjcmV0"
console.log(Buffer.from(stored, 'base64').toString('utf8')); // "super-secret"
把重要的句子大声说出来:编码不是加密。环境变量、.env 文件或 Kubernetes secret 里的 Base64 "秘密"(k8s 把它的 secret 以 Base64 存在 API 和 etcd 里,文档也不断重复这一点),任何能读到进程环境、文件或集群的人都能读懂。在那里用 Base64,是因为传输层(shell、YAML、JSON)是纯文本,永远不是因为你相信它藏住了什么。
在数据库里,Base64 是 JSON 文档存储里二进制的标准桥梁,因为 jsonb 列或 MongoDB 文档没有自己的字节类型:
const document = {
name: 'logo',
mime: 'image/png',
data: Buffer.from([0x89, 0x50, 0x4e, 0x47]).toString('base64')
};
console.log(JSON.stringify(document)); // {"name":"logo","mime":"image/png","data":"iVBORw=="}
像例子那样把媒体类型和载荷放在一起存,一年以后当有人问那些字节是什么时,你会感谢自己。如果你的数据库有原生二进制类型(Postgres 的 bytea 是参考例子),优先用它:字节不花任何额外代价,而且你永远跳过 33% 的税。
流式编码而不拆散分组
Base64 以三个字节一组工作,所以接收任意数据块的编码器必须携带余数:还凑不成组的一两个字节,得等下一个块来了才能编码。按块做数学、只输出完整的分组,输出就和一次性编码整个流逐字节相同:
const { Transform } = require('node:stream');
const { Buffer } = require('node:buffer');
function base64Encoder () {
let pending = Buffer.alloc(0);
return new Transform({
transform (chunk, _encoding, done) {
pending = Buffer.concat([pending, chunk]);
const whole = Math.floor(pending.length / 3) * 3;
this.push(pending.subarray(0, whole).toString('base64'));
pending = pending.subarray(whole);
done();
},
flush (done) {
if (pending.length > 0) {
this.push(pending.toString('base64'));
}
done();
}
});
}
let output = '';
const encoder = base64Encoder();
encoder.on('data', (part) => { output += part; });
encoder.on('end', () => {
console.log(output); // "aGVsbG8gd29ybGQsIHRoaXMgaXMgYSBzdHJlYW0h";和一次大的 toString('base64') 相同
});
encoder.end(Buffer.from('hello world, this is a stream!'));
flush 回调是每个都忘掉的细节:最后那一两个字节,在常规块里始终没找到伙伴的那些,在这里拿到填充,被推出去。同样的携带逻辑就是你将在解码侧镜像的东西,只是那边 ES2026 API 免费送给你:setFromBase64() 配上 "stop-before-partial",正好停在分组边界上,并告诉你它消耗了多少字符。
大文件与内存账单
Base64 在空间上很大方,所以大文件需要策略。一个 1 GB 的文件变成大约 1.33 GB 的 Base64 文本,而 JavaScript 字符串存的是 UTF-16,堆里每个字符两个字节,所以光这份文本在你 Buffer 到来之前就要大约 2.7 GB 内存。Node 里有明确的天花板:buffer.constants.MAX_STRING_LENGTH 是 536870888 个字符,略低于 512 MiB 的文本,解码出来约 400 MB 字节。超过它,单个字符串不是选项,流式处理是唯一的游戏:
const fs = require('node:fs');
const { Buffer } = require('node:buffer');
let carried = Buffer.alloc(0);
const source = fs.createReadStream('./video.mp4', { highWaterMark: 64 * 1024 });
source.on('data', (chunk) => {
const joined = Buffer.concat([carried, chunk]);
const whole = Math.floor(joined.length / 3) * 3;
process.stdout.write(joined.subarray(0, whole).toString('base64'));
carried = joined.subarray(whole);
});
source.on('end', () => {
if (carried.length > 0) {
process.stdout.write(carried.toString('base64'));
}
process.stdout.write('\n');
});
这个模式就是上一节流编码器的扁平化版本:按 64 KiB 块读取,携带 1 到 2 字节的余数,输出完整的分组,冲刷尾部。无论文件多重,内存占用都保持在一块加一个余数左右。而且如果接收端能接受二进制,问自己一句:我为什么要交这个税。
终端一行流
Node 还能客串一个命令行 Base64 编码器,打包配置值、调试 API、或者通过一条聊天消息在小机器之间搬一个小文件时,都很方便:
# 把文件编码成经典 Base64 输出到 stdout
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64"))' notes.txt
# URL 安全变体,填充已丢弃
node -e 'const fs=require("node:fs");process.stdout.write(fs.readFileSync(process.argv[1],"base64url"))' notes.txt
# 从 stdin 读取,管道存在的意义
echo -n "hello world" | node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>process.stdout.write(Buffer.from(d,"utf8").toString("base64")))'
三条命令都不自带尾部换行,这让输出对复制粘贴和 shell 脚本里的 $(...) 替换保持干净。如果你想要一个带折行的漂亮打印文件,把结果管道进你顺手的编辑器,或者在一行流末尾加一个 \n。
让开发者付出数小时代价的陷阱
这里每一条,都在某个真实的代码库里烧掉过真实的下午:
- Unicode 高墙:
btoa('héllo ⛳')抛出InvalidCharacterError,因为那个高尔夫旗杆塞不进一个字节。解法是 UTF-8 桥梁:先用TextEncoder转成字节,再编码。在 Node.js 里用Buffer.from(text)可以直接跳过这个问题,它假定 UTF-8。 - 遗留惯用法:满是
btoa(unescape(encodeURIComponent(x)))的老代码能工作,但escape和unescape是已弃用的遗留函数。重构那段代码时,换成TextEncoder桥梁,行为保持完全相同。 - 缺失的编码参数,反过来:解码侧的陷阱是不带 'base64' 模式的
Buffer.from(str);编码侧的双胞胎是以为Buffer.from(someString)会对 Base64 做什么特别的事。它不会。没有显式编码,它就从字符串的 UTF-8 字节构建 Buffer,你的"编码"输出就是字符串字母字节的 Base64,几乎从不是你想要的。两个方向都要显式。 - 填充错位:JWT 和大多数令牌标准要无填充的 base64url,MIME 要带填充的经典 Base64,两者很容易交叉。JWT 部分里带填充的
=会让严格验证器崩掉;长度未知时缺失的填充会让宽松解码器崩掉。跟着标准走,别跟着习惯走。 - 非规范的尾巴:RFC 4648 要求最后一组未使用的填充位是零。内置编码器都产生规范输出,但一个手工移位的自制编码器可能往那些位里留下垃圾,严格解码器会无缘无故拒绝你的载荷。如果你写自己的编码器,用 RFC 4648 的测试向量来测,别只用你自己的数据。
- 被忘记的折行:MIME 要 76 字符行,PEM 要 64,严格的消费方(邮件网关、Java 密钥工具)会拒绝单行大块。反过来虽然少见但真实存在:一些解析器是按行工作的,PEM 文件末尾缺失的 CRLF 搞坏的构建比 Base64 本身的任何 bug 都多。
- 安全幻觉:环境变量、
.env文件或 Kubernetes secret 里的 Base64 不是加密。一行代码就能解码,任何语言、任何能读到文件或集群的人都可以。把它当作传输外衣,让真正的防线(权限、TLS、密钥轮换)继续负责保护。 - JSON 膨胀:JSON 里的 Base64 花 33% 加转义的代价,一个 5 MB 的上传变成 6.7 MB 的字符串,你的 JSON 解析器必须把它复制进内存。凡是文件级的东西走 HTTP,
multipart/form-data或裸二进制正文是更好的传输,Base64 留给通道纯文本的时候。 - 堆账单:编码后的字符串在 JavaScript 堆里是 UTF-16,每个字符两个字节,解码后或源 Buffer 是数据的第二份拷贝。一个 100 MB 的文件短暂意味着大约 270 MB 字符串加 100 MB Buffer。大的用流,并让编码形式保持被引用的时间尽可能短。
- Node 里的遗留全局:Node 自己的文档把
btoa()和atob()标记为 Stability 3, Legacy,并叫你用Buffer代替。在浏览器里btoa()处理 ASCII 文本是完全没问题的工具;在 Node.js 里,伸手去拿 Buffer,把全局函数留给需要它们的 polyfill 形态代码。
JavaScript 如何学会打包字节
浏览器一侧是一个漫长的安静故事。btoa() 在 2011 年初的 HTML5 草案中被写进规范,而自 2000 年代中期起它就坐在每个主流浏览器里,行为未曾改变,带着它每字符一字节的契约和永远带填充的输出。那个契约早于类型化数组 - 2009 年之前二进制字符串是携带字节的唯一方式 - 所以 btoa() 至今仍在用"二进制字符串"思考。故事的现代一半非常新:给类型化数组添加原生 Base64(连同十六进制)的 TC39 提案作为 ES2026 的一部分完成标准化,2024 年落在 Firefox 133 和 Safari 18.2,2025 年 9 月 2 日落在 Chrome 140,随后被宣布为 Baseline Newly available。Bun 在 2024 年 8 月的 1.1.22 版本里上线了同样的方法。
Node.js 用另一只时钟打包字节。Buffer 类在 2010 年夏天的 0.1.103 版本成为全局对象,比 Node 1.0 早了近五年,toString('base64') 是十多年来首选的编码器,带着那个时代的字母表怪癖(解码时它就已经接受 URL 安全字符了,一种规范从没要求过的双语习惯)。2021 年 1 月的 15.7.0 版本把 'base64url' 模式加为一等编码名,同年 Node 16 加入了浏览器的 btoa()/atob() 全局对象(立刻标记为 Legacy),2024 年的 Node 22 又交付了更多 V8 和 base64 性能工作。然后 Node 25 在 2025 年 10 月 15 日发布,把 V8 升级到 14.1,把 ES2026 方法带进运行时,toBase64() 带着它的 omitPadding 选项,setFromBase64() 负责反方向。对跟不上步伐的运行时,core-js 提供 polyfill(features/typed-array/to-base64 / from-base64),而小小的 base64-js 包(三个函数、零依赖)这些年作为传递依赖,安静地扛着整个生态。
它们服务的格式比这一切都老。这套字母表最早在 1987 年为 Privacy-Enhanced Mail 标准化(RFC 989),1993 年的修订版(RFC 1421)保留了它,MIME 在同一年晚几个月采用了它,带着 76 字符的折行;RFC 3548 在 2003 年合并了 Base-N 家族并添加了 URL 安全变体,RFC 4648 在 2006 年重新发布它。十年后,RFC 7515 和 7519 让无填充的 base64url 成为每个 JWT 的骨架,RFC 7636 把它放进 OAuth 的 PKCE 流程。这篇文章里的编码器,是一个三十年高龄、还在不断上客的格式的最后一段路。
聚会上值得一说的冷知识
btoa('GIF89a')返回"R0lGODlh",一个 GIF 的整个魔数头,就八个字符。这是二进制文件能用 Base64 说出的最小"你好",也难怪它是维基百科文章里的第一个 Web API 例子。toBase64()有一个omitPadding选项,这是btoa()永远不可能有的,因为 Web API 契约无条件加填充。同一套字母表走了两十年,较新的 API 能做一件较老的 API 从未被允许做的事。- 一套字母表,两个官方行宽:MIME 折在 76,PEM 折在 64。同样 64 个字符、同样的填充,两种 30 年历史的不同意见,关于一行文本可以有多宽。
- 33% 这个数字是精确的:每三个字节四个字符是 4/3 的比率,RFC 时代的邮件又为换行加了大约 3.5 个百分比。你的"小"配置字符串白白胖了 37%。
- 小小的
base64-js包在 npm 上每周下载量过亿,其中几乎全都藏在别的包的依赖树里。Base64 是 JavaScript 生态里被"走私"得最多的代码。 - 小 Buffer 不是一个个分配的:Node 从一个共享的 65536 字节池(
Buffer.poolSize)里切,这就是 Buffer 创建快的原因,也是为什么存在 "unsafe" 分配变体,留给上一个租户的数据无所谓的那些场合。 - 1998 年定义 data URL 的 RFC 警告它们"只对短值有用",引用的是 1024 字符的 HTML 属性限制。现代浏览器在同样的属性里把兆字节大小的图片以 data URL 嵌进去,这到底是进步还是傲慢,取决于你的主视觉图片。
- Unix 密码哈希用自己的 Base64 风味字母表,没有填充,而且令人困惑的是顺序随方案而变:经典
crypt(3)哈希用./0-9A-Za-z,而 JavaScript 项目为用户密码存储的$2b$bcrypt 字符串则把同样 64 个字符排成./A-Za-z0-9。这是个好提醒:安全语境下的 "Base64" 是一个家族,不是单一格式。 - Node 的解码器在
'base64'和'base64url'两种模式里都接受-、_、+和/,四个字符,一张表。编码器当然只讲你要求的那种方言。
往返的一半
在 JavaScript 和 Node.js 里编码 Base64,归结为三个决定:你握的是哪些字节(字符串需要字符集,Buffer 不需要),目的地要求哪种字母表(MIME 和 data URL 用经典,令牌和 URL 用 base64url,填充按上下文可选),格式还在强制执行哪些行规则(邮件 76、PEM 64、JSON 没有)。答完这些,内置函数干剩下的活:Node 里 Buffer.toString(),浏览器里 btoa() 加 UTF-8 桥梁,还有终于拿到这一件的现代运行时里的 Uint8Array.toBase64()。
而你在这里封好的每一个包裹,总有一天会有别人打开。解码那一侧有它自己的一整套陷阱:不声不响吞下垃圾的宽松解码器、atob() 递给你的二进制字符串外衣、发生在墙另一侧读者端的字符集抉择,以及镜像你刚学的携带模式的流式逻辑。那个故事,每个步骤都配有代码示例,在我们姊妹站点的 Base64 解码相关文章里讲得很深。接下来读它,因为字母表那一侧的陷阱更安静,而安静正是它们取胜的方式。
最后更新: 2026-09-08