需要使用 Base64 格式吗?那么本网站正好适合您!使用我们的在线工具对数据进行编码或解码,便捷好用。

JavaScript/Browser 中的 Base64 编码:完整指南

你手上有样东西需要上路,而这条路只容得下纯 ASCII。它可能是一张本该待在 JSON 响应里的图片、一个必须跟着 URL 同行的配置对象、一个由点号和字母组成三段的令牌,还有一个 API 坚持要求以 Base64 字符串形式装进 JSON 体里送达的文件。Base64 就是为这种情况设立的收费站,本站首页已经把格式一步步讲过 - 四个可打印字符代表每三个字节,再用 = 填充给这一组收尾 - 所以阅读时请把这一个数字记在脑子里:编码是变大的那个方向。你每交出去三个字节,就换回四个字符,约 33% 的尺寸税,按带宽、存储和内存收取。当通道要求可打印文本时就用 Base64,并且清楚地知道这笔税要花掉你什么。

令人鼓舞的消息是:浏览器从来就能不靠任何包干好这份活儿。btoa() 从 2000 年代初就开始随浏览器发货,TextEncoder 十年前就把你真正的 Unicode 文本变成了老老实实的字节,而 Baseline 2025 这一波里,平台终于补上了 Uint8Array.toBase64(),它能直接编码字节数组,还带一个 URL 安全字母表的选项。这篇文章就是一张决策地图:哪种工具配哪种活儿、锋利的边缘藏在哪里(它们全都追溯到同一条边界),以及那些你真正会被要求产出 Base64 的地方的具体配方。

挑选你的编码器

如今已经不存在唯一的 "那个" 编码器了,而伸手拿错那一个,正是经典 bug 的诞生方式。下面这张表就是完整的决策树:

情境 伸手去拿
纯 ASCII 文本,一次性值 btoa(text)
带重音、emoji、CJK 的真正文本 new TextEncoder().encode(text),然后 btoatoBase64
字节已经在 Uint8Array 2025 年以后的浏览器用 bytes.toBase64(),其他地方用分块的 btoa
URL、JWT、文件名 toBase64({ alphabet: 'base64url', omitPadding: true })
老浏览器或共享代码库 js-base64,或者经典的 TextEncoder + btoa 配方

表底下藏着的模式:btoa() 只读单字节字符,所以任何不是 ASCII 的东西都必须先变成字节数组,而现代 API 正是围绕那个字节数组建起来的。把 "文本变成字节,字节变成 Base64" 记在脑子里,本文每一个配方都是同样的两步,只是换了名字。

btoa 与 Latin1 边界

btoa(stringToEncode) - 二进制字符串到 ASCII 字符串 - 是最初的编码器,在所有要紧的浏览器里都有(Chrome 4、Firefox 1、Safari 3、IE 10 及以上、所有 worker 作用域,还有从 16 版开始的 Node)。它的约定只有一条款,而所有出错的地方都在这一个条款上:输入里的每个字符,码点必须在 0 到 255 之间。这个函数读的是码点,不是 UTF-8 字节,所以 "é"(码点 233)能顺利通过,而 "你"(码点 20320)在任何一个字符被编码之前就会抛出一个名叫 InvalidCharacterErrorDOMException。这条边界不是 "ASCII",也不是 "Unicode",它精确地就是 256,而且包含最底部的那些控制字符 - 编码一个 NUL 字节是合法的、有意义的,而这正是这个函数存在的原因之一。

完整行为,逐行来看:

输入 结果
"Hello, World!" "SGVsbG8sIFdvcmxkIQ==" - 教科书案例
""(空字符串) "" - 空进空出
"\u0000"(NUL) "AA==" - 控制字符是一等公民
"a\u00e9z"(é,码点 233) "Yel6" - 整个 Latin1 范围都过得去
"\u0100"(码点 256) 抛出 InvalidCharacterError - 刚过边界一步
"h\u4f60"(你,码点 20320) 抛出 InvalidCharacterError - 每个 emoji 也一样,因为它们全都远超 255

两条实用备注。错误消息因引擎而异 - Firefox 会说 "字符串包含无效字符",Chrome 会说字符串 "包含 Latin1 范围之外的字符" - 所以在任何防御性代码里,你都按异常名捕获。而且抛出发生在第一个出格的字符上,不是在末尾:btoa 不会把字符串编码一半再道歉。当你确实有意想要 Latin1 行为(编码一个故意用 0-255 码点搭出来的字节字符串)时,这个函数干的正是你要求的事,而上面那张表就是它全部的性格。

字节之桥

那么问题就变成了:真正的数据 - 你文本的 UTF-8 字节、文件的内容、canvas 的输出 - 是怎么进入 btoa() 的输入的?答案就是 "字节之桥":一个每个字符都装着一个字节值的 JavaScript 字符串,正是解码器产出的那种技巧,btoa 天然就懂它。朴素版本是一个循环:

function bytesToBase64 (bytes) {
  let binary = '';
  for (let i = 0; i < bytes.length; i += 1) {
    binary += String.fromCharCode(bytes[i]);
  }
  return btoa(binary);
}

正确,但在循环里做字符串拼接对大文件来说很慢,而那个流行的捷径 - String.fromCharCode.apply(null, bytes),把整个数组作为参数在单次调用里喂进去 - 有一道硬悬崖。函数调用对参数个数有上限,而你远在第一个兆字节之前就撞上了它:

const big = new Uint8Array(1000000);
btoa(String.fromCharCode.apply(null, big));
// RangeError in Firefox: "too many arguments provided for a function call"
// RangeError in Chrome: "Maximum call stack size exceeded"

那个救回了比任何其他单一改动都多文件上传功能的修法,就是分块过桥,一次几千个字符,然后把结果拼起来:

function bytesToBase64Chunked (bytes) {
  const CHUNK = 0x8000;
  const parts = [];
  for (let i = 0; i < bytes.length; i += CHUNK) {
    parts.push(String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK)));
  }
  return btoa(parts.join(''));
}

每个块都小到足以安全地 apply,subarray 给出不拷贝的视图,而 join 产出的二进制字符串和循环版本一模一样。现在看硬币的文本一面。对任何真正的文本,TextEncoder - 平台的 UTF-8 编码器,从 Firefox 18、Chrome 38、Safari 10.1 起到处都有 - 会在桥开工之前把你的字符串变成老老实实的字节:

const bytes = new TextEncoder().encode('hello 你好');
const base64 = bytesToBase64Chunked(bytes);
console.log(base64); // "aGVsbG8g5L2g5aW9"

那个输出就是 "hello 你好" 在线上真正摸起来的样子:六个 ASCII 字节,加上两个中文字符的六个 UTF-8 字节,全都穿着同一副可打印的伪装。如果你的文本不是 UTF-8 - 而在 Web 上它通常就是 - 你就得先要那个别的字符集,这意味着得到会说那个字符集的地方去编码它,通常是服务器。TextEncoder 故意拒绝猜测,而它是正确的。

2025 年的捷径:Uint8Array.toBase64

如果你手里已经握着一个 Uint8Array,桥就是个绕路,因为新的 ECMAScript(ES2026)特性直接编码数组:bytes.toBase64(options)。它随 Chrome 140、Edge 140、Firefox 133、Safari 18.2、Node 25 和 Deno 2.5 落地 - 和它的解码兄弟同属 Baseline 2025 这一波 - 而且它接受两个选项,这两个选项把它变成了平台上最万能的编码器。第一个是 alphabet"base64"(默认)或 "base64url"。第二个是 omitPadding:把它设成 true,末尾的 = 字符就被丢掉,而这正是大多数 URL 友好的消费者想要的形状。传入其他任何东西当选项会抛出 TypeError,这是 API 对你的笔误保持礼貌的方式:

const bytes = new Uint8Array([251, 255]);
console.log(bytes.toBase64()); // "+/8="
console.log(bytes.toBase64({ omitPadding: true })); // "+/8"
console.log(bytes.toBase64({ alphabet: 'base64url' })); // "-_8="

那两个字节是被挑出来专门对字母表极不客气的:在标准模式下它们产出 +/,所以最后一行恰好展示了切换到 base64url 时变了什么。性能是悄悄附赠的好处:在较新的 Firefox 上,编码十兆字节用 toBase64 只要大约五毫秒,而上面的字符串桥路径要慢大约十五倍,因为它在过程中造了一个巨大的中间字符串。在较老的浏览器上,桥对几兆字节以内的东西依然完全够用 - 而且你要用的是上面的分块版本,理由见上一节。

URL 安全的输出

Base64 有一个专门的变体,用在 +/= 会闯祸的地方,而且它值得单独一节,因为太多坏掉的代码只是标准 Base64 在路上撞见了 URL。在查询字符串里,+ 是空格;在路径里,/ 是分隔符;而 = 在某些位置想要百分号编码。RFC 4648 第 5 节里 URL 和文件名安全的字母表 - base64url - 把那两个字符换成了 -_,而且因为接收方通常知道数据长度,它允许把填充整个丢掉。输出穿过 URLSearchParams、路径段、片段和文件名,没有一个百分号。

用 2025 年的 API,这就是一个 options 对象的事:

const params = new URLSearchParams();
params.set('payload', bytes.toBase64({ alphabet: 'base64url', omitPadding: true }));
console.log(params.toString()); // "payload=-_8" - 完全不需要百分号编码

在较老的浏览器上,用 btoa 编码之后再转换。两个 replace 加一次 trim 就干完全活儿:

function toUrlBase64 (base64) {
  return base64
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}
console.log(toUrlBase64(btoa('hi?/x'))); // "aGk_L3g"

三条规则保持通道干净。每个通道挑一种字母表,然后咬住不放 - 一个混了 +- 的值不属于任何一家,没有任何解码器会猜你想用哪个。填充是契约,不是建议:你省略它,接收方就必须对无填充的值做好准备;你保留它,接收方就必须不被它噎住(浏览器是宽松的,有些 JSON schema 不是)。还要记住这个替换是可逆且无损的 - -_ 映射到 +/ 占据的同样的第 62 和第 63 个字母表位置,所以选这对更友善的字符不会丢失任何东西。

让图片上路:Data URL

Base64 在浏览器中最古老、也最显眼的用途就是 data URL:data:、一个可选的媒体类型、一个可选的 ;base64 标志、一个逗号,然后是载荷。文本载荷做百分号编码;二进制载荷 - 图片、字体、音频 - 用 Base64,浏览器用零个 HTTP 请求把它们渲染出来。对用户刚选好的图片文件,FileReader 替你完成编码,把现成的 URL 递回给你:

const reader = new FileReader();
reader.onload = () => {
  console.log(reader.result); // "data:image/png;base64,iVBORw0KGgo..."
  imageElement.src = reader.result;
};
reader.readAsDataURL(file);

结果是一个现成的 src,一个你可以存进 localStorage 的值,或者放进 JSON 体里发送。如果图片在 canvas 上 - 一张截图、一张处理过的照片、一张生成的图表 - canvas.toDataURL() 从最早的浏览器版本起就在干这份活儿,而且它甚至让你选格式,对有损格式还让你选质量:

const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.drawImage(photo, 0, 0);
const pngUrl = canvas.toDataURL('image/png');
const jpegUrl = canvas.toDataURL('image/jpeg', 0.8);

有三个坑要提前规划。第一,tainted canvas(污染画布)规则:如果你在没有 CORS 许可的情况下把跨域图片画到了 canvas 上,那么每次尝试读回像素 - 包括 toDataURL - 都会抛出 SecurityError。修法是加载图片时带上 crossOrigin = 'anonymous',并确保服务器发送正确的头。第二,quality 参数对 PNG 被忽略,只对 JPEG(和 WebP)有意义 - 这是 "为什么我的 PNG 更大" 的常见来源。第三,也是最要命的:载荷比文件大约大 33%,而且它以字符串的形式坐在页面里。对那些永远不会离开浏览器的图片,有一个免费的替代品 - object URL,它把 Blob 包起来却完全不做编码:

const objectUrl = URL.createObjectURL(blob);
imageElement.src = objectUrl;
URL.revokeObjectURL(objectUrl); // 用完之后

由此得出的分工:留在页面上的一切用 object URL,必须被复制、存储或作为文本发送的一切用 data URL。两者都是一等公民;它们只是在解决不同的问题。

构建并签名一个 JWT

如果你在浏览器里生成令牌 - 为自托管的认证流程、一个演示、或者一个 serverless 前端 - 紧凑 JWS 格式就是三段 base64url:header、payload、signature,到处都没有填充。Web Crypto API 负责签名;编码正是两节之前那个 URL 安全的输出:

const encoder = new TextEncoder();
const segment = (bytes) =>
  bytes.toBase64({ alphabet: 'base64url', omitPadding: true });
const header = segment(encoder.encode(JSON.stringify({ alg: 'HS256', typ: 'JWT' })));
const payload = segment(encoder.encode(JSON.stringify({ sub: '1234567890', name: 'John Doe' })));
const key = await crypto.subtle.importKey(
  'raw',
  encoder.encode('shared-secret'),
  { name: 'HMAC', hash: 'SHA-256' },
  false,
  ['sign']
);
const signature = segment(
  new Uint8Array(
    await crypto.subtle.sign('HMAC', key, encoder.encode(header + '.' + payload))
  )
);
const token = header + '.' + payload + '.' + signature;

有两个细节比管道本身更要紧。签名恰好覆盖 header + '.' + payload - 原始的段,不是 JSON - 所以对任何一部分的改动都会让令牌失效,而这正是它的意义所在。还有 crypto.subtle.sign 返回的是原始 ArrayBuffer,所以进段编码器之前要先用一行包进 Uint8Array。对基于 RSA 的令牌,流程用 RS256 和密钥对是完全一样的,而且如果你把一个公钥导出为 JWK(crypto.subtle.exportKey('jwk', key)),那些数字成员 - ne,以及私钥的 dpq - 会自动以无填充 base64url 的形式出来。安全上的告诫和任何令牌都一样:alg: "none" 的头是在请求跳过验证,时间声明(expnbf)必须被强制执行,而对同一个受众同时接受 HMAC 和 RSA 的服务器会打开经典的密钥混淆之门。正确编码,正确签名,在接收端验证。

认证头

Web 上最简单的认证方案,也是最能说明 Base64 是什么、不是什么的那个。HTTP Basic 发送 Authorization: Basic,后面跟着 username:password 的 Base64 - 一个调用,不需要字节桥,因为用户名和密码(希望如此)是纯文本:

const credentials = btoa('alice:secret123');
fetch('/api/me', {
  headers: { Authorization: 'Basic ' + credentials }
});
// Authorization: Basic YWxpY2U6c2VjcmV0MTIz

而这一课装得进一行:Base64 不是加密。上面的头距离 alice:secret123 只差一次 atob 调用 - 对攻击者如此,对读日志的人亦然 - 所以 Basic 认证只在 HTTPS 之上才能接受,在那里传输层才是真正的保护,Base64 只是格式。对任何寿命超过一个请求的东西,优先用基于令牌的方案:Bearer 令牌同样只是一个头,但它是随机值,其秘密根本不需要在头里携带,而且它可以被吊销。两者之间的编码选择微不足道 - 都是 btoa 或纯文本 - 但安全选择不是,而且它应该被有意识地做出。

文件进,文本出

上传是 33% 的税被换成真金白银报价的地方,因为文件通常是页面上最大的东西。有两条路,第一条是你默认应该走的那条:多部分表单数据。FormData 把文件作为原始字节装进一个标准的体里,由浏览器做帧封装,全程没有任何 Base64 - 没有尺寸税,没有中间字符串,字节在读到的同时就流向服务器:

const form = new FormData();
form.append('upload', file);
await fetch('/api/upload', { method: 'POST', body: form });

第二条路是给那些坚持要 JSON 体、文件以字符串形式出现的 API 的 - 一些 serverless 函数、一些移动后端、一些遗留服务。那里的编码每个文件一行,代价正是税单上写的那样:一个 5 兆字节的文件变成一个 6.7 兆字节的字符串,然后被序列化进 JSON,然后被发送。对照片还行,对视频就疼了:

const bytes = new Uint8Array(await file.arrayBuffer());
const body = JSON.stringify({
  name: file.name,
  content: bytes.toBase64()
});
await fetch('/api/upload-json', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body
});

在那条路上处理大文件时,不要用一次调用拼出一个巨大字符串 - 要分片构建,每个片是三字节的倍数。正是这种对齐让把戏合法:三字节的倍数编码成干净的四字符倍数、不带填充,所以独立编码的分片拼起来恰好等于整个文件的编码,而且只有最后一个片会带填充:

async function encodeLargeFile (file) {
  const bytes = new Uint8Array(await file.arrayBuffer());
  const SLICE = 3 * 1000 * 1000;
  const parts = [];
  for (let i = 0; i < bytes.length; i += SLICE) {
    parts.push(bytes.subarray(i, i + SLICE).toBase64());
  }
  return parts.join('');
}

同样的对齐思想,也解释了为什么你绝不应该在任意位置切开一个 Base64 字符串、还指望碎片能自己解码 - 一个三字节组才是原子,在组中间切一刀会留下悬空的碎片。下载是镜像版本:对生成的文件,小文件可以走下载链接上的 data URL 出去,但对任何有分量的东西,Blob 加 object URL 才是健康的路线,因为浏览器从一开始就无须把整个载荷当字符串携带。

保存与分享状态

还有两个纯文本通道,Base64 在那里干着真活儿。第一个是存储:localStoragesessionStorage 存的是字符串,所以结构化或二进制数据要编码之后才能进去。往返就是一次编码加一次解码,值得两边一起看,因为存储 bug 几乎总是两者之间的字符集不匹配:

const state = { theme: 'dark', draft: 'hello' };
const packed = new TextEncoder().encode(JSON.stringify(state));
localStorage.setItem('app-state', new Uint8Array(packed).toBase64());
const raw = atob(localStorage.getItem('app-state'));
const bytes = Uint8Array.from(raw, (c) => c.codePointAt(0));
const state = JSON.parse(new TextDecoder().decode(bytes));

不过要把它算进预算:每个源拿到大约 5 兆字节的 localStorage,你存的字符串比数据胖 33%,而页面打开期间这个字符串还以 UTF-16 住在内存里 - 长度又翻一倍。一个 3 兆字节的资源是 4 兆字节的存储加 8 兆字节的内存,这就是一个 "小" 功能变成配额错误的方式。第二个通道是 URL 本身:分享链接、深度链接和 OAuth state 都想要结构化数据待在一个能挺过复制粘贴的地方。配方是紧凑状态、JSON,然后是无填充的 base64url,这样值完全不需要百分号编码 - 整个 URL 要控制在一两千字符以内,因为超过这个长度,老客户端、代理和日志工具就开始紧张了。

邮件与 MIME

Base64 比 Web 老,而它的主场是邮件。带 Content-Transfer-Encoding: base64 的 MIME 附件,就是二进制文件搭乘文本协议的方式,而从消息格式旧有的 76 字符行限制延续下来的约定值得知道:把编码后的体按每行 76 个字符换行。浏览器发不了 SMTP,但它干两样邮件活儿 - 构建由后端中继发送的 MIME 体,以及展示所收到消息的附件 - 两者都碰到编码。换行本身是个两行的函数,而且操作顺序要紧:先编码,后换行,因为 btoa 不会在输入里的换行符上抛错 - 它会把这个断行编码成一个载荷字节,你的换行就这样钻进了输出里:

function wrapForMime (base64, width) {
  const w = width || 76;
  return base64.match(new RegExp('.{1,' + w + '}', 'g')).join('\r\n');
}

接收方是免费的那一方:atob 跳过 ASCII 空白本就是它的标准行为,所以一个换行包裹的 MIME 体原样解码 - 连换行符一起 - 不需要解包步骤。如果你在做 webmail 客户端或附件选择器,那唯一的一点不对称 - 编码器必须产出干净的行,解码器不在乎 - 就是 MIME 整个故事的一句话版本。

何时该伸手拿库

有了上面的原生工具,库很少是必要的,诚实的建议是:默认用平台,只有当真实需求指向某个包时才加包。真正会出现在代码库里的有三个:

js-base64(npm install js-base64)是通用型的那个:一个小巧的纯 JavaScript 转码器,把 UTF-8 字符串当一等公民 - 对 CJK 字符串调 Base64.encode,UTF-8 的舞蹈它替你跳 - 而且对解码和编码一样有用的地方是,decode 接受两种字母表,还附带一个 isValid 检查。当你面向的那些浏览器还没有 2025 年的 API、又想用一个 import 同时覆盖字符串和字节时,它就是正确答案:

import { Base64 } from 'js-base64';
const encoded = Base64.encode('小飼弾'); // "5bCP6aO85by+" - UTF-8 替你处理好了
const decoded = Base64.decode('5bCP6aO85by-'); // 标准与 URL 安全通吃
const valid = Base64.isValid(encoded); // true

base64-js 是字节导向的那个:对 Uint8ArrayfromByteArraytoByteArray,零依赖,老 browserify 生态的主力,而当你的代码住在类型化数组里、想让编码成为字节的纯函数时,它依然是个很好的选择。而如果你想要库的理由是 "我喜欢 2025 年的 API,但不能要求 2025 年的浏览器",答案压根不是一个 Base64 包,而是 polyfill:core-js(以及把它带进来的 Babel preset)实现了 Uint8Array.fromBase64 和朋友们,所以你可以只写一次新风格的代码,让 shim 在老引擎上补上缺口。按约束来挑 - 老浏览器、字符串便利、还是字节纯粹 - 而不是按习惯。

让开发者付出数小时代价的坑

  • 对一个含有码点超过 255 的字符的字符串调用 btoa。它会抛错,不会搞坏,而且停在第一个出格的字符上。修法永远一样:先 TextEncoder,后过桥。
  • fromCharCode.apply 在大数组上的悬崖。一百万个参数在两大引擎里都是 RangeError。分块过桥,或者迁到 toBase64
  • 在最疼的地方忘了尺寸税:存储。localStorage 里的文件比原文件大 33%,而且配额是按源算的,和你的应用存的一切共享。
  • 标准 Base64 撞上查询字符串。+ 到达时变成空格,/ 把路径弄断,bug 报告写着 "API 不稳定"。URL 安全的输出、不带填充,整类 bug 就此消失。
  • 各服务之间填充不一致。一个网关保留 =,另一个剥掉它,第三个又加回来。接收方必须对两种形状都做好准备,而契约应该写明哪一个是权威的。
  • 把 Base64 当锁。它是一种序列化格式,距明文只有一个函数调用,而安全评审里的 "用 Base64 编码过" 是一条发现项,不是一道控制措施。
  • 把二进制字符串当内存模型。解码或编码后的一兆字节,以 UTF-16 形式占两兆字节;Uint8Array 装它只占一兆。对大载荷,从头到尾让字节住在类型化数组里。
  • 双重编码。一个已经是 Base64 的值被又编码了一次,消费者解码一次,得到一串字母而不是数据。拿不准时,先检查再包 - 一个已经在字母表里、填充也有效的字符串,就是个坏味道。
  • 因为 JWT 载荷解码得很干净就信任它。可解码不等于真实。在读任何一条声明之前,先用正确的密钥和正确的算法验证签名。

性能:一百万字节要付多少

浏览器里的 Base64,在曾经昂贵的地方变便宜了,预算也从一条变成了三条。CPU:在较新的 Firefox 上,Uint8Array.toBase64 编码十兆字节大约五毫秒,而分块的 btoa 桥要慢大约十五倍 - 不是因为 btoa 慢,而是桥在路上造了一个巨大的中间字符串。如果你的编码预算以毫秒计,用原生方法;如果你在编码一个 2 千字节的配置对象,两者都在感知阈值之下。带宽:这是永久税 - 你编码的每个字节在线上花 1.33 字节,外加传输层加的任何帧封装。在你 "优化" 编码之前,先量一量传输。内存:编码后的字符串是你将做的最大一笔瞬时分配,对一个 5 兆字节的文件,它是一个 6.7 兆字节的字符串,也就是页面持有它时约 13.4 兆字节的 UTF-16 内存。实际后果从算术里掉出来:给大编码分片,别让任何单个字符串变得巨大;字符串一存在就释放中间字节;字节根本不需要可打印时,优先 object URL 和多部分;主线程必须保持滚动顺滑时,把多兆字节的工作搬进 Web Worker。这个格式将近四十年了;平台终于追上了它。

浏览器学会编码的历程

编码器有它的历史,而它解释了你将要继承的那些遗迹。btoa - "二进制到 ASCII",名字是字面意思,而 atob 就是同样的词倒过来 - 在 2011 年被写进 HTML 规范,是从早已发货它的浏览器逆向工程来的:Firefox 从 2004 年起,Safari 3,Chrome 4。Internet Explorer 一如既往地跳过了这两个函数,直到 2012 年的 10 版,而它这一个缺席,正是一整个十年 JavaScript 里满是手搓 Base64 查表、外加一句针对 Unicode 的特定咒语的原因:btoa(unescape(encodeURIComponent(str)))。它管用 - encodeURIComponent 产出百分号转义的 UTF-8,unescape 再把它变成字节字符串 - 但它建在 unescape() 上,那正是这一对被语言弃用的成员,而且它靠着纯粹的惯性在浏览器代码里又活了好多年。有原则的修法随 Encoding 标准而来:TextEncoderTextDecoder,Firefox 18(2013)、Chrome 38(2014)、Safari 10.1(2017),任何 IE 里都没有 - 又一个 IE 缺口,又一个十年的权宜之计。Node.js 讲了这个故事的服务端那一半:它从第一天起就有带 Base64 的 Buffer,但 atobbtoa 作为全局函数直到 2021 年的 16 版才出现,之前是两个小小的 npm shim 在扛。然后,横跨 2024 年底和 2025 年,语言本身发货了 Base64 - Uint8Array.toBase64 和朋友们,Firefox 133(2024 年 11 月)、Safari 18.2(2024 年 12 月)、Chrome 140(2025 年 9 月)和 Node 25(2025 年 10 月)- 而这个特性被标记为 Baseline 2025 - 平台用助手函数近似了二十年的同一套特性,如今成了标准。文章末尾的趣味琐事,主要讲的是每一块花了多久才到。

你知道吗?

  • 函数名是一句话:btoa 是 "二进制到 ASCII",atob 是 "ASCII 到二进制"。方向就在名字里,所以这一对从 2000 年代起就是自解释的。
  • 计算史上被编码最多的字符串大概是 "hello":btoa('hello')aGVsbG8=,地球上每个教程、测试套件和面试白板上的输出都是它。
  • 每个有效的 Base64 字符串,长度都是四的倍数,填充算在内。= 字符是指纹:有一个,说明最后一组装了两个字节;有两个,说明装了一个。
  • MIME 和大多数命令行工具里的 76 字符换行,是从邮件时代继承来的,那时消息格式的行长定了上限。这个数字挺过了三个一切都更快了的十年。
  • "Data URI" 是个退休的名字。WHATWG 把它改名成 "data URL",是那场宏大 URI 到 URL 协调运动的一部分,所以规范、博客帖子和包名会在同一段里各拼各的。
  • btoa('') 返回 '':空输入产出空输出,没有填充,没有特殊情况 - 唯一零字符的 Base64 字符串(它的长度 0 仍然是四的倍数)。
  • canvas 可以用 toDataURL 把一张照片变成 data URL - 这个能力从 IE 9、Firefox 2 和 Safari 4 起就存在,早于我们认为 "现代" 的 Web 平台的大部分 - 再用一个 <img> 标签和 FileReader 原样往返回来。
  • WebSocket 握手把 SHA-1(key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11) 编码成 Base64,而那个 GUID 是 RFC 里的固定常量,被精确地选出来,目的就是一台只会 HTTP 的服务器永远不可能意外地完成握手。

接下来去哪儿

在浏览器里做编码的整套手艺装得进一页:btoa 负责它生来就为的朴素单字节情形;TextEncoder 加分块桥负责任何浏览器上的真正文本和文件;Uint8Array.toBase64 带着它的字母表和填充选项负责那条现代的、直接的路;URL 安全的变体 - 带不带填充都行 - 负责一切将住在 URL 里的东西。剩下的就是判断:花掉 33% 的税之前先知道它,字节大的时候让字节住在类型化数组里,先编码后换行,永远别把序列化格式叫作锁。当通道能承载原始字节时,就拿字节 - Base64 是给那些只容得下可打印文本的路准备的,而现在你确切知道过路费该怎么付了。

旅程的另一半 - 收到这些字符串之一,把字节、文本和含义从里面重新拽出来 - 在下文链接的 JavaScript 中 Base64 解码伴读指南里有详细覆盖。

最后更新: 2026-09-08

相关文章: JavaScript/Browser 中的 Base64 解码:完整指南