為什麼 escape 可以使 btoa 正確處理 UTF-8 編碼的字串?
瀏覽器在 window 上提供了atob
和btoa
兩個 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))) }
令人訝異的是,通過組合escape
和encodeURIComponent
兩個函式,可以正確地將字串編碼為 UTF-8的 base64 編碼:
encodeBase64('你好') // 5L2g5aW9 decodeBase64('5L2g5aW9') // 你好
這其中的原理其實並不複雜。
古老的 escape / unescape
escape 和 unescape是ofollow,noindex">ECMA-262 第一版 標準中定義(P60 15.1.2.4)的 API,是 The global object 下的一個函式,直到現在也還是 ES 標準中的一部分(雖然已經JavaScript/Reference/Global_Objects/escape" target="_blank" rel="nofollow,noindex">不是 Web 標準了)。這裡摘錄最新版的escape 標準 如下:
Theescape
function is a property of theglobal 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 is0x00FF
or less, a two-digit escape sequence of the form%xx
is used. For those characters being replaced whose code unit value is greater than0x00FF
, 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 了:
- 先用 encodeURIComponent 把 js 的字串轉成 UTF-8 的百分號編碼形式;
- 再用 unescape 把百分號編碼按位元組轉化成對應的含有 Latin-1 字符集字元的 js 字串 (即使它是亂碼);
- 最後用 btoa 把只含有 Latin-1 的 js 字串轉換成 Base64 編碼。
回過頭來看看文章開頭的程式碼,和這個思路一樣嘛!
小結
其實說實在的,這個小技巧成功的主要原因還是多虧了 encodeURIComponent;它會按照 UTF-8 編碼來處理字串,給我們帶來了很大的操作空間。事實上,ping_the_string_before_encoding_it" target="_blank" rel="nofollow,noindex">MDN 的樣例程式碼 就沒有使用 unescape,而是使用了正則和 String.fromCharCode 來將百分號編碼轉換為 Latin-1 字元。在這個頁面上,也有一些其他的解決方案能夠達到同樣的問題。
寫這篇文章主要是想講一些和編碼相關的事情,可能平時大家不太在意,或者懂個大概,希望能夠比較詳細的講解一下。
另外,為了方便討論起見,本文僅僅討論了字串中只含有 BMP 平面的情況。事實上,由於種種原因,即使字串中包含了非 BMP 平面的字元,本文的所有結論依然成立——主要還是因為 encodeURIComponent 會按類似 UTF-16 的方式去解析 js 底層實際上是 UCS-2 的字串,並按 UTF-8 編碼起來。
擴充套件閱讀:UTF-8 遍地開花 宣言