为什么 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 遍地开花 宣言

评论

tcdw

我是看见 MDN 上说 escape 和 unescape 是 deprecated 的玩意。。
所以一开始并没有考虑用这种简单方法实现。。

三三

虽然他们不在 Web 标准里,但依然是最新的 ECMAScript 的一部分。离 deprecated 估计还早着呢。

发表评论

发表评论代表你授权本网站存储并在必要情况下使用你输入的邮箱地址、连接本站服务器使用的 IP 地址和用户代理字符串 (User Agent) 用于发送评论回复邮件,以及将上述信息分享给 Libravatar Akismet,用于显示头像和反垃圾。