[JS] JavaScript 中的 Base64——atob、btoa 与 Node Buffer
一份面向工程实战的 JavaScript Base64 指南。btoa/atob 什么时候用、为什么遇到 Unicode 就挂、Node.js 的 Buffer 如何工作,以及一个跨运行时的 base64url 辅助函数。
// 全景
JavaScript 与 Base64 的关系被历史切得支离破碎。浏览器从 1990 年代 Netscape 那里继承了 btoa() / atob()——这两个名字字面意思是 binary-to-ASCII 和 ASCII-to-binary。它们只能处理 Latin-1 范围内的字符,这也是为什么你把 emoji 或中文字符复制进 btoa() 就会直接抛错。
Node.js 则通过 Buffer 类走了自己的路。它支持几乎所有你会用到的编码(utf8、base64、base64url、hex、latin1、ascii),能原生处理二进制数据,是服务端代码里显而易见的首选。
现代运行时(Node 18+、Deno、Bun、现代浏览器)还提供了 TextEncoder / TextDecoder——这是标准化的办法,把 Unicode 字符串先转成字节再做 Base64 编码。知道在哪种场景下该选哪种工具,就已经成功了一半。
// 浏览器:btoa / atob——正确用法
btoa(str) 接受一个字符串,其中每个字符必须在 1 个字节内(Latin-1 范围,U+0000 到 U+00FF),并返回一个 Base64 字符串。atob(b64) 是其逆操作。它们是同步的、快速的,并且自古以来每个浏览器都内置。
它们的正确用法是:对已经基本是字节形态的数据做 Base64。比如从 FileReader 读出的原始字节、从二进制 XMLHttpRequest(responseType: 'arraybuffer')里得到的字节、或 Uint8Array 这类每个字节数值都在 0–255 之间的数据。
// Encode a Latin-1 string
const latin1 = 'Hello, world!';
const encoded = btoa(latin1); // 'SGVsbG8sIHdvcmxkIQ=='
// Decode it back
const decoded = atob(encoded); // 'Hello, world!'
// Encode a binary Uint8Array (the common real-world case)
const bytes = new Uint8Array([0xFF, 0xD8, 0xFF, 0xE0]); // JPEG magic
const binStr = String.fromCharCode(...bytes); // 'ÿØÿà' (Latin-1)
const b64 = btoa(binStr); // '/9j/4A=='
// 为什么 btoa() 遇到 UNICODE 就抛错
在浏览器控制台里试 btoa('héllo')。你会得到 InvalidCharacterError: The string to be encoded contains characters outside of the Latin1 range。é(U+00E9)没问题,但 btoa('日本語') 会挂,因为 日(U+65E5)超出了 Latin-1。这个函数根本没有 UTF-8 的概念;它只是把 JavaScript String 里的每一个 code unit 当作一个字节来读。
解决办法是:先把 Unicode 字符串转为 UTF-8 字节,再对这些字节做 Base64。任何现代浏览器都内置了 TextEncoder 来干这件事。
// ❌ broken
// btoa('日本語') → throws InvalidCharacterError
// ✅ correct: encode to UTF-8 first
function b64encodeUtf8(str) {
const bytes = new TextEncoder().encode(str);
let bin = '';
for (const byte of bytes) bin += String.fromCharCode(byte);
return btoa(bin);
}
b64encodeUtf8('日本語'); // '5pel5pys6Kqe'
// ✅ round-trip decode
function b64decodeUtf8(b64) {
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
b64decodeUtf8('5pel5pys6Kqe'); // '日本語'
// NODE.JS:BUFFER 套路
在 Node 里请优先用 Buffer。它默认感知 UTF-8,支持市面上一切编码,并且原生地处理二进制。Node 16+ 还识别 'base64url' 作为一种编码名,因此做 URL-safe 输出不需要再额外写辅助函数。
// String → Base64
const encoded = Buffer.from('héllo 世界').toString('base64');
// 'aMOpbGxvIOS4lueVjA=='
// Base64 → string (UTF-8 by default)
const decoded = Buffer.from(encoded, 'base64').toString();
// 'héllo 世界'
// URL-safe (Node 16+): +/- omitted padding
const safe = Buffer.from('héllo 世界').toString('base64url');
// 'aMOpbGxvIOS4lueVjA' ← no = padding, uses -_
// Binary file → Base64
const fs = require('node:fs');
const imgB64 = fs.readFileSync('logo.png').toString('base64');
// Base64 → binary file
fs.writeFileSync('out.png', Buffer.from(imgB64, 'base64'));
// BUFFER 与 btoa/atob 的快速对比
- > Unicode:Buffer 原生感知 UTF-8;btoa/atob 只认 Latin-1 字节。
- > 二进制:Buffer 是 Node 的原生二进制类型;浏览器必须借助 Uint8Array + String.fromCharCode。
-
>
URL-safe:Buffer 把
'base64url'作为头等编码支持(Node 16+);浏览器需要自己写正则替换。 - > 性能:Buffer 由 C++ 实现,在大输入上比 atob/btoa 快 3–5 倍。
- > 流式:Buffer 可以接到 transform stream 里处理大文件;btoa/atob 无法流式处理。
- > 可用性:Node 16+ 里 btoa/atob 也是全局,但能选的时候优先选 Buffer——它能覆盖边界情况。
// 在浏览器里编/解码二进制文件
浏览器里把文件选择器选中的文件转成 Base64 字符串的经典套路是 FileReader.readAsDataURL(),它直接返回一个可用的 data URI。如果你只要纯 Base64 载荷,就把 data:...;base64, 前缀切掉。
对于超大文件,改用 readAsArrayBuffer() 读取并分块编码,以避开 String.fromCharCode(...bytes) 在超过 ~65,000 个元素时遇到的 'maximum call stack size exceeded' 错误。
// ▸ Simple: file → data URI → strip prefix
async function fileToBase64(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => {
const dataUri = reader.result; // 'data:image/png;base64,iVBOR...'
resolve(dataUri.split(',')[1]); // raw Base64 only
};
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
// ▸ Large files: chunked btoa() to avoid stack overflow
async function arrayBufferToBase64(buffer) {
const bytes = new Uint8Array(buffer);
const chunkSize = 0x8000; // 32 KB chunks
let binary = '';
for (let i = 0; i < bytes.length; i += chunkSize) {
binary += String.fromCharCode.apply(null,
bytes.subarray(i, i + chunkSize));
}
return btoa(binary);
}
// Usage
const file = inputEl.files[0];
const buf = await file.arrayBuffer();
const b64 = await arrayBufferToBase64(buf);
// 在浏览器里把 BASE64 解码回字节
// ▸ Base64 → Uint8Array (binary bytes)
function base64ToBytes(b64) {
const bin = atob(b64);
const bytes = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i);
return bytes;
}
// ▸ Base64 → Blob → downloadable
function base64ToBlob(b64, mime = 'application/octet-stream') {
const bytes = base64ToBytes(b64);
return new Blob([bytes], { type: mime });
}
// Trigger a download
const blob = base64ToBlob(b64, 'image/png');
const url = URL.createObjectURL(blob);
Object.assign(document.createElement('a'), { href: url, download: 'out.png' }).click();
URL.revokeObjectURL(url);
// JavaScript 中的 URL-SAFE BASE64(base64url)
标准 Base64 用到 + 和 /,这两个在 URL 里都是保留字符。末尾一个或两个 = 还会被百分号编码成 %3D,白白浪费空间。RFC 4648 §5 定义了 URL-safe 变体(base64url):+ 换成 -,/ 换成 _,并且不保留填充。
在 Node 16+ 上,直接用 Buffer.from(x).toString('base64url')。在浏览器里,则在 btoa() 之上做一层后处理。
// ▸ Browser: standard Base64 → URL-safe Base64
function toBase64Url(b64) {
return b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// ▸ URL-safe Base64 → standard Base64 (before feeding atob)
function fromBase64Url(b64u) {
let b64 = b64u.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '='; // re-add padding
return b64;
}
// Round-trip
const token = toBase64Url(btoa('hello+world/'));
// 'aGVsbG8rd29ybGQv'
atob(fromBase64Url(token)); // 'hello+world/'
// 常见坑与如何排查
-
>
btoa() 抛出 InvalidCharacterError——你的输入含有 Latin-1 之外的字符。修复:先用
TextEncoder转成 UTF-8 字节。 -
>
atob() 抛出 InvalidCharacterError——输入不是合法的 Base64,通常是 base64url 少了填充,或是混入了空白字符。去掉空白、补回
=填充。 -
>
解码后 Unicode 乱码——你直接用
atob()解码而没经过TextDecoder。UTF-8 的多字节序列会变成乱码(mojibake)。 -
>
'Maximum call stack size exceeded'——你对一个超过 ~65,000 字节的数组调用了
String.fromCharCode(...bytes)。修复:按 32 KB 分块(见上文循环)。 - > 浏览器和 Node 输出不一致——通常意味着一边是 URL-safe、另一边是标准。应在交界处统一到同一个变体。
-
>
载荷被重复编码——数据被 Base64 了两次。检查:
atob(input)结果看起来是否又像 Base64?如果是,再解一次。 - > JSON 中的 Base64 被多次 JSON 编码——你把 Base64 字符串丢进 JSON.stringify,接收方解析 JSON 拿回原字符串,但你的服务端代码又额外编了一次。抓一下线上 payload 看看。
// 跨运行时辅助函数(浏览器 + NODE + DENO + BUN)
// A portable Base64/base64url helper. Uses Buffer when available,
// falls back to btoa/atob + TextEncoder in the browser / Deno.
const hasBuffer = typeof Buffer !== 'undefined';
export function encode(str, { urlSafe = false } = {}) {
if (hasBuffer) {
return Buffer.from(str, 'utf8').toString(urlSafe ? 'base64url' : 'base64');
}
const bytes = new TextEncoder().encode(str);
let bin = '';
for (const b of bytes) bin += String.fromCharCode(b);
const b64 = btoa(bin);
return urlSafe
? b64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
: b64;
}
export function decode(b64, { urlSafe = false } = {}) {
if (hasBuffer) {
return Buffer.from(b64, urlSafe ? 'base64url' : 'base64').toString('utf8');
}
if (urlSafe) {
b64 = b64.replace(/-/g, '+').replace(/_/g, '/');
while (b64.length % 4) b64 += '=';
}
const bin = atob(b64);
const bytes = Uint8Array.from(bin, c => c.charCodeAt(0));
return new TextDecoder().decode(bytes);
}
// Usage
encode('héllo 世界'); // 'aMOpbGxvIOS4lueVjA=='
encode('héllo 世界', { urlSafe: true }); // 'aMOpbGxvIOS4lueVjA'
decode('aMOpbGxvIOS4lueVjA=='); // 'héllo 世界'
// 性能笔记
- > Buffer 最快——Node 里它跑在 C++,处理 MB 级输入也不抖。
- > btoa/atob 在小载荷(< 100 KB)上有一定竞争力,但它们是同步的,大输入会阻塞主线程。
-
>
浏览器里处理 > 10 MB 的输入,请用 Worker,把编码放到主线程之外——或者使用
Blob.text()+FileReader.readAsDataURL(),这两个 API 内部做了优化。 - > 避免反复编解码——每一次往返在时间和内存上都是 O(n)。如果会多次使用,缓存其 Base64 形式。
-
>
流式——对于真正超大的载荷(100 MB+),用 Node 的
stream.Transform或浏览器的TransformStreamAPI 配合一个分块 Base64 编码器。
// 相关阅读
• Base64 是什么?它是如何工作的?——从零讲清 6-bit 分组。
• 用 Base64 处理 UTF-8 与 Unicode——TextEncoder 的完整舞步。
• URL-safe 与标准 Base64 的差别——什么时候需要 -_ 而不是 +/。
• 在线试一下 Base64 编码器——粘贴文本,看编码结果,复制。
如果你经常伸手去按 btoa(),把这一页收藏起来——十次里有九次你想 Google 的答案就在这里。