每個 JavaScript 開發者都應該瞭解的 Unicode
!! 本文譯者爲 360 奇舞團前端資深開發工程師
原文標題:What every JavaScript developer should know about Unicode
原文作者:Dmitri Pavlutin
原文地址:https://dmitripavlutin.com/what-every-javascript-developer-should-know-about-unicode/#comments
本文絕大多數專業名詞中文翻譯均參考自對應的中文維基百科
在開始之前,我要坦白一點:我很長一段時間都很怕 Unicode。每當我遇到些需要應用 Unicode 知識去解決的編程任務,我就會去搜一個 hack 方案,但其實我並不理解到底在做什麼。
我一直在逃避,直到我遇到了一個需要深入理解 Unicode 才能解決的問題。搜索不到適合我當前場景的解決方案了。
我努力的讀了一大堆文章 — 令我驚訝的是,其實 Unicode 不難理解。雖說 … 有些文章我讀了至少三回。
事實證明,Unicode 是一個通用且優雅的標準。但由於那一大堆的抽象術語,堅持深入學習其實還是挺艱難的。
如果你感覺自己對 Unicode 的理解還不夠,那麼現在是時候來直面它了!沒那麼難。給自己泡一杯可口的 茶 或 咖啡 ☕。讓我們深入 抽象、字符、星光 和 代理 的美妙世界吧。
本文首先會解釋 Unicode 的基本概念,幫你打牢基礎。之後會闡述 JavaScript 是如何和 Unicode 協作的,以及在這個過程中你可能會遇到的陷阱。你還會學會如何應用新的 ECMAScript 2015 特性來解決部分問題。
準備好了嗎?燥起來!
- Unicode 背後的思想
我們先從一個本質上的問題開始。你爲何能夠閱讀理解這篇文章?很簡單,因爲你知道每一個 字 和 由字組成的詞 的含義。
爲什麼你能理解每一個字的含義呢?簡單來說,因爲這些圖形符號(你在屏幕上看到的東西)和 中文這門語言中的漢字(含義)之間有着聯繫,而你(讀者)和我(作者)都認同這一點。
這對於計算機來說也是一樣的。不同之處在於計算機無法理解字的含義,它們對於計算機僅僅是 二進制位序列。
想象這樣一個場景, 用戶 1 通過網絡向 用戶 2 發送了一條消息 hello
。
用戶 1 的計算機不知道其中每個字母的含義。所以它會將 hello
轉換成數字序列 0x68 0x65 0x6C 0x6C 0x6F
,其中每一個字母唯一對應一個數字: h
對應 0x68
, e
對應 0x65
,諸如此類。這些數字會被髮送到 用戶 2 的計算機上。
用戶 2 的計算機接收到數字序列 0x68 0x65 0x6C 0x6C 0x6F
後,它會使用同樣一套字母和數字的對應關係來還原信息。然後展示出正確的信息: hello
。
兩臺計算機之間 在 字母和數字的對應關係 這個方面達成的協議 就是 Unicode 標準化的產物。
按照 Unicode 標準, h
是一個叫做 LATIN SMALL LETTER H 的抽象字符。這個字符對應的數字是 0x68
,其碼位(code point)記爲 U+0068
。
Unicode 會提供一個 抽象字符列表(字符集),併爲每個字符分配一個獨一無二的碼位標識符(編碼字符集)。
- Unicode 基礎術語
網站 www.unicode.org 提到:
!! “ Unicode 爲每個字符提供了一個獨一無二的數字,無論是什麼平臺,無論是什麼程序,無論是什麼語言。”
Unicode 是一個通用字符集,它爲大多數的書寫系統定義了 字符列表,併爲每個字符關聯了一個唯一的數字(碼位)。
Unicode 包含了當今大多數語言中的 字符、標點符號、變音符號、數學符號、技術符號、箭頭、emoji 等等。
最初的 Unicode 版本 1.0 在 1991-10 發佈,包含 7,161 個字符。最近的一個版本是 14.0 (2021-9 發佈)包含 144,697 個字符。
在 Unicode 出現之前,很多廠商實現了大量難以處理的 字符集 和 編碼,Unicode 廣泛且具有包容性的方法 解決了這個重大問題。
創建一個支持全部字符集和編碼的應用程序是非常複雜的。
如果你覺得 Unicode 已經很難,那沒了 Unicode 編程會變得更難。
我還記得以前在讀取文件的時候,會胡亂地挑選字符集和編碼。簡直是在抽獎!
2.1 字符 和 碼位
!! “ 抽象字符 (或者說 字符)是用於組織、控制 或 表示 文本數據 的 信息單元”
Unicode 將 字符 作爲抽象術語。每個抽象字符都有一個關聯名稱,例如 LATIN SMALL LETTER A 。這個字符的渲染形式(字形)是 a
。
!! “ 碼位 是分配給單個字符的一個數字”
碼位 是從 U+0000
到 U+10FFFF
的數字。
U+<hex>
是碼位的格式,其中 U+
是代表 Unicode 的前綴,而 <hex>
表示十六進制數字。例如, U+0041
和 U+2603
都是碼位。
記住,碼位 就是一個簡簡單單的數字,你不用考慮更多了。碼位 就是一種數組中的元素索引。
就是因爲 Unicode 將 碼位 和 字符 關聯起來,才變得不可思議。例如, U+0041
對應的 字符 名爲 LATIN CAPITAL LETTER A (渲染爲 A
),U+2603
對應的 字符 名爲 SNOWMAN (渲染爲 ☃
)。
並非所有 碼位 都有關聯字符。總共有 1,114,112
個碼位可用(範圍從從 U+0000
到 U+10FFFF
),但是隻有 144,697
個(截止 2021.9)被分配了字符。
2.2 Unicode 平面(Unicode Planes)
!! “ 平面(Planes) 是從
U+n0000
到U+nFFFF
的 65,536(或2BAH1v ) 個連續的碼位,n 的取值範圍從bNxG84 到m4F6rW ”
平面 將全部的 Unicode 碼位分成了 17 個均等的組:
-
平面 0 包含從 U+0000 到 U+FFFF 的 碼位。
-
平面 1 包含從 U+1000 到 U+1FFFF 的 碼位。
-
…
-
平面 16 包含從 U+10000 到 U+10FFFF 的 碼位。
基本多文種平面(Basic Multilingual Plane)
平面 0 很特殊,名爲 基本多文種平面(Basic Multilingual Plane) 或 簡稱 BMP 。它包含絕大多數現代語言中的字符 (Basic Latin 基礎拉丁字母,Cyrillic 西裏爾字母,Greek 希臘字母,等等)和 大量的符號。
綜上所述,基本多文種平面 範圍是從 U+0000
到 U+FFFF
,最多 4 位十六進制數字。
開發者通常只會處理 BMP 中的字符。BMP 包含了絕大部分必要的字符。
BMP 中的一些字符:
-
e
是U+0065
,命名爲 LATIN SMALL LETTER E -
|
是U+007C
,命名爲 VERTICAL BAR -
■
是U+25A0
,命名爲 BLACK SQUARE -
☂
是U+2602
,命名爲 UMBRELLA
星光平面(Astral plane)
!! 譯者注: Astral Plane,也稱爲星光界,是古典、中世紀、東方、神祕哲學和神祕宗教所假設的一個存在位面。它是天球的領域,是靈魂誕生和死亡之後的穿梭的地方,通常認爲,天使、精靈 或 其他非物質生命在這裏居住。"Astral Plane" 是 輔助平面(Supplementary Planes) 的非正式名稱,因爲(特別是 90 年代後期)對它們的使用太少了,以至於和神祕學中的 “彼岸”(The Great Beyond)一樣虛無縹緲。很多人對這種幽默的稱呼持反對意見,而且隨着 平面 1 和 平面 2 的廣泛使用,越來越少的人覺得這些平面真的是 “星光界”。但是這種詼諧的引申是無害的,它提醒我們現在還遠遠達不到那種程度。 詳見:http://www.opoudjis.net/unicode/unicode_astral.html
在 BMP 後的 16 個平面(平面 1,平面 2,…,平面 16)被稱爲 星光平面(Astral Plane) 或 輔助平面(Supplementary Planes)
星光平面 中的 碼位 被稱爲 星光碼位。碼位範圍從 U+10000
到 U+10FFFF
。
一個 星光碼位 有 5 或 6 位 十六進制數字:U+ddddd
或 U+dddddd
。
讓我們看看 星光平面 中的一些字符:
-
𝄞
是U+1D11E
,命名爲 MUSICAL SYMBOL G CLEF -
𝐁
是U+1D401
,命名爲 MATHEMATICAL BOLD CAPITAL B -
🀵
是U+1F035
,命名爲 DOMINO TITLE HORIZONTAL-00-04 -
😀
是U+1F600
,命名爲 GRINNING FACE
2.3 碼元(Code Units)
OK,我們剛剛說的 Unicode 的 字符,碼位 和 平面 都是抽象的。
現在我們該談談 Unicode 在 物理層面、硬件層面 是如何實現的了。
計算機在內存層面上不會使用 碼位 或者 抽象字符。它需要一種物理方式來表現 Unicode 碼位,那就是 碼元。
!! “碼元 是用於 以給定編碼格式 對每個字符編碼 的一個 二進制位序列”
字符編碼 將 抽象的 碼位 轉換爲 物理的 二進制位:碼元。換句話說,字符編碼 將 Unicode 碼位 轉換爲了 唯一的 碼元序列。
常用的 字符編碼 有 UTF-8、UTF-16 和 UTF-32 。
大多數 JavaScript 引擎 會使用 UTF-16 編碼。這影響了 JavaScript 和 Unicode 的協作方式。現在開始,我們聚焦到 UTF-16。
UTF-16 (全稱爲:16-bit Unicode Transformation Format)是一種可變長度編碼:
-
BMP 的 碼位 會編碼爲 1 個 16 位長的碼元
-
星光平面的 碼位 會編碼爲 2 個 16 位長的碼元
我們枯燥的理論談的有點多了。現在我們來看一些例子。
假設你想將 LATIN SMALL LETTER A 字符 a
保存到硬盤驅動器。Unicode 會告訴你 抽象字符 LATIN SMALL LETTER A 是映射到 碼位 U+0061
的。
現在我們來想想 UTF-16 編碼 是如何對 U+0061
做的轉換。按照 編碼規範,對於 BMP 碼位,會提取其中的十六進制數字 0061
並將其存儲在 1 個 16 位長的碼元:0x0061
。
如你所見,BMP 碼位 很適合塞進 1 個 16 位長的碼元。
2.4 代理對(Surrogate Pairs)
現在讓我們研究一個複雜的例子。假設你想保存一個 星光碼位(從星光平面):GRINNING FACE 字符 😀。這個字符映射爲碼位 U+1F600
。
由於 星光碼位 需要 21 個二進制位 來存儲信息,按照 UTF-16,需要 2 個 16 位長的碼元。碼位 U+1F600
會被分割位所謂的 代理對:0xD83D
(高位代理碼元)和 0xDE00
(低位代理碼元)。
!! “代理對 是對那些由 2 個 16 位長的碼元所組成序列的 單個抽象字符 的表示方式,代理對 中的首個值爲 高位代理碼元 而第二個值爲 低位代理碼元。”
一個 星光碼位 需要兩個 碼元,這就是代理對。就像之前的例子中展示的,爲了將 U+1F600
(😀
)編碼爲 UTF-16,使用的代理對是 0xD83D 0xDE00
。
console.log('\uD83D\uDE00'); // => '😀'
高位代理碼元 從 0xD800
到 0xDBFF
取值。低位代理碼元 從 0xDC00
到 0xDFFF
取值。
將 代理對 轉換爲 星光碼位 的算法如下,反之亦然:
function getSurrogatePair(astralCodePoint) {
let highSurrogate =
Math.floor((astralCodePoint - 0x10000) / 0x400) + 0xD800;
let lowSurrogate = (astralCodePoint - 0x10000) % 0x400 + 0xDC00;
return [highSurrogate, lowSurrogate];
}
getSurrogatePair(0x1F600); // => [0xD83D, 0xDE00]
function getAstralCodePoint(highSurrogate, lowSurrogate) {
return (highSurrogate - 0xD800) * 0x400
+ lowSurrogate - 0xDC00 + 0x10000;
}
getAstralCodePoint(0xD83D, 0xDE00); // => 0x1F600
代理對 用起來可不舒服。在 JavaScript 中處理字符串時,你必須將它們當作特殊情況處理,我會稍後在文章裏談談這部分。
然而,UTF-16 是內存高效的。我們平時需要處理字符中有 99% 都來自 BMP,只需要一個碼元。
2.5 組合符號(Combining Marks)
!! “字位(grapheme,又稱 形素、字素),或 符號(symbol),對一些書寫系統來說 是最小的構成單位”
字位 就是用戶對於一個字符的理解。字位 在顯示器上的具體圖像稱爲 字形(graph)。
在絕大多數情況下,單個 Unicode 字符表示 單個的 字位。例如 U+0066
LATIN SMALL LETTER F 就寫作 f 。
但也存在 單個字位 包含一系列 字符 的情況。
例如,å
在丹麥書寫系統中是一個原子性的 字位。展示它需要使用 U+0061
LATIN SMALL LETTER A (渲染爲 a
) 組合一個特殊字符 U+030A
COMBINING RING ABOVE (渲染爲 ◌̊)。
U+030A
對前面的字符進行了修改,它叫做 組合字符。
console.log('\u0061\u030A'); // => 'å'
console.log('\u0061'); // => 'a'
!! “組合字符 是一種在位置在前的 基本字符 上創建 字位 的 字符”
組合字符 包含 重音符號、變音符號、希伯來語點、阿拉伯元音符號 和 印度 matras。
組合符號 通常不會獨立使用(即沒有字符作爲基礎時)。你應當避免獨立使用它們。
就和 代理對 一樣,組合符號 在 JavaScript 中也很難處理。
組合字符序列 (基本字符 + 組合字符)會被用戶認爲是單個符號(例如 '\u0061\u030A'
就是 'å'
)。但開發者必須使用 2 個 碼位 U+0061
和 U+030A
來構造 å
。
- JavaScript 中的 Unicode
ES2015 規範 提到 ,源碼文本使用 Unicode (版本 5.1 以上)。源碼文本是一個從 U+0000
到 U+10FFFF
的碼位序列。源碼 存儲 和 數據交換 的格式在 ECMAScript 規範中沒有提到,但通常會使用 UTF-8 編碼(Web 的首選編碼)。
我建議保留源碼中 Unicode 基本拉丁字母塊(或者 ASCII)中的字符。超出 ASCII 的字符應該被轉義。
深入下去,在語言層面,ECMAScript 2015 提供了 JavaScript 中 String 的明確定義:
!! “String 類型是由零個或多個 16 位無符號整數值(“元素”)組成的所有有序序列的集合,最大長度爲 個元素。 String 類型通常用於表示運行中 ECMAScript 程序的文本數據,這時,String 中的每個元素都被視爲一個 UTF-16 碼元 值。”
字符串中的每個元素都會被引擎解析爲一個碼元。字符串的渲染方式不會提供一種確定的方法來決定其包含的碼元(所表示的碼位)。看下面這個例子:
console.log('cafe\u0301'); // => 'café'
console.log('café'); // => 'café'
'cafe\u0301'
和 'café'
從字面上看,碼元略有不同,但兩者都會渲染出相同的符號序列 café
。
在之前的 代理對 和 組合符號 這兩章,我們知道,一些符號需要 2 個 或 更多 碼元來表示。因此在計算字符數量 或 通過索引訪問字符時,你要小心仔細,做好預防工作:
const smile = '\uD83D\uDE00';
console.log(smile); // => '😀'
console.log(smile.length); // => 2
const letter = 'e\u0301';
console.log(letter); // => 'é'
console.log(letter.length); // => 2
smile
字符串包含 2 個碼元:\uD83D
(高位代理) 和 \uDE00
(低位代理)。由於字符串就是一個碼元序列,所以 smile.length
就會得出 2
。即使 字符串 smile
只渲染出一個符號 😀
。
我建議,始終將 JavaScript 中的字符串理解爲碼元序列。渲染字符串的方式無法清晰地說明其包含的碼元。
星光平面的符號 和 組合符號序列 需要 2 個 或 更多碼元 進行編碼。但只會被當作單一的字位。
如果 字符串 中包含 代理對 和 組合符號,開發者在不知情的情況下可能會在 字符串長度的計算 和 用索引訪問字符 時感到困惑。
大多數 JavaScript 字符串方法都不是 “Unicode 感知”(Unicode-aware) 的。如果你的字符串中包含 Unicode 複合字符,在調用 myString.slice()
,myString.substring()
這些方法時,請做好防範。
3.1 轉義序列(Escape Sequences)
JavaScript 字符串 的 轉義序列 是基於 碼位數字 來表示 碼元 的。JavaScript 提供了 3 種轉義類型,其中一種是在 ECMAScript 2015 中引入。
我們來看更多細節。
十六進制轉義序列
轉義序列最短的形式叫做 十六進制轉義序列:\x<hex>
,其中 \x
時前綴,後面跟着的是長度固定爲 2 位的十六進制數字 <hex>
。例如,'\x30'
(符號 '0'
),'\x5B'
(符號 '['
)。
十六進制轉義序列 的 字符串字面量 和 正則表達式 寫法是這樣的:
const str = '\x4A\x61vaScript';
console.log(str); // => 'JavaScript'
const reg = /\x4A\x61va.*/;
console.log(reg.test('JavaScript')); // => true
因爲只能使用 2 位,所以十六進制轉義序列 只能轉義有限範圍內的碼位:U+00
到 U+FF
。但它的優點在於短。
Unicode 轉義序列
如果你想轉義整個 BMP 的碼位,那你就應該使用 ** Unicode 轉義序列 **。其轉義格式爲 \u<hex>
,其中 \u
爲前綴,後面跟着一個長度固定爲 4 位的十六進制數字 <hex>
。例如,'\u0051'
(符號 'Q'
),'\u222B'
(積分符號 '∫'
)。
我們來用用看 Unicode 轉義序列:
const str = 'I\u0020learn \u0055nicode';
console.log(str); // => 'I learn Unicode'
const reg = /\u0055ni.*/;
console.log(reg.test('Unicode')); // => true
因爲只能使用 4 位, Unicode 轉義序列 可以轉義有限範圍內的碼位:U+0000
到 U+FFFF
(BMP 的全部碼位),大多數情況下,這已經足夠表示那些常用的符號了。
想要再 JavaScript 字面量 中表示一個 星光平面中的符號,需要使用兩個連接在一起的 Unicode 轉義序列(高位代理 和 低位代理),這就創建了一個 代理對:
const str = 'My face \uD83D\uDE00';
console.log(str); // => 'My face 😀'
碼位轉義序列
ECMAScript 2015 提供了能夠表示整個 Unicode 空間(U+0000
到 U+10FFFF
) 碼位 的 轉義序列:即 BMP 和 星光平面。
新的更是被稱作 碼位轉義序列:\u{<hex>}
,其中 <hex>
是一個長度爲 1 到 6 位 的十六進制數字 。例如,'\u{7A}'
(符號 'z'
),'\u{1F639}'
(滑稽貓符號 '😹'
)。
看看如何再字面量中使用它:
const str = 'Funny cat \u{1F639}';
console.log(str); // => 'Funny cat 😹'
const reg = /\u{1F639}/u;
console.log(reg.test('Funny cat 😹')); // => true
注意,正則表達式 /\u{1F639}/u
中有一個特殊的標誌 u
,這是用來開啓附加的 Unicode 特性的(詳見 3.5 正則表達式匹配)。
我喜歡通過 碼位轉義 來避免使用 代理對 來表示 星光平面的符號。我們來轉義 U+1F607
SMILING FACE WITH HALO 碼位:
const niceEmoticon = '\u{1F607}';
console.log(niceEmoticon); // => '😇'
const spNiceEmoticon = '\uD83D\uDE07'
console.log(spNiceEmoticon); // => '😇'
console.log(niceEmoticon === spNiceEmoticon); // => true
賦給變量 niceEmoticon
的字符串字面量是 轉義碼位 \u{1F607}
,表示的是星光平面的碼位 U+1F607
。不過,其實在底層 碼位轉義 還是創建了一個 代理對(2 個碼位)。正如你看到的,spNiceEmoticon
使用 Unicode 轉義 '\uD83D\uDE07'
創建了一個代理對,是等同於 niceEmoticon
。
如果正則表達式是通過 RegExp
構造函數創建的,必須在字符串字面量中將 \
替換爲 \\
來做 Unicode 轉義。下面這些正則表達式對象是等價的:
const reg1 = /\x4A \u0020 \u{1F639}/;
const reg2 = new RegExp('\\x4A \\u0020 \\u{1F639}');
console.log(reg1.source === reg2.source); // => true
3.2 字符串比較
JavaScript 中的字符串 是 碼元序列。那麼,我們有理由認爲 字符串比較 會涉及對碼元的計算,這樣的話,比較字符串 就是在比較 兩個字符串中包含的碼元是否一致。
這種方式快速高效。可以很好的處理 “簡單的” 字符串:
const firstStr = 'hello';
const secondStr = '\u0068ell\u006F';
console.log(firstStr === secondStr); // => true
firstStr
和 secondStr
字符串是相同的碼元序列。
假設你想比較兩個渲染出來相同,但包含不同碼元序列的字符串。那麼你也許會得到一個意想不到的結果,字符串 看起來相同 但 比較結果是不相等:
const str1 = 'ça va bien';
const str2 = 'c\u0327a va bien';
console.log(str1); // => 'ça va bien'
console.log(str2); // => 'ça va bien'
console.log(str1 === str2); // => false
str1
和 str2
渲染看起來相同,但碼元不同。之所以會發生這種情況,字位 ç
是通過兩種不同的方式構造的:
-
使用
U+00E7
LATIN SMALL LETTER C WITH CEDILLA -
另一種使用 組合字符序列:
U+0063
LATIN SMALL LETTER C 加 組合符號U+0327
COMBINING CEDILLA。
如何處理這種情況並正確比較字符串?答案是字符串 正規化(Normalization)。
正規化(Normalization)
!! “正規化 就是將字符串轉換爲規範的表示形式,以確保 標準等價(canonical-equivalent)和 / 或 兼容等價(compatibility-equivalent)的 字符串 有標準的表示形式。”
換句話說,當字符串結構複雜(包含組合字符序列 或 其他複雜結構),可以 將它 正規化 得到規範格式。正規化的字符串可以無痛 比較 或 執行文字搜索等字符串操作,以此類推。
Unicode 附加標準 #15 提供了 正規化 過程中有趣的細節。
在 JavaScript 正規化 字符串 可以調用myString.normalize([normForm])
方法,該方法在 ES2015 中可用。normForm
是一個可選參數(默認爲 'NFC'
),其值也可以是一下 正規化 格式:
-
'NFC'
爲 Normalization Form Canonical Composition -
'NFD'
爲 Normalization Form Canonical Decomposition -
'NFKC'
爲 Normalization Form Compatibility Composition -
'NFKD'
爲 Normalization Form Compatibility Decomposition
我們通過應用字符串正規化來改進之前的例子,這能幫助我們正確的比較字符串:
const str1 = 'ça va bien';
const str2 = 'c\u0327a va bien';
console.log(str1 === str2.normalize()); // => true
console.log(str1 === str2); // => false
'ç'
和 'c\u0327'
是 標準等效 的。當調用 str2.normalize()
時,會返回標準版本的 str2
('c\u0327'
被替換爲 'ç'
)。因此比較 str1 === str2.normalize()
就會和預期一樣返回 true
。str1
不受 正規化 影響,因爲它已經是規範形式了。
將兩個比較的字符串都 正規化,得到兩個操作數的規範表示是非常合理的。
3.3 字符串長度
當然,確定字符串長度最常用的方式,就是訪問 myString.length
屬性。這個屬性可以等到一個字符串所擁有的碼元數量。
只包含 BMP 中 碼位 的字符串確實可以使用這種計算方式 來得到預期結果。
const color = 'Green';
console.log(color.length); // => 5
color
中每一個 碼元 對應一個單獨的 字位。字符串預期長度爲 5
。
長度 與 代理對
當字符串包含 代理對 (爲了表示 星光平面的碼位)時,情況就變得棘手了。因爲每個 代理對 包含 2 個 碼元(一個高位代理,一個低位代理),因此 length 屬性會比預期大。
看下面這個例子:
const str = 'cat\u{1F639}';
console.log(str); // => 'cat😹'
console.log(str.length); // => 5
字符串 str
渲染後,包含 4 個符號 cat😹
。然而 smile.length
得出結果是 5
,這是因爲 U+1F639
是一個星光平面的碼位,被編碼成了 2 個碼元(一個代理對)。
很不幸,目前還沒有一種原生且高效的方式來解決這個問題。
不過至少 ECMAScript 2015 引入了感知 星光平面符號 的算法。星光平面的符號 即使被編碼爲 2 個碼元,也會被計算爲單個字符。
字符串迭代器 String.prototype[@@iterator]()
是 Unicode 感知 的。你可以通過 展開運算符 [...str]
或 Array.from(str)
函數(兩者底層都會調用字符串迭代器)來。然後計算返回數組的符號數量。
注意,這種解決如果廣泛使用可能會導致性能問題。
我們來通過 展開運算符 來改進之前的例子:
const str = 'cat\u{1F639}';
console.log(str); // => 'cat😹'
console.log([...str]); // => ['c', 'a', 't', '😹']
console.log([...str].length); // => 4
[...str]
會創建一個包含 4 個符號的數組。 表示 U+1F639
CAT FACE WITH TEARS OF JOY 😹 的 代理對會保持完整,因爲字符串迭代器是 Unicode 感知的。
長度 和 組合符號
那組合符號序列該怎麼處理呢?因爲每個組合符號是一個碼元,所以會遇到相同的問題。
利用 字符串正規化 就能解決這個問題。如果足夠幸運,組合字符序列 會被 正規化 爲單個字符。我們來試試:
const drink = 'cafe\u0301';
console.log(drink); // => 'café'
console.log(drink.length); // => 5
console.log(drink.normalize()) // => 'café'
console.log(drink.normalize().length); // => 4
drink
字符串包含 5 個碼元(因此 drink.length
得 5
),即使它被渲染爲了 4 個符號。
drink
正規化 時,很幸運,組合字符序列 'e\u0301'
存在一個標準形式 'é'
。所以 drink.normalize().length
會得到預期的 4
。
但不幸的是,正規化 不是一個通用的解決方案。較長的 組合字符序列 並不總是有單個字符的標準等價形式。我們來看個例子:
const drink = 'cafe\u0327\u0301';
console.log(drink); // => 'cafȩ́'
console.log(drink.length); // => 6
console.log(drink.normalize()); // => 'cafȩ́'
console.log(drink.normalize().length); // => 5
drink
有 6 個碼元,drink.length
得 6
。然而 drink
有 4 個符號。
正規化 drink.normalize()
將 組合序列 'e\u0327\u0301'
轉換成了 兩個字符的標準形式 'ȩ\u0301'
(只消去了一個組合符號)。很可悲,drink.normalize().length
得到了 5
,仍然不能表示出正確的符號個數。
3.4 字符定位
由於字符串就是一系列碼元,所以通過字符串索引來訪問字符也是存在一些難題的。
如果字符串只包含 BMP 字符(不包含 U+D800
到 U+DBFF
高位代理 和 U+DC00
到 U+DFFF
的 低位代理),字符定位可以正常運行。
const str = 'hello';
console.log(str[0]); // => 'h'
console.log(str[4]); // => 'o'
每個符號都會被編碼爲單個碼元,因此通過索引訪問是 OK 的。
字符定位 與 代理對
一旦字符串包含 星光平面的符號 時,情況就不一樣了。
星光平面的符號 使用 2 個碼元進行編碼(代理對)。所以通過索引訪問字符串中的字符,可能會得到分離的 高位代理 或者 低位代理,這些東西都不是有效的符號。
const omega = '\u{1D6C0} is omega';
console.log(omega); // => '𝛀 is omega'
console.log(omega[0]); // => '' (無法打印的字符)
console.log(omega[1]); // => '' (無法打印的字符)
因爲 U+1D6C0
MATHEMATICAL BOLD CAPITAL OMEGA 是星光平面的字符,它會被編碼爲包含兩個碼元的代理對。omega[0]
會訪問到 高位代理碼元,omega[1]
會訪問到 低位代理碼元,代理對 被破壞分解了。
存在 2 種在字符串中正常訪問 星光平面符號 的方法:
-
使用 Unicode 感知 的 字符串迭代器 生成符號數組
[...str][index]
-
通過調用
number = myString.codePointAt(index)
拿到 碼位數字,然後通過String.fromCodePoint(number)
將數字轉換爲符號。(推薦選項)
我們來試試看這兩種方法:
const omega = '\u{1D6C0} is omega';
console.log(omega); // => '𝛀 is omega'
// Option 1
console.log([...omega][0]); // => '𝛀'
// Option 2
const number = omega.codePointAt(0);
console.log(number.toString(16)); // => '1d6c0'
console.log(String.fromCodePoint(number)); // => '𝛀'
[...omega]
返回 omega
包含的符號數組。代理對被正確計算了,因此能夠如預期一般訪問第一個字符。[...smile][0]
是 '𝛀'
。
omega.codePointAt(0)
方法調用 是 Unicode 感知的,所以會返回 omega
中第一個字符的 星光碼位數字 0x1D6C0
。函數 String.fromCodePoint(number)
會基於碼位數字返回符號:'𝛀'
。
字符定位 與 組合符號
包含 組合符號 的字符串 進行 字符定位 時 也會有相同的問題。
通過字符串索引訪問字符 就相當於 訪問碼元。然而,組合符號序列 應當被當作一個整體訪問,而不應該被分離多個碼元。
下面這個例子說明了這個問題:
const drink = 'cafe\u0301';
console.log(drink); // => 'café'
console.log(drink.length); // => 5
console.log(drink[3]); // => 'e'
console.log(drink[4]); // => ◌́
drink[3]
只會訪問到 基本字符 e
,而不會帶上 組合字符 U+0301
COMBINING ACUTE ACCENT (渲染爲 ◌́
)。drink[4]
會訪問到獨立的 組合字符 ◌́
。
這種情況可以將字符串 正規化。U+0065
LATIN SMALL LETTER E 加上 U+0301
COMBINING ACUTE ACCENT 的 組合字符序列 存在 標準等價形式 U+00E9
LATIN SMALL LETTER E WITH ACUTE é
。我們來改進前面的代碼示例:
const drink = 'cafe\u0301';
console.log(drink.normalize()); // => 'café'
console.log(drink.normalize().length); // => 4
console.log(drink.normalize()[3]); // => 'é'
請注意,並非所有 組合字符序列 都存在 單個字符的標準等價。因此 正規化 並不是一個通用的解決方案。
但幸運的是,這種方式對大部分歐美語言是有效的。
3.5 正則表達式匹配
正則表達式 和 字符串 一樣,是基於碼元工作的。和之前我提到那些場景類似,代理對 和 組合字符序列 會給正則表達式的使用帶來麻煩。
BMP 字符匹配符合預期,因爲單一碼位表示單一符號:
const greetings = 'Hi!';
const regex = /.{3}/;
console.log(regex.test(greetings)); // => true
greetings
裏的 3 個符號被編碼爲了 3 個碼元。正則表達式 /.{3}/
想要匹配 3 個碼元,用於匹配 greetings
。
當匹配 星光平面中符號(會被編碼爲包含 2 個碼元的代理對),你可能就會遇到一些問題。
const smile = '😀';
const regex = /^.$/;
console.log(regex.test(smile)); // => false
smile
包含 星光平面中的符號 U+1F600
GRINNING FACE。U+1F600
會被編碼爲代理對 0xD83D 0xDE00
。然而,正則表達式 /^.$/
期望匹配一個碼元,所以匹配失敗了:regexp.test(smile)
就得 false
了。
當使用 星光平面中的符號 來定義 字符類(Character Classes) 的時候情況會更糟糕,JavaScript 會直接拋出錯誤:
const regex = /[😀-😎]/;
// => SyntaxError: Invalid regular expression: /[😀-😎]/:
// Range out of order in character class
星光平面中的符號 會被編碼爲代理對。所以 JavaScript 在正則表達式中會使用碼元 /[\uD83D\uDE00-\uD83D\uDE0E]/
。每個碼元在 pattern 中都被視爲分離的元素,因此正則表達式會忽略 代理對 這個概念。因爲 \uDE00
大於 \uD83D
,所以 字符類 中 \uDE00-\uD83D
這部分就是非法的。因此,錯誤就產生了。
正則表達式 標誌 u
幸運的是,ECMAScript 2015 引入了一個非常有用的 u
標誌,它爲正則表達式帶來了 Unicode 感知 的能力。這個標誌開啓後,就能夠正確處理 星光平面的字符。
你可以在正則表達式中使用 Unicode 轉義序列 /u{1F600}/u
。這種轉義方式的寫法是要 短於 高位代理 + 低位代理 的 代理對 /\uD83D\uDE00/
的。
我們應用 u
標誌 來看看 .
運算符()是如何匹配 星光平面的符號的:
const smile = '😀';
const regex = /^.$/u;
console.log(regex.test(smile)); // => true
/^.$/u
正則表達式,因爲 u
標誌的原因 變得 Unicode 感知 了,現在可以匹配 😀
。
開啓 u
標誌後,也能正常處理 字符類 中的 星光平面符號了:
const smile = '😀';
const regex = /[😀-😎]/u;
const regexEscape = /[\u{1F600}-\u{1F60E}]/u;
const regexSpEscape = /[\uD83D\uDE00-\uD83D\uDE0E]/u;
console.log(regex.test(smile)); // => true
console.log(regexEscape.test(smile)); // => true
console.log(regexSpEscape.test(smile)); // => true
[😀-😎]
現在可以得到 星光平面符號的範圍了。/[😀-😎]/u
匹配到了 😀
。
正則表達式 與 組合符號
不幸的是,無論在正則表達式中是否使用 u
標誌,正則表達式都會將其視爲分離的碼元。
如果你需要匹配一個 組合字符序列,你必須分別匹配 基本字符 和 組合符號。
看下面的例子:
const drink = 'cafe\u0301';
const regex1 = /^.{4}$/;
const regex2 = /^.{5}$/;
console.log(drink); // => 'café'
console.log(regex1.test(drink)); // => false
console.log(regex2.test(drink)); // => true
渲染出的字符串包含 4 個符號 café
。
不過,正則表達式 /^.{5}$/
匹配到了 'cafe\u0301'
,認爲其有 5 個元素。
- 總結
可能在 JavaScript 中,Unicode 最重要的概念就是:將 字符串 視爲 碼元序列,那是字符串真正的樣子。
一旦開發者將 字符串 理解爲由 字位(或符號) 組成,而忽落了 碼元序列 的概念,就會產生困惑了。
在處理字符串時,如果包含 代理對 或 組合字符序列,就容易出現一些坑:
-
獲取字符串長度
-
字符定位
-
正則表達式匹配
注意,JavaScript 中大部分方法不是 Unicode 感知的:比如 myString.indexOf()
,myString.slice()
等等。
ECMAScript 2015 引入了很棒的特性,諸如 字符串 和 正則表達式 中的 碼位轉義序列 \u{1F600}
。
新的正則表達式標誌 u
可以開啓 Unicode 感知 的 字符串匹配。這簡化了 星光平面中符號 的匹配。
字符串迭代器 String.prototype[@@iterator]()
是 Unicode 感知的。你可以使用 展開運算符 [...str]
或 Array.from(str)
來創建 符號數組,然後就可以在不破壞代理對的條件下,計算字符串長度 或 通過索引訪問其中的字符。注意,這些操作會影響性能。
如果你需要更好的方式來處理 Unicode 字符,你可以使用 工具庫 punycode 或 generate 來生成專業的正則表達式。
我希望這篇文章能幫助你掌握 Unicode!
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/YIJzT7ymxbxNxXYsV8zpVg