JavaScript 中的 Base64 编解码 (UTF-8 及任意二进制数据)
使用 JavaScript 处理 UTF-8 文本字符串或任意数据的 Base64 编解码.

Base64 是一种将二进制数据编码为 ASCII 字符的编码方式. 使用 Base64 编码, 可以在媒介不支持传输或存储任意二进制数据的情况下, 避免数据在过程中发生损坏. 本文将主要介绍在 JavaScript 中处理 UTF-8 文本的 Base64 编解码的方式.

Base64 是一种将二进制数据编码为 ASCII 字符的编码方式. 使用 Base64 编码, 可以在媒介不支持传输或存储任意二进制数据的情况下, 避免数据在过程中发生损坏. 比如, 网站开发者常常会使用 Data URLs 来在 HTML 文档内嵌入一些小型文件.

Base64 的详情这里不作展开. 本文将主要介绍在 JavaScript 中处理 UTF-8 文本的 Base64 编解码的方式.

JavaScript 的字符串背景及 UTF-16 编码

在 JavaScript 中, 有 btoa() (binary to ASCII) 和 atob() (ASCII to binary) 这两个处理 Base64 编解码的函数. 如 MDN 关于 Base64 的术语解释页 所述, Base64 本身是对二进制数据进行编码, 而非针对于文本, 同时, 这两个函数的名称也示意着, 它们接收/返回的也是二进制 (binary) 数据. 只是在这两个函数加入 Web 平台时, 尚不存在适用于二进制数据的类型, 因此这两个函数使用字符串中每个字符的点位, 来表示二进制数据中每个字节的值.

这样的情况导致了一种常见误区: btoa 可以用来编码任意文本数据, atob 也可以用来解码文本数据. 事实上, 这种字符点位和字节之间的对应只限于 0x7f 以下的点位. 进一步来说, 0xff 以上的点位, 会令 btoa 函数抛出异常, 因为它超出了一个字节所能表示的最大数值. 所以, 在编码任意 Unicode 文本时, 需要针对该情况进行特殊处理.

JavaScript 中的字符串, 其内在是一串连续的 UTF-16 码元 (code units). 在 UTF-16 编码下, 每个码元都是 16 位长, 也就意味着, 使用单独的一个 UTF-16 码元, 可以表示 216 (或者说 65536) 种可能的字符. 但很显然, Unicode 中的字符总量远远超过这个数目; 在 UTF-16 下, 这之外的字符通过 surrogate pairs (代理对) 的形式, 也就是一对 16 位码元来表达. 为了避免歧义, 这一对码元必须位于 0xD8000xDFFF 之间, 这些点位也不能用来编码单码元的字符. 每个 Unicode 字符, 都有一个对应的 Unicode 代码点位 (Unicode code point), 可用 1 或 2 个 UTF-16 码元来表示. 每个 Unicode 代码点位可以使用 \u{xxxxxx} 以在字符串中表示, 其中 xxxxxx 表示 1-6 个十六进制数位.

Unicode 的编码空间从 U+0000 到 U+10FFFF, 一共有 1112064 个码位 (code point) 可以用来映射字符. Unicode 的编码空间可以划分为 17 个平面 (plane), 每个平面包含 216 (65536) 个码位. 17 个平面的码位对应到从 U+xx0000 开始到 U+xxFFFF 的码位 (其中 xx 是从 0x000x10 的十六进制值, 共 17 个). 第一个平面称为基本多语言平面 (Basic Multilingual Plane, BMP), 或第零平面 (Plane 0), 其它平面则称为辅助平面 (Supplementary Planes). 在基本多语言平面内, U+D800 和 U+DFFF 之间的码位永久保留, 不会映射到 Unicode 字符. UTF-16 可以利用这一区间内的码位来编码辅助平面内字符对应的码位.

具体的 UTF-16 编码方式这里不作展开, 这里仅简单介绍一下代理对中前导代理和后尾代理的概念. 起始的代理 (surrogates), 也被称作高位代理 (high-surrogate code units) 或 前导代理 (lead surrogates), 其取值应在 0xD8000xDBFF 之间 (包含起始), 而结尾的代理点位 (surrogates), 也称作低位代理点位 (low-surrogate code units) 或 后尾代理 (trail surrogates), 其取值应在 0xDC000xDFFF 之间 (包含起始).

可见, 在 UTF-16 中, 前导代理, 后尾代理 以及 BMP 中有效字符的码位互不重叠, 因此在进行字符串搜索等处理时, 不用担心不同字符编码之间会存在重叠的部分. 也可以说, UTF-16 是 自同步 (self-synchronizing) 的, 只给定一个码元, 即可判断其是否是一个字符的起始码元.

在编码领域中, 自同步代码是可以被唯一解码的代码; 从它的代码字 (code word) 中取出一部分符号流, 或者从两个相邻代码字中取出的重叠部分, 都不能作为合法的代码字. 或者用另一种方式来解释, 使用一个字母表构成的一个字符串 (即 “代码字”) 集合, 如果使用任意两个代码字组成的新的字符串中, 从第二个字符直至倒数第二个字符中, 不包含任何可作为代码字的子串, 则可以称其为自同步代码.

出现在字串末位或非末尾代理之前的前导代理 (位于 0xD8000xDBFF 范围 (含起始) 间的码元), 以及出现在字串首位或非前导代理之后的末尾代理 (位于 0xD8000xDBFF 范围 (含起始) 间的码元), 被称作 “lone surrogate” (孤独代理), 本身不能表示任何 Unicode 字符. JavaScript 的大多数函数都是基于 UTF-16 码元工作, 因此能够处理这些码元. 但如果要和其它系统进行交互, 比如使用 encodeURI() 将特定字符根据 UTF-8 编码替换为若干转义序列 (escape sequences) 时, 由于 UTF-8 编码不对 “long surrogate” 进行编码, 因此会在遇到 “lone surrogate” 时发生异常.

不包含 “lone surrogate” 的 JavaScript 字符串被称作 “well-formed” (格式良好的), 可以安全地被 encodeURI()TextEncoder 使用. 可以使用 isWellFormed() 判断字符串是否包含 “lone surrogate” (以便进行进一步处理) 或使用 toWellFormed() 将字符串中的 “lone surrogate” 替换为 U+FFFD (Unicode replacement character).

需要注意的是, 在一些情况下 (比如使用 TextEncoder 进行编码), “ill-formed” (格式不良好的) 的字符串, 也就是包含 “lone surrogate” 的字符串, 其中的 “lone surrogate” 会被 自动 替换为 U+FFFD; 在被渲染显示时, “lone surrogate” 也通常被渲染为 U+FFFD, 也就是 这个中间带有问号的菱形符号.

尽管 Unicode 标准中, 包括 UTF-16 在内的各种 UTF 编码, 不会对 “surrogate pair” 对应的点位进行编码, 对 “lone surrogate” 的编码应视作错误, 但是 UCS-2, UTF-8, 以及 UTF-32 都能很自然地编码这些点位; 而在 UTF-16 中, 直接使用不成对的代理码元, 就能无歧义地编码它的对应点位, 虽然结果并不是合法的 UTF-16. 因此, 使用这些点位记录数据从理论上可行, 但是在处理时需要特别注意, 不然可能会造成数据损失.

除了 Unicode 字符之外, 还有一些特定的 Unicode 字符序列, 应当被视作一个可视单元 (visual unit), 这样的序列也被称作 “grapheme cluster”.

“grapheme” 称作 “字位” 或 “字素”, 是最小的有意义的书写符号单位; 比如小写英文字母 a 和大写英文字母 A 是两个 “glyph (字形)”, 但是同属一个 “字位” <a>. 而在中文中, 同一个字可以有不同写法 (如正体字, 简化字, 异体字), 但是都计算为同一个字位.

在 emoji 中便存在很多 “grapheme cluster”, 属于常见的场景了. 比如, 很多 emoji 存在一系列变体, 其事实上由多个 emoji 组成, 通常使用 <ZWJ> (U+200D, Zero Width Joiner, 零宽连字) 这一控制字符连接在一起.

一些书写系统中, 字素的位置和形态取决于其和其他字素之间的关系, 比如阿拉伯文字和婆罗米系文字, 或者德文尖角体 (Fraktur, 亦称破碎体) 下的拉丁文字 (也称罗马字体); 在计算机中排版这些书写系统下的文字时, 就会用到这些控制字符. ZWJ 的具体用法, 取决于 “conjunct consonant” 或 “ligature” (译作 “合字” “连字” 或 “连体字”) 的使用是否是默认的. 比如在天城文 (Devanagari) 中, 它被用来阻碍 conjunct 的形成, 而在僧家罗文中则相反 (其默认不使用合字). Emoji 中的使用和后者类似, 通过在两个或多个 emoji 之间放置 ZWJ, 来形成展示出来的单个字形, 比如表示家庭的 emoji, 就是由两个表示成人的 emoji 和一到两个表示孩子的 emoji 所组成的.

因此在对字符串进行迭代访问时, 需要注意所访问的 “字符” (“character”) 层级. 这里使用引号来强调 “字符”, 是因为比如使用 split("") 分割字符串时, 所得到的是一系列 UTF-16 码元, 而非用户所能感知的单个字符 (比如 “grapheme clusters”) 或单个 Unicode 字符 (即 code point, 代码点位), 也就是说, 字符串中的 “surrogate pair” 会被破坏. 而使用字符串的 @@iterator() 方法时, 比如 ... 展开语法或者 for...of 循环, 则会按照 Unicode 代码点位进行迭代.

这里摘选 MDN 上给出的几个代码示例:

"😄".split(""); // ['\ud83d', '\ude04']; splits into two lone surrogates

// "Family: Man, Boy"
[..."👨‍👦"]; // [ '👨', '‍', '👦' ]
// splits into the "Man" and "Boy" emoji, joined by a ZWJ

在 JavaScript 中 Bases64 编解码字符串

如前文所述, 若要在 JavaScript 中不进行另行实现的情况下使用 Base64 算法编码 (即 btoa 函数), 需要使用各个字符均在 0x00 - 0xff (一个字节) 以内的字符串, 亦即 “bytes string” (字节串) 或者 “binary string” (二进制字符串). 类似地, atob 解码后得到的 “字符串”, 实际上也是一个 “bytes string”, 仅当每个字节都恰好对应 UTF-16 编码下的字符时, 这个 “字符串” 才有字符内容上的意义. 而涉及到这之外的字符, 就需要进行恰当的编解码了.

一些文章中使用 decodeURIencodeURI 来将字符串中不符合 URI 规范的字符替换为 UTF-8 编码的转义序列, 这样得到的结果中仅包含 ASCII 字符, 因此在使用 btoaatob 进行编解码时是安全的. 也有的方案会进一步将 %xx 这样的转义序列中的十六进制数对, 替换为对应数码下的字符, 去掉了冗余数据, 可以提升存储和传输的效率; 这样的字符串中, 也是只包含 0xff 以内的字符. (参见 Using Javascript’s atob to decode base64 doesn’t properly decode utf-8 strings - Stack Overflow)

atobbtoa 的若干年后, JavaScript 不仅加入了可以用于处理二进制数据的 TypedArray, 也提供了将字符串编码到 UTF-8 的 TextEncoder, 以及可以解码多种字符集编码的 TextDecoder. 使用这些功能可以进一步简化代码的编写. MDN 关于 Base64 的术语解释页 上就给出了这样的例子:

function base64ToBytes(base64) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0));
}

function bytesToBase64(bytes) {
  const binString = Array.from(bytes, (byte) =>
    String.fromCodePoint(byte),
  ).join("");
  return btoa(binString);
}

// Usage
bytesToBase64(new TextEncoder().encode("a Ā 𐀀 文 🦄")); // "YSDEgCDwkICAIOaWhyDwn6aE"
new TextDecoder().decode(base64ToBytes("YSDEgCDwkICAIOaWhyDwn6aE")); // "a Ā 𐀀 文 🦄"

不同于 charCodeAt() 方法返回对应下标处 UTF-16 码元的数值, codePointAt() 方法返回的是对应下标位置处, 字符的 Unicode 点位数值. 不过, 由于 codePointAt() 方法会在下标越界时返回 undefined, 因此 TypeScript 会在 base64ToBytes 调用 Uint8Array.from() 时提示类型错误. 可以通过添加 as 指定 codePointAt() 调用处返回的类型. 笔者尝试为这些代码添加了类型提示, TypeScript 代码示例如下:

function base64ToBytes(base64: string) {
  const binString = atob(base64);
  return Uint8Array.from(binString, (m) => m.codePointAt(0) as number);
}

function bytesToBase64(bytes: Uint8Array) {
  const binString = Array.from(bytes, (byte) =>
    String.fromCodePoint(byte),
  ).join("");
  return btoa(binString);
}

读者也可以 在 TypeScript Playground 上尝试这些代码.

这些方法在浏览器中是普遍支持的, 而 Node.js 也在 8.3.0 版本 (2017 年 8 月) 支持了 TextEncoder. 这样, 同样的代码, 既可以在 Web 前端实现功能, 也能在服务器端使用.

在 JavaScript 中 Bases64 编解码任意二进制数据

上文中的 base64ToBytesbytesToBase64 即可实现从 Base64 到 Uint8Array 的转换, 只是使用 TextEncoderTextDecoder 将其应用到了字符串之上.

如果为了更高的效率, MDN 的页面上还给出了使用 FileReaderfetch API 的 Base64 解决方案.

async function bytesToBase64DataUrl(bytes, type = "application/octet-stream") {
  return await new Promise((resolve, reject) => {
    const reader = Object.assign(new FileReader(), {
      onload: () => resolve(reader.result),
      onerror: () => reject(reader.error),
    });
    reader.readAsDataURL(new File([bytes], "", { type }));
  });
}

async function dataUrlToBytes(dataUrl) {
  const res = await fetch(dataUrl);
  return new Uint8Array(await res.arrayBuffer());
}

// Usage
await bytesToBase64DataUrl(new Uint8Array([0, 1, 2])); // "data:application/octet-stream;base64,AAEC"
await dataUrlToBytes("data:application/octet-stream;base64,AAEC"); // Uint8Array [0, 1, 2]

同样, 读者也可以 在 TypeScript Playground 上尝试上述代码.

另见: The nuances of base64 encoding strings in JavaScript | Articles | web.dev

速度评测

以上介绍了两种进行 Base64 编解码的思路, 一种是利用现有的 atobbtoa 函数, 另一种(也)是利用(现有的)FileReader 和 Fetch API.

出于历史原因, 使用 btoa 函数时, 需要先将字节流转换为 JavaScript 中的字符串, 该字符串中, 每个字符对应的数码, 为对应字节的数字表示; 类似地, 使用 atob 函数解码 base64 后得到的不是字节流, 而是“字节字符串”.

因此, 针对使用 atobbtoa 函数进行的 Base64 编解码, 其性能方面, 需要考虑的是“字节字符串”和字节流之间的转换.

字节串到 “字节字符串” 的转换

这里笔者首先使用 jsbenchmark.com 进行了字节串到字符串之间的转换测评 (链接). 主要考察了以下几种方法:

  • String.fromCodePoint() 方法(.join("")、字符串拼接、字符串拼接 + Array.reduce
  • String.fromCharCode() 方法(.join("")、字符串拼接、字符串拼接 + Array.reduce
  • String.fromCodePoint.apply() (字符串拼接、字符串拼接 + Array.reduce
  • String.fromCharCode.apply() (字符串拼接、字符串拼接 + Array.reduce
  • Uint16Array + TextDecoder('utf-16')

需要注意的是, JavaScript 中的 Function.apply() 方法有数量限制; 由于标准没有对此进行指定, 不同的 JavaScript 引擎会有不同的实现, 进而数量限制也不同. 因此在使用 Function.apply() 处理大量数据时, 最好事先将数据拆分为小块.

结果上来说, 在笔者使用的 Chrome (V8) 和 Firefox (SpiderMonkey) 上:

  • 单独调用的 fromCodePoint 略优于 fromCharCode, 但在 Safari 上, 后者优于前者不少;
  • 单独调用时, 字符串拼接要优于 join; reduce 在 Safari 上会略优于字符串拼接;
  • 通过 apply 调用远优于单独调用, 且 fromCodePoint 略优于 fromCharCode; 使用 reduce 拼接分块, 优于字符串拼接 (Safari 上 仍是 fromCharCode 略优于 fromCodePoint , 但在使用 reduce 拼接时, 二者几乎持平);
  • 在 Chrome 和 Safari 上, 使用 TextEncoder 远优于 apply 调用, 但是在 Firefox 上却相反;

在清楚了字符串转换的效率后, 便可以使用相对较优的方法, 来进一步比较不同 base64 编解码方法的效率.

base64 编码

首先是 base64 编码 (链接), 主要考察了以下几种方法:

  • fromCodePoint (apply + reduce) + btoa
  • fromCharCode (apply + reduce) + btoa
  • TextDecoder + btoa
  • Blob + FileReader.readAsDataURL

总的来说,

  • 在 Chrome 上, fromCodePoint 不再优于 fromCharCode;
  • 在 Chrome、Firefox 和 Safari 上, TextDecoder 的方案都要优于 fromCharCodefromCodePoint;
  • FileReader.readAsDataURL 的方案在 Firefox 和 Safari 上都远远优于其它, 但在 Chrome 上, FileReader 只是略优于 TextEncoder 的方案, 并且优势并不稳定.

base64 解码

base64 解码方面主要考察了以下几种方法 (链接):

  • atob + codePointAt (Uint8Array.from)
  • atob + charCodeAt (Uint8Array.from)
  • atob + charCodeAt (for loop)
  • DataURL + fetch

结果大概如下:

  • 在 Firefox 上, charCodeAt 要优于 codePointAt, 而在 Safari 和 Chrome 上, 二者几乎持平;
  • 使用 for-loop 的实现要远优于使用 Uint8Array.from 的;
  • 在 Chrome 上, 使用 fetch 的实现快于其它几种, 但较 atob + for-loop 没有明显优势;
  • 在 Safari 上, fetch 仍优于 Uint8Array.from 的方案, 但远低于 atob + for-loop 的方法;
  • 在 Firefox 上, fetch 有时甚至会慢于 Uint8Array.from 的方案.

Last modified on 2024-04-27