進階知識
本節將更深入地介紹字符串的內部原理。如果妳打算處理表情符號(emoji)、罕見的數學或象形文字字符,或其他罕見字符,這些知識將對妳很有用。
正如我們所知,JavaScript 的字符串是基于 Unicode 的:每個字符由 1-4 個字節的字節序列表示。
JavaScript 允許我們通過下述三種表示方式之壹將壹個字符以其十六進制 Unicode 編碼的方式插入到字符串中:
xXX
XX
必須是介于 00
與 FF
之間的兩位十六進制數,xXX
表示 Unicode 編碼爲 XX
的字符。
因爲 xXX
符號只支持兩位十六進制數,所以它只能用于前 256 個 Unicode 字符。
這前 256 個字符包括拉丁字母、最基本的語法字符和其他壹些字符。例如,"x7A"
表示 "z"
(Unicode 編碼爲 U+007A
)。
alert( "x7A" ); // z alert( "xA9" ); // © (版權符號)
uXXXX
XXXX
必須是 4 位十六進制數,值介于 0000
和 FFFF
之間。此時,uXXXX
便表示 Unicode 編碼爲 XXXX
的字符。
Unicode 值大于 U+FFFF
的字符也可以用這種方法來表示,但在這種情況下,我們要用到代理對(我們將在本章的後面討論它)。
alert( "u00A9" ); // ©, 等同于 xA9,只是使用了四位十六進制數表示而已 alert( "u044F" ); // я(西裏爾字母) alert( "u2191" ); // ↑(上箭頭符號)
u{X…XXXXXX}
X…XXXXXX
必須是介于 0
和 10FFFF
(Unicode 定義的最高碼位)之間的 1 到 6 個字節的十六進制值。這種表示方式讓我們能夠輕松地表示所有現有的 Unicode 字符。
alert( "u{20331}" ); // 佫, 壹個不常見的中文字符(長 Unicode) alert( "u{1F60D}" ); // ?, 壹個微笑符號(另壹個長 Unicode)
所有常用字符都有對應的 2 字節長度的編碼(4 位十六進制數)。大多數歐洲語言的字母、數字、以及基本統壹的 CJK 表意文字集(CJK —— 來自中文、日文和韓文書寫系統)中的字母,均有對應的 2 字節長度的 Unicode 編碼。
最初,JavaScript 是基于 UTF-16 編碼的,只允許每個字符占 2 個字節長度。但 2 個字節只允許 65536 種組合,這對于表示 Unicode 裏每個可能符的號來說,是不夠的。
因此,需要使用超過 2 個字節長度來表示的稀有符號,我們則使用壹對 2 字節長度的字符編碼,它被稱爲“代理對”(surrogate pair)。
這種做也有副作用 —— 這些符號的長度爲 2
:
alert( '?'.length ); // 2, 大寫的數學符號 X alert( '?'.length ); // 2, 笑哭的表情 alert( '?'.length ); // 2, 壹個少見的中文字符
這是因爲在 JavaScript 被創造出來的時候,代理對這個概念並不存在,因此語言並沒有正確處理它們!
雖然上面的每個字符串都只有壹個字符,但其 length
屬性顯示其長度爲 2
。
如何獲取這些符號,也是壹個棘手的問題:因爲編程語言的大部分功能都將代理對當作兩個字符對待。
舉個例子,我們可以在輸出中看到兩個奇怪的字符:
alert( '?'[0] ); // 顯示出了壹個奇怪的符號... alert( '?'[1] ); // ...代理對的片段
代理對的片段失去彼此就沒有意義。所以上面示例中 alert()
打印出的內容其實就是沒有任何意義的垃圾信息。
從技術上講,可以通過代理對的編碼來檢測代理對:如果壹個字符的編碼在 0xd800..0xdbff
這個範圍中,那麽它就是代理對的前壹個部分。下壹個字符(第二部分)的編碼必須在 0xdc00..0xdfff
範圍中。這兩個範圍中的編碼是規範中專爲代理對預留的。
基于此,JavaScript 新增了 String.fromCodePoint 和 str.codePointAt 這兩個方法來處理代理對。
它們本質上與 String.fromCharCode 和 str.charCodeAt 相同,但它們可以正確地處理代理對。
在這裏可以看出它們的區別:
// charCodeAt 不會考慮代理對,所以返回了 ? 前半部分的編碼: alert( '?'.charCodeAt(0).toString(16) ); // d835 // codePointAt 可以正確處理代理對 alert( '?'.codePointAt(0).toString(16) ); // 1d4b3,讀取到了完整的代理對
也就是說,如果我們從 ?
的位置 1 開始獲取對應的編碼(這麽做是不對的),那麽這兩個方法都只會返回此代理對的後半部分:
alert( '?'.charCodeAt(1).toString(16) ); // dcb3 alert( '?'.codePointAt(1).toString(16) ); // dcb3 // 無意義的代理對後半部分
妳稍後可以在 Iterable object(可叠代對象) 壹章中找到更多處理代理對的方式。可能也有專門處理代理對的庫,但沒有足夠流行到可以讓我們在這裏推薦的庫。
注意:在任意點拆分字符串是很危險的
我們不能隨意在任意位置對字符串進行拆分,例如通過 str.slice(0, 4)
獲取壹個字符串,並期待它是壹個有效的字符串:
alert( 'hi ?'.slice(0, 4) ); // hi [?]
在這裏,我們看到壹個沒有意義的垃圾字符被打印了出來(笑哭表情代理對的前半部分)。
如果妳期望可靠地使用代理對,請注意這壹點。這可能並不是什麽大問題,但至少妳應該知道發生了什麽。
很多語言都有由基礎字符及其上方/下方的標記所組成的符號。
舉個例子,字母 a
就是這些字符 àáâäãåā
的基礎字符。
大多數常見的“複合”字符在 Unicode 表中都有自己的編碼。但不是所有這些字符都有自己的編碼,因爲可能的組合形式太多了。
爲了支持任意的組合,Unicode 標准允許我們使用多個 Unicode 字符:基礎字符後跟著壹個或多個“裝飾”它的“標記”字符。
例如,如果我們在 S
後附加上特殊的“上方的點”字符(編碼爲 u0307
),則顯示爲 Ṡ。
alert( 'Su0307' ); // Ṡ
如果我們需要在字母上方(或下方)添加壹個額外的標記 —— 很簡單,只需添加必要的標記字符即可。
例如,如果我們繼續在後面附加壹個“下方的點”符號(編碼 u0323
),那麽我們將得到壹個“上下都有壹個點符號的 S”:Ṩ
。
就像這樣:
alert( 'Su0307u0323' ); // Ṩ
這提供了極大的靈活性,但也帶來了壹個有趣的問題:兩個字符可能在視覺上看起來相同,但卻使用的是不同的 Unicode 組合。
舉個例子:
let s1 = 'Su0307u0323'; // Ṩ, S + 上方點符號 + 下方點符號 let s2 = 'Su0323u0307'; // Ṩ, S + 下方點符號 + 上方點符號 alert( `s1: ${s1}, s2: ${s2}` ); alert( s1 == s2 ); // 盡管這兩個字符在我們看來是相通的,但結果卻是 false
“Unicode 規範化”算法可以解決這個問題,該算法將每個字符串轉換爲單壹的“規範的”形式。
可以借助 str.normalize() 實現這壹點。
alert( "Su0307u0323".normalize() == "Su0323u0307".normalize() ); // true
有意思的是,在我們這個例子中,normalize()
將 3 個字符的序列合並爲了壹個字符:u1e68
(帶有上下兩個點的 S)。
alert( "Su0307u0323".normalize().length ); // 1 alert( "Su0307u0323".normalize() == "u1e68" ); // true
但實際並非總是如此。出現這種情況的原因是符號 Ṩ
是“足夠常見的”,所以 Unicode 創建者將其囊括在了 Unicode 主表中,並爲其提供了對應的編碼。
如果妳想了解關于 Unicode 規範化規則和變體的更多信息,可以參閱 Unicode 標准的附錄中的內容:Unicode 規範化形式。但就實用而言,本節中的信息就已經足夠了。