譯者注:本文用到了很多 emoji 符號,建議不要使用 Windows 系統閱讀本文。
如今 emoji 已經成為文字交流的重要基礎。離開這些精巧的符號,只怕很多對話早就因尷尬和誤解而草草收場了。還記得當年簡訊風行時的那些事嗎?
沒有笑臉表情的文字聊天過程中,常常會得到“你不是在開玩笑吧?”這樣的回覆,以免將一些無聊的笑話信以為真。後來並沒有花多久的時間,大家都明白了,單純靠文字來理解那些幽默與調戲並不那麼容易(但不管怎麼說,這種套路確實應該少一些)。世界上首個 emoji 誕生之後不久,emoji 很快成為文字交流中不可或缺的要素。
日用之而不覺,我從未思考過 emoji 在技術層面上是如何工作的。但無論如何,它們肯定和 Unicode 有關係,儘管我確實不瞭解實際機制。老實說,我倒也沒怎麼在意……
讀了 Wes Bos 的一條推文之後,我的想法被徹底改變。Wes Bos 在這條推文中分享了一些 JavaScript 字串操作,其中也包括表示家庭的 family emoji。
1 2 |
[...'???'] // ["?", "", "?", "", "?"] '???'.length // 8 |
OK, 對字串使用展開運算操作倒沒什麼稀奇的,可是一個符號拆分出了三個符號外加兩個空字元,我頗有些疑惑。接著看到該符號的 length
(長度) 竟然是 8,愈加困惑,展開陣列中明明就只有五項啊。
當即測試這段程式碼,絲毫不爽,果然如 Wes 所述。什麼鬼?不深入瞭解 Unicode、JavaScript 和 emoji,就難解我心頭之惑。
Unicode 簡介
JavaScript 為什麼會如此處理 emoji 呢?欲要理解箇中原理,還需深入去看 Unicode 本身。
Unicode 是國際計算機工業標準。它是一個字母(或字元、符號)對應一個數值的對映集。如果沒有 Unicode,像那些含有像德文字母 ß、ä、ö 這樣的特殊字元的文件,就無法在其他不使用這類字元的系統上共享。感謝 Unicode 的跨平臺、跨系統編碼。
Unicode 中共有 1,114,112 個不同的碼點(code point),它們通常使用 U+
加上一個十六進位制數字表示。Unicode 碼點取值範圍是 U+0000
到 U+10FFFF
。
這些碼點總數超過十億,它們被分為 17 個“平面”(plane)。每個平面包含六萬五千多個碼點。其中,最重要的平面是“多語言基本平面”(Basic Multilingual Plane,BMP),範圍是 U+0000
至 U+FFFF
。
BMP 基本平面幾乎包含了所有現代語言中使用到的字元,以及很多其他符號。其餘 16 個平面稱作“補充平面”(Supplementary Planes),其中包含一些不同的案例,比如——聰明如你,可能已經猜到了——大多數 emoji 符號的定義。
emoji 是如何定義的
我們今天所知的 emoji 至少由一個 Unicode 碼點所定義。可以看下 Full Emoji Data list,其中列出了所有定義的 emoji。你可能會問,Unicode 目前到底定義了多少不同的 emoji 呢?答案是“視情況而定”,這可是電腦科學中常見的答案。要回答這個問題,首先需要理解 Unicode。
如前面所述,emoji 至少由一個碼點定義。這也就意味著,還有一些 emoji 是由幾種不同的 emoji 和碼點組合而成的。這些組合稱作序列(sequence)。有了序列,就可以做一些別的事,比方說,修飾那些中性 emoji (通常用黃色皮膚展示),讓它們符合你的風格。
修飾序列
猶記得當初在聊天中發現可以按自己的膚色修飾“點贊”表情的時候,我感受到了一種包容,這個表情與我之間的聯絡似乎變得更加緊密了。
Unicode 中有五種修飾符,用於修飾與人相關的中性 emoji。不同的修飾符會產生不同膚色效果。修飾符基於 Fitzpatrick 量表 設定,其編碼範圍為U+1F3FB
~U+1F3FF
。
下面是使用修飾符修改 emoji 膚色的示例:
1 2 3 |
// U+1F467 + U+1F3FD ? + ? > ?? |
在那些支援修飾序列的作業系統中,為碼點值為 U+1F467
的小女孩 emoji 新增修飾符之後,就能得一個膚色發生變化的小女孩表情。
零寬連線序列
與人相關的,可不止膚色這一種。再看看前面提到的家庭 emoji,顯然並非所有家庭都是由爸爸、媽媽、兒子三者組成的。
Unicode 中包括一箇中性的表示家庭的碼點(U+1F46A
– ?),但這並非家庭真實寫照。不過,還可以使用“零寬連線符”序列(Zero-Width-Joiner sequence)建立一些不同的家庭符號。
先來談談工作原理:Unicode 中有一個稱為零寬連線符(U+200D
)的碼點。它就像膠水一樣,將兩個碼點粘在一起以單個符號的形式展現。
想想要組成一個家庭的話,需要將哪些符號連在一起呢?很簡單,兩個大人,一個孩子。使用零寬連線符很容易就能拼出各種各樣的家庭符號。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
// 中性家庭 // U+1F46A > ? // 零寬連線序列: 家庭 (男人, 女人, 男孩) // U+1F468 + U+200D + U+1F469 + U+200D + U+1F466 // ? + U+200D + ? + U+200D + ? > ??? // 零寬連線序列: 家庭 (女人, 女人, 女孩) // U+1F469 + U+200D + U+1F469 + U+200D + U+1F467 // ? + U+200D + ? U+200D + ? > ??? // 零寬連線序列: 家庭 (女人, 女人, 女孩, 女孩) // U+1F469 + U+200D + U+1F469 + U+200D + U+1F467 + U+200D + U+1F467 // ? + U+200D + ? + U+200D + ? + U+200D + ? > ???? |
可以檢視全部的零寬連線序列,其中的型別更加多種多樣,比如,帶著兩個女孩的父親。不幸的是,在本文寫作的時候,這些序列的支援度並不是很好。好在零寬連線序列還能優雅降級,單個碼點分別獨立顯示。這有助於保持特殊組合符號的語義。
1 2 3 4 |
// 零寬連線序列: 家庭 (男人, 女孩, 女孩) // U+1F468 + U+200D + U+1F467 + U+200D + U+1F467 // ? + U+200D + ? + U+200D + ? > ??? -> 尚不支援的情況下以這種形式顯示 |
還有很棒的一點是,上面這些原則並不是僅僅針對家庭 emoji 的。來看看著名的 David Bowie emoji(該 emoji 的真名應該是“男歌手”)。這個表情實際上也是一個零寬連線序列,由一個男士(U+1F468
)、一個零寬連線符和一個耳機(U+1F3A4
)組成。
可能你已經猜到了,將男人(U+1F468
)替換成女人(U+1F469
),結果就是一個女歌手(女版 David Bowie)。若再引入可以修改膚色的修飾符,還可能出現一個黑人女歌手。棒棒噠!
1 2 3 4 |
// 零寬連線序列: 女歌手 // U+1F469 + U+1F3FF + U+200D + U+1F3A4 // ? + ? + U+200D + ? > ??? -> 尚不支援的情況下以這種形式顯示 |
然而,依然不幸,目前這種序列的支援程度也並不是很好。
emoji 數量
回答 emoji 到底有多少種,得看怎麼算了。是可用於展示 emoji 的不同碼點的數量嗎?需要計算可以展示的各種不同的 emoji 變體嗎?
如果計算可展示的不同 emoji(包括所有序列、變體),總數是 2198。如果你對計算感興趣,可以看下 unicode.org 上的完整章節。
除了“如何計算”這個問題之外,還有一個現實問題:新的 emoji 和 Unicode 字元在不斷加入規範,想要記錄準確的總數還是挺困難的。
JavaScript 字串與 16 位程式碼單元
JavaScript 字串的格式是 UTF-16,使用一個 16 位的程式碼單元表示最常見的字元。掐指一算,這意味著一個程式碼單元能放下六萬五千多個碼點(譯者注:2^16=65536
),幾乎和 BMP 一一對應。下面使用 BMP 中的一些符號試試看:
1 2 3 |
'ツ'.length // 1 -> U+FF82 '⛷'.length // 1 -> U+26F7 '☃'.length // 1 -> U+9731 |
不出所料,這些字元的 length
值正好是 1。可是,如果要用到的字元不在 BMP 範圍內呢?
代理對
還可以將兩個 BMP 碼點結合在一起,形成一個新的碼點,這就是代理對(surrogate pair)。
U+D800
到 U+DBFF
之間的保留碼點用於所謂的高階代理(又作 leading surrogates,主代理),U+DC00
到 U+DFFF
之間的保留碼點則用於低階代理(又作 trailing surrogates,尾代理)。
這兩類碼點總是同時成對出現,高階代理後面跟著低階代理。然後通過特定演算法對超出範圍的碼點進行解碼。
一起來看下面的例子:
1 2 3 4 5 |
'?'.length // 2 '?'.charCodeAt(0) // 55357 -> U+D83D // 返回主代理的碼點 '?'.charCodeAt(1) // 56424 -> U+DC68 // (譯者注:這個是尾代理碼點) '?'.codePointAt(0) // 128104 -> U+1F468 // 返回組合在一起的代理的碼點 '?'.codePointAt(1) // 56424 -> U+DC68 |
中性的男性 emoji 的碼點是 U+1F468
,在 JavaScript 中無法通過單個程式碼單元來表示。這就是為何需要使用代理對的原因,通過兩個單獨的程式碼單元組成這個表情。
分析 JavaScript 中的程式碼單元,有兩種可能有用的方法。一個是 charCodeAt
,遇上代理對的時候,該方法會分別返回每個代理的碼點。另一個方法是 codePointAt
,遇上主代理時會返回代理對組合的碼點,遇上尾代理時則返回尾代理的碼點。
看起來有點恐怖?深有同感。強烈建議仔細 MDN 上的相關文章。
再從數學方面深入看一下這個代表男性的 emoji。通過 charCodeAt
方法,我們可以檢索到組成代理對的獨立程式碼單元。
我們得到的第一個值是 55357
,也就是十六進位制的 D83D
,這個是高階代理。得到的第二個值是 56424
,即十六進位制的 DC68
,這是低階代理。這兩個典型的代理對經過運算後便得到了 128104
,對映到 emoji 就是男性符號。
1 2 3 4 |
// 十六進位制 0x1F468 = (0xD83D - 0xD800) * 0x400 + 0xDC68 - 0xDC00 + 0x10000 // 十進位制 128104 = (55357 - 55296) * 1024 + 56424 - 56320 + 65536 |
JavaScript 中的 length
屬性與碼點數量
學習了碼點的相關知識,現在可以理解這讓人困惑的 length
屬性了。它會返回的是碼點的數量,而非一開始所認為的肉眼所見符號的數量。在處理 JavaScript 字串的時候,這讓尋找 bug 變得相當麻煩。所以處理 BMP 平面之外的符號時千萬要當心。
小結
再回到 Wes 最初的例子。
1 2 3 4 5 6 7 8 9 |
// 零寬連線序列: family (man, woman, boy) // U+1F468 + U+200D + U+1F469 + U+200D + U+1F466 [...'???'] // ["?", "", "?", "", "?"] '???'.length // 8 // neutral family // U+1F46A [...'?'] // ['?'] '?'.length // 2 |
我們在這裡看到的家庭 emoji 由一個男性、一個女性、一個男孩組成。展開運算子會檢查所有碼點。我們所看到的空字元並非真正的空字元,而是零寬連線符。讀取該 emoji 的 length
屬性會得到 8,其中每個 emoji 的 length
為 2,每個零寬連線符的 length
為 1,合起來正好是 8。
我真心享受深挖 Unicode 的過程。如果你同樣對這個話題感興趣,必須向你推薦 @fakeunicode 這個 Twitter 賬號。你知道嗎,甚至還有關於 emoji 的 podcast 和會議 呢。我會保持關注的,瞭解這些每天都在使用的小符號真是有趣極了,你可能也會感興趣的。