JavaScript emoji utils
寫在前面
JavaScript的字串處理貌似不難,直到遇上了emoji:

javascript-emoji-issues
??發生了什麼? 到底怎麼回事?
得從Unicode編碼說起……
一.Unicode編碼
The Unicode codepoint range goes from U+0000 to U+10FFFF which is over 1 million symbols, and these are divided into groups called planes. Each plane is about 65000 characters (16^4). The first plane is the Basic Multilingual Plane (U+0000 through U+FFFF) and contains all the common symbols we use everyday and then some. The rest of the planes require more than 4 hexadecimal digits and are called supplementary planes or astral planes.
也就是說,Unicode支援的編碼範圍是 U+0000
到 U+10FFFF
,能對應100多萬個符號( 0x10FFFF === 1114111
)。這些符號被分組歸入16個 平面(panel) ,所以每個平面放65536( 16^4 === 65536
)個
其中,常用符號都放在第一個平面( U+0000
到 U+FFFF
)裡,所以稱之為 基本多語言平面(Basic Multilingual Plane,也簡稱BMP) ,其餘的平面中的 碼位值(codepoint,即符號對應的Unicode編碼值) 都大於4位(16進位制),稱為 輔助平面(supplementary plane)
P.S.輔助平面還有個看起來 很厲害 的名字,叫astral plane(星界?星界位面?)
I have no idea if there’s a good reason for the name “astral plane.” Sometimes, I think people come up with these names just to add excitement to their lives.
此外,基本多語言平面裡 65536
個位置的入住率並不是 100%
, 專門空出來一些位置 以備不時之需,比如新增特殊含義符號,或者擴充套件
比如UTF-16中 代理對兒(surrogate pairs) 的概念,即用兩個4位(16進位制)的小碼位值表示一個大碼位值(大於4位),算是一種從基本多語言平面到輔助平面的對映,之所以能這樣做,就是因為:
基本多語言平面內,從U+D800到U+DFFF之間的碼位區段是永久保留不對映到Unicode字元。UTF-16就利用保留下來的0xD800-0xDFFF區段的碼位來對輔助平面的字元的碼位進行編碼。
二.JavaScript中的Unicode
JS中的Unicode字元有3種表示方法:
'A' === '\u0041' === '\x41' === '\u{41}'
其中 \x
僅用於 U+0000
到 U+00FF
, \u
適用於任意Unicode字元( U+0000
到 U+10FFFF
),但大於4位(大於 U+FFFF
)的話,就要用花括號( {}
)把十六進位制序列包起來:
The \x can be used for most (but not all) of the Basic Multilingual Plane, specifically U+0000 to U+00FF. The \u can be used for any Unicode characters. The curly braces are required if there are more than 4 hexadecimal digits and optional otherwise.
注意, \u{}
轉義語法是在ES 2015中定義的,稱之為 ofollow,noindex" target="_blank">UnicodeEscapeSequence 。之前用兩個小Unicode來表示一個大Unicode,例如:
'' === '\u{1F4A9}' '' === '\uD83D\uDCA9'
\uD83D\uDCA9
就是代理對兒,形如 <H,L>
,二者的轉換關係如下:
let C, L, H; C = 0x1F4A9; // 公式:大Unicode轉代理對兒 H = Math.floor((C - 0x10000) / 0x400) + 0xD800; L = (C - 0x10000) % 0x400 + 0xDC00; [H, L].map(v => '\\u' + v.toString(16).toUpperCase()).join('') "\uD83D\uDCA9"
另外,JS中認為一個16位無符號整數值是一個字元,所以一個emoji可能會被認為是多個字元:
The phrase code unit and the word character will be used to refer to a 16-bit unsigned value used to represent a single 16-bit unit of text. Unicode character only refers to entities represented by single Unicode scalar values: the components of a combining character sequence are still individual “Unicode characters”, even though a user might think of the whole sequence as a single character.
P.S.關於JavaScript的Unicode支援以及ES規範的相關內容,見 JavaScript’s internal character encoding: UCS-2 or UTF-16?
正則表示式中的Unicode
既然大Unicode(大於 U+FFFF
的)在JS中用兩個小Unicode(代理對兒)來表示,那麼自然會寫出這樣的正則表示式:
> /[\uD83D\uDCA9-\uD83D\uDE0A]/.test('') Uncaught SyntaxError: Invalid regular expression: /[\uD83D\uDCA9-\uD83D\uDE0A]/: Range out of order in character class
報錯無法識別這樣的range,那 怎樣用正則表示式描述大Unicode字元範圍 呢?
JS提供了 u flag 來解決這個問題:
u Unicode; treat pattern as a sequence of Unicode code points
/[\uD83D\uDCA9-\uD83D\uDE0A]/u.test('') /[-]/u.test('')
類似的, .
(點號匹配任意字元)想要匹配代理對兒形式的大Unicode的話,也需要開啟u flag:
> /foo.bar/.test('foobar') false > /foo.bar/u.test('foobar') true
P.S. /./u
僅能匹配代理對兒形式的emoji ,其它形式的不行,例如:
> /foo.bar/u.test('foo2⃣️bar') false
P.S.更多相關示例,見 Astral ranges in character classes
fromCodePoint與fromCharCode
String.fromCodePoint
與 String.fromCharCode
的區別在於,前者支援更大範圍的16進位制Unicode編碼,例如:
> String.fromCodePoint(0x1F4A9) "" > String.fromCharCode(0x1F4A9) ""
但 fromCodePoint
由ES 2015規範定義,相容性不如 fromCharCode
好,對於 0x0000-0xFFFF
範圍的65536個Unicode字元,建議使用 fromCharCode
三.emoji編碼
類似於Unicode,emoji也是一種編碼規則,也有對應的規範,還存在很多個版本:
Emoji 12.0 Emoji 11.0 Emoji 5.0 Emoji 4.0 Emoji 3.0 Emoji 2.0 Emoji 1.0
其中 12.0
計劃2019年才釋出,最新的 11.0 釋出於 2018-02-07
像HTML、CSS規範一樣,新版規範中新增的emoji不一定都被實現了,並且面臨的 相容性問題比HTML、CSS更惡劣 :
-
規範版本:emoji規範發版頻繁,多版本共存
-
平臺差異:除了Web瀏覽器環境外,emoji還依賴平臺原生支援(各種螢幕顯示裝置)
-
依賴Unicode:emoji是在Unicode基礎上建立的,依賴Unicode規範
比如從簡訊複製貼上到網頁輸入框,emoji可能就顯示不出來或者亂碼了,因為native與Web瀏覽器支援的emoji規範版本或實現程度存在差異。另外,Unicode新規範可能會與已定義的emoji規範有衝突,這時候自然得由emoji規範讓步:
Unicode 12.0 is the new version of the Unicode Standard planned for release in March 2019. See Emoji 12.0 for a more complete list of potential emojis for 2019. Note: All emojis listed throughout 2018 are candidates only, and subject to change before a final release.
emoji面臨的環境有多惡劣呢?如圖:

emoji-unicode-platform
回到emoji規範本身,長這樣子:
1F600 ; emoji ; L1 ;secondary ; x# V6.1 () GRINNING FACE 1F48F ; emoji ; L1 ;none ;j# V6.0 () KISS
最左邊是Unicode碼位值,被成功錄入Unicode規範的話, U+1F48F
就會對應 KISS
表情:
> '\u{1F48F}' ""
除了這種與Unicode一一對應的emoji,加入Unicode大家庭外,還 有幾種特殊的emoji :
-
variation selector-16 :一個不可見字元(
U+FE0F
),表示在它前面的字元應該用emoji顯示 -
zero width joiner :零寬連線符,是一個零寬空格(
U+200D
),用來把多個emoji合成為一個emoji -
tone modifier:膚色修飾,一種語法,能改變前一個emoji的膚色,語法格式是
<emoji>\ud83c[\udffb-\udfff]
,即U+D83C
後面跟不同的幾個值表示不同的膚色控制 -
keycap:鍵帽符號,鍵帽樣式的
0-9
、#
和*
,以U+20E3
結尾 -
unofficial emoji flag:存在一些非常規國旗emoji,以黑色旗子(
U+1F3F4
)開頭, 取消符號 (U+E007F
)結尾
例如:
// \ufe0f讓黑心字元顯示成emoji,連續兩個也沒關係 ':heart:️' === '\u2764\ufe0f\ufe0f' '\u2764\ufe0f' === ':heart:' '\u2764' === '❤' // 零寬連線符\u200d合成複雜表情, + :heart: ++= :heart: ':heart:' == '\ud83d\udc69\u200d\u2764\ufe0f\u200d\ud83d\udc8b\u200d\ud83d\udc69' // 膚色修飾,黑baby、白baby '' === '\ud83d\udc76\ud83c\udfff' '' === '\ud83d\udc76\ud83c\udffb' // 鍵帽樣式 ':hash:' === '\u0023\ufe0f\u20e3' // 非官方國旗 '' ==='\ud83c\udff4\udb40\udc67\udb40\udc62\udb40\udc73\udb40\udc63\udb40\udc74\udb40\udc7f'
四.JavaScript裡的emoji
那麼 在JS裡,一個emoji到底含有幾個Unicode字元?
> ':golf:'.length 1 > ''.length 2 > ':one:'.length 3 > ''.length 4 > ''.length 11 > ''.length 14
一個emoji字面量的長度從 1
到 14
(還可能存在更長的)各不相同……所以,會出現這種情況:
> '我們是一家人'.slice(0, 1) "�" > '我們是一家人'.substr(0, 2) ""
期望通過 slice(0, 1)
擷取第一個emoji,卻 得到了一個無法顯示的字元 ,甚至 substr(0, 2)
從一家4口中拆出了Man()…… 這可咋整?
對於某些emoji,有一種非常簡單的處理方式, Array.from
:
> Array.from('').length 1
字串轉陣列時會保持代理對兒在一起,所以 length
正確了,但 這種方法不是萬能的 :
> Array.from('').length 7
P.S.類似的,支援Unicode編碼轉換的 bestiejs/punycode.js 也存在類似的問題:
> punycode.ucs2.decode(' ').length 1 > punycode.ucs2.decode('').length 7
也就是說, 單靠JS對Unicode的原生支援,無法正確處理含emoji的字串 。那麼,在一些場景會遇到問題:
-
表單檢驗字數限制
-
擷取文章摘要
-
反轉字串
-
逐字元處理
-
正則匹配
-
……其它含emoji的文字處理場景
例如:
> '一個打十個'.length >= 10 === true true > '你好hi233..。'.substr(0, 10) "你好hi233�" > Array.from(':one:23').reverse().join('') "32⃣️1" > '開心'[0] === '' false > /a.b/.test('ab') false
P.S.關於JavaScript中Unicode的更多問題,見 JavaScript has a Unicode problem
五.解決方案:emoji-utils.js
要解決上面列出的一排問題,只能想辦法識別emoji了,目前( 2018/09/15
)貌似還沒有這樣的工具庫
手搓一個,類似於詞法分析,逐字元匹配,挑出符合emoji編碼規則的Unicode組合,具體見下面原始碼
Github地址: https://github.com/ayqy/emoji-utils
線上Demo(測試case): https://ayqy.github.io/emoji/index.html
API
提供了6個簡單API:
// 是不是一個emoji isEmoji(str) // 是否包含emoji containsEmoji(str) // 字串轉Unicode陣列 str2unicodeArray(str) // 計算長度 length(str) // 子串擷取 substr(str = '', start = 0, len = Infinity) // 字串轉陣列,相當於split('') toArray(str)
內部未暴露的方法有:
// 嘗試匹配開頭的emoji,失敗返回'' matchOneEmoji(str, matched = '')
缺陷
但是,這些工具函式 並不100%靠譜 ,因為:
Not all browsers, UIs, etc even render :heart: as a single symbol. The code assumes the joiners are used between characters appropriately which could be very problematic.
所以, emoji-utils.js
的 實現基於3點假設 :
-
所有代理對兒都是emoji(事實上,有些代理對兒不是emoji)
-
膚色控制對所有emoji都是有效的,並且只對emoji生效(對普通文字符號無效)
-
joiner連線起來的emoji都算一個,無論顯示上能否被合成一個emoji
對於第一點假設,代理對兒形式的不一定是emoji,也可能是純文字,例如:
'\ud835\udc00' === ''
後兩點假設也會導致一些badcase,例如(Chrome Console環境):
// 嘗試製造黑色笑臉,未遂 '\ud83d\ude0a\ud83c\udfff' === '' // 嘗試人工合成新物種,失敗 '\u0023\ufe0f\u20e3\u200d\ud83d\ude0a' === ':hash:' '\ud83d\ude0a\u200d\ud83d\ude0a' === ''
這些case都會被識別成1個emoji,而Chrome Console環境顯示是2個,因為它們:
-
符合emoji編碼的語法規則
-
但不一定是合法的emoji
-
即便合法,當前平臺也不一定支援
emoji-utils.js
假設滿足第一點的就是一個獨立顯示的合法emoji,未考慮emoji規範版本以及平臺支援性,所以存在這樣的badcase。badcase可能帶來的影響是:
-
isEmoji/containsEmoji()
誤判類似於”的文字字元 -
length()
小於實際顯示的字元長度 -
substr()/toArray()
與實際預期不符
所以能這個工具庫所能識別出的字符集是emoji的超集,多出來一部分代理對兒形式的文字,以及符合emoji編碼規則但在emoji規範中未定義的字元序列。儘管如此,它是 目前最健全的JS emoji處理工具 ,實際應用中足夠應對大多數場景了