为什么 escape 可以使 btoa 正确处理 UTF-8 编码的字符串?

浏览器在 window 上提供了 atobbtoa 两个 API 用于处理 base64 编码的字符串,然而他们不能处理非 Latin1 字符集的文本:

btoa('hello') // "aGVsbG8="
btoa('你好') // error!
// Firefox: InvalidCharacterError: String contains an invalid character
// Google Chrome: Uncaught DOMException: Failed to execute 'btoa' on 'Window': The string to be encoded contains characters outside of the Latin1 range.

然而,经常看到有人会这么处理 base64 编码的字符串:

function encodeBase64(str) {
    return btoa(unescape(encodeURIComponent(str)))
}
function decodeBase64(encoded) {
    return decodeURIComponent(escape(atob(encoded)))
}

令人讶异的是,通过组合 escapeencodeURIComponent 两个函数,可以正确地将字符串编码为 UTF-8 的 base64 编码:

encodeBase64('你好') // 5L2g5aW9
decodeBase64('5L2g5aW9') // 你好

这其中的原理其实并不复杂。

古老的 escape / unescape

escape 和 unescape 是 ECMA-262 第一版标准中定义(P60 15.1.2.4)的 API,是 The global object 下的一个函数,直到现在也还是 ES 标准中的一部分(虽然已经不是 Web 标准了)。这里摘录最新版的 escape 标准 如下:

The escape function is a property of the global object. It computes a new version of a String value in which certain code units have been replaced by a hexadecimal escape sequence.

For those code units being replaced whose value is 0x00FF or less, a two-digit escape sequence of the form %xx is used. For those characters being replaced whose code unit value is greater than 0x00FF, a four-digit escape sequence of the form %uxxxx is used.

为了避免问题变得复杂,我们在这里先只讨论 Unicode BMP(0号平面)的问题。

根据标准定义,每个 code point 在 0x00~0xff 的(非字符数字、@*_+-./ 的) Unicode 字符将被编码为 %xx,其中 xx 为这个字符的码点 16 进制的表示;而每个在 0x0100 ~ 0xffff 之间的 Unicode 字符都会被编码成 %uxxxx。这在 0x00~0xff 区间和我们所熟悉的 URL 百分号编码(RFC3986)有些相似——都是 %XX 的形式;而在更广的范围内编码结果并不是我们所熟悉的样子。

举个例子:

escape(',') // %2C
encodeURIComponent(',') // %2C

当然,unescape 就是上述规则的反向,就不再赘述。

encode / decodeURIComponent

encodeURIComponent 相对 escape 可是年轻多了,它是在 ES3 被加入标准的。这个 API 和 escape 的最大不同在于,它将 JavaScript 字符串进行 UTF-8 编码,再进行百分号编码。例如一个汉字:

escape('你') // % u4F60
encodeURIComponent('你') // %E4%BD%A0

可以看到,两个编码截然不同,encodeURIComponent 并不是简单的将 % u4F60 拆解为 %4F%60,而是引入了另一套规则。要解释其中的差别,我们就不得不了解字符的“编码”。

编码?

一个 Unicode 字符(更确切地说,已编码字符)的表达方式多种多样。比如我们上文中提到的汉字“你”,它的字符码点(可以理解为编号)是 U+4F60,也可以简单理解为它是 Unicode 的第 20320 个字符。但在计算机中需要对字符进行存储时,则不得不将它编码。常见的编码有 UTF-8, UTF-16 等;而我们的 JavaScript 使用的是一个类似 UTF-16 的编码——UCS-2。UCS-2 可以视为 UTF-16 的前身(或者讲,子集),在我们没有引入码点大于 0xffff 的字符时,可以视为它就是 UTF-16。

UCS-2 表达一个字符时,直接使用 16 位存储它的 Unicode 码点。例如,刚刚我们看到,escape('你') 得到的编码 % u4F60 正是“你”这个字符的码点 U+4F60。

而 UTF-8 作为变长编码,会更加复杂一些。UTF-8 使用 1~3 个字节来表达其 BMP 平面(前面说过了,不讨论别的平面的事情)的所有字符,其编码规则可以参考UTF-8 的编码方式一文。对于汉字“你”,UTF-8 编码后的字节序列为 E4 BD A0,经过百分号编码就变成了 %E4%BD%A0 ,十分合乎逻辑。

Latin-1

那么我们这里又要说说 Latin-1 这个编码了。Latin-1 正式的名字叫 ISO/IEC 8859-1:1998,这个标准使用了 ASCII 留下的 128 个空位,加入了一堆西欧的字符——可能那时候的人类认为有英文字母再加上西欧字母就足够用了吧。通常我们说的 Latin-1 编码,其实还包括了所谓 C0 / C1 控制字符,所以下文说 Latin1 其实是指这个包括了控制字符的字符集。总之,Latin1 加上 C1 控制字符,刚好够把一个字节从 0x00~0xff 一共 256 种可能性全数填满。

这样一种一个字节的所有可能性全部填满的编码,有一个显而易见的特性:任意字节序列都是合法的 Latin-1 编码的字符串,说人话就是,啥都可以被解读成 Latin-1,即使看起来是乱码。

说回 atob

普及了这么多编码常识,终于可以回到重点了。让我们先来看看 Unicode 的编码区块:其 0x00 ~ 0x7F 和 ASCII 一致,而 0x80 ~ 0xFF 和 Latin-1 一致;而 Latin-1 的 0x00 ~ 0x7F 和 ASCII 又一致,那么可以得出结论:Unicode 的前 256 个字符,和 Latin-1 的字符集完全一致!

而 btoa 这个 API 是基于 Latin-1 字符集处理的;它将字符串中的每个字符,转换成 Latin-1 编码,然后按字节对其进行 base64。

这样一来,我们就有思路从 UTF-8 转化到 Base64 了:

  1. 先用 encodeURIComponent 把 js 的字符串转成 UTF-8 的百分号编码形式;
  2. 再用 unescape 把百分号编码按字节转化成对应的含有 Latin-1 字符集字符的 js 字符串 (即使它是乱码);
  3. 最后用 btoa 把只含有 Latin-1 的 js 字符串转换成 Base64 编码。

回过头来看看文章开头的代码,和这个思路一样嘛!

小结

其实说实在的,这个小技巧成功的主要原因还是多亏了 encodeURIComponent;它会按照 UTF-8 编码来处理字符串,给我们带来了很大的操作空间。事实上,MDN 的样例代码就没有使用 unescape,而是使用了正则和 String.fromCharCode 来将百分号编码转换为 Latin-1 字符。在这个页面上,也有一些其他的解决方案能够达到同样的问题。

写这篇文章主要是想讲一些和编码相关的事情,可能平时大家不太在意,或者懂个大概,希望能够比较详细的讲解一下。

另外,为了方便讨论起见,本文仅仅讨论了字符串中只含有 BMP 平面的情况。事实上,由于种种原因,即使字符串中包含了非 BMP 平面的字符,本文的所有结论依然成立——主要还是因为 encodeURIComponent 会按类似 UTF-16 的方式去解析 js 底层实际上是 UCS-2 的字符串,并按 UTF-8 编码起来。

扩展阅读:UTF-8 遍地开花 宣言