每個 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 特性來解決部分問題。

準備好了嗎?燥起來!

  1. Unicode 背後的思想

我們先從一個本質上的問題開始。你爲何能夠閱讀理解這篇文章?很簡單,因爲你知道每一個 字 和 由字組成的詞 的含義。

爲什麼你能理解每一個字的含義呢?簡單來說,因爲這些圖形符號(你在屏幕上看到的東西)和 中文這門語言中的漢字(含義)之間有着聯繫,而你(讀者)和我(作者)都認同這一點。

這對於計算機來說也是一樣的。不同之處在於計算機無法理解字的含義,它們對於計算機僅僅是 二進制位序列。

想象這樣一個場景, 用戶 1  通過網絡向 用戶 2 發送了一條消息 hello

用戶 1 的計算機不知道其中每個字母的含義。所以它會將 hello 轉換成數字序列 0x68 0x65 0x6C 0x6C 0x6F ,其中每一個字母唯一對應一個數字: h 對應 0x68e 對應 0x65 ,諸如此類。這些數字會被髮送到 用戶 2 的計算機上。

用戶 2 的計算機接收到數字序列 0x68 0x65 0x6C 0x6C 0x6F 後,它會使用同樣一套字母和數字的對應關係來還原信息。然後展示出正確的信息: hello

兩臺計算機之間 在 字母和數字的對應關係 這個方面達成的協議 就是 Unicode 標準化的產物。

按照 Unicode 標準, h 是一個叫做 LATIN SMALL LETTER H 的抽象字符。這個字符對應的數字是 0x68 ,其碼位(code point)記爲 U+0068

Unicode 會提供一個 抽象字符列表(字符集),併爲每個字符分配一個獨一無二的碼位標識符(編碼字符集)。

  1. 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+0000U+10FFFF 的數字。

U+<hex> 是碼位的格式,其中 U+ 是代表 Unicode 的前綴,而 <hex> 表示十六進制數字。例如, U+0041U+2603 都是碼位。

記住,碼位 就是一個簡簡單單的數字,你不用考慮更多了。碼位 就是一種數組中的元素索引。

就是因爲 Unicode 將 碼位 和 字符 關聯起來,才變得不可思議。例如, U+0041 對應的 字符 名爲 LATIN CAPITAL LETTER A (渲染爲 A ),U+2603 對應的 字符 名爲 SNOWMAN (渲染爲 )。

並非所有 碼位 都有關聯字符。總共有 1,114,112 個碼位可用(範圍從從 U+0000U+10FFFF ),但是隻有 144,697 個(截止 2021.9)被分配了字符。

2.2 Unicode 平面(Unicode Planes)

!! “ 平面(Planes) 是從 U+n0000U+nFFFF 的 65,536(或2BAH1v ) 個連續的碼位,n 的取值範圍從bNxG84m4F6rW

平面 將全部的 Unicode 碼位分成了 17 個均等的組:

基本多文種平面(Basic Multilingual Plane)

平面 0 很特殊,名爲 基本多文種平面(Basic Multilingual Plane) 或 簡稱 BMP 。它包含絕大多數現代語言中的字符 (Basic Latin 基礎拉丁字母,Cyrillic 西裏爾字母,Greek 希臘字母,等等)和 大量的符號。

綜上所述,基本多文種平面 範圍是從 U+0000U+FFFF ,最多 4 位十六進制數字。

開發者通常只會處理 BMP 中的字符。BMP 包含了絕大部分必要的字符。

BMP 中的一些字符:

星光平面(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+10000U+10FFFF

一個 星光碼位 有 5 或 6 位 十六進制數字:U+dddddU+dddddd

讓我們看看 星光平面 中的一些字符:

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)是一種可變長度編碼:

我們枯燥的理論談的有點多了。現在我們來看一些例子。

假設你想將 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'); // ='😀'

高位代理碼元 從 0xD8000xDBFF 取值。低位代理碼元 從 0xDC000xDFFF 取值。

將 代理對 轉換爲 星光碼位 的算法如下,反之亦然:

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+0061U+030A 來構造 å

  1. JavaScript 中的 Unicode

ES2015 規範 提到 ,源碼文本使用 Unicode (版本 5.1 以上)。源碼文本是一個從  U+0000U+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+00U+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+0000U+FFFF (BMP 的全部碼位),大多數情況下,這已經足夠表示那些常用的符號了。

想要再 JavaScript 字面量 中表示一個 星光平面中的符號,需要使用兩個連接在一起的 Unicode 轉義序列(高位代理 和 低位代理),這就創建了一個 代理對:

const str = 'My face \uD83D\uDE00';
console.log(str); // ='My face 😀'

碼位轉義序列

ECMAScript 2015 提供了能夠表示整個 Unicode 空間(U+0000U+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

firstStrsecondStr 字符串是相同的碼元序列。

假設你想比較兩個渲染出來相同,但包含不同碼元序列的字符串。那麼你也許會得到一個意想不到的結果,字符串 看起來相同 但 比較結果是不相等:

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

str1str2 渲染看起來相同,但碼元不同。之所以會發生這種情況,字位 ç 是通過兩種不同的方式構造的:

如何處理這種情況並正確比較字符串?答案是字符串 正規化(Normalization)。

正規化(Normalization)

!! “正規化 就是將字符串轉換爲規範的表示形式,以確保 標準等價(canonical-equivalent)和 / 或 兼容等價(compatibility-equivalent)的 字符串 有標準的表示形式。”

換句話說,當字符串結構複雜(包含組合字符序列 或 其他複雜結構),可以 將它 正規化 得到規範格式。正規化的字符串可以無痛 比較 或 執行文字搜索等字符串操作,以此類推。

Unicode 附加標準 #15 提供了 正規化 過程中有趣的細節。

在 JavaScript 正規化 字符串 可以調用myString.normalize([normForm]) 方法,該方法在 ES2015 中可用。normForm 是一個可選參數(默認爲 'NFC'),其值也可以是一下 正規化 格式:

我們通過應用字符串正規化來改進之前的例子,這能幫助我們正確的比較字符串:

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() 就會和預期一樣返回 truestr1 不受 正規化 影響,因爲它已經是規範形式了。

將兩個比較的字符串都 正規化,得到兩個操作數的規範表示是非常合理的。

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.length5),即使它被渲染爲了 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.length6 。然而 drink 有 4 個符號。

正規化 drink.normalize() 將 組合序列 'e\u0327\u0301' 轉換成了 兩個字符的標準形式 'ȩ\u0301' (只消去了一個組合符號)。很可悲,drink.normalize().length 得到了 5,仍然不能表示出正確的符號個數。

3.4 字符定位

由於字符串就是一系列碼元,所以通過字符串索引來訪問字符也是存在一些難題的。

如果字符串只包含 BMP 字符(不包含 U+D800U+DBFF 高位代理 和 U+DC00U+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 種在字符串中正常訪問 星光平面符號 的方法:

我們來試試看這兩種方法:

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 FACEU+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 個元素。

  1. 總結

可能在 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