文章內容較多,如果只是尋找解決方案,請直接劃到文末。
談到 emoji
想必我們都不陌生,它是一種廣泛使用在網頁和聊天上的表情符號,如 😂, 😄 等。
雖然 emoji 是合法的字符串內容,但由於其反直覺的長度和類型的多樣,在分割的時候很可能會產生出乎意料的結果。比如下面這個例子:
'😃⛔'.split('') // ['\uD83D', '\uDE03', '⛔']
なに?怎麼兩個符號分割變成了三個?還亂碼了?
別慌,讓我們先來看看他們的長度。
'⛔'.length // 1
'😃'.length // 2
'👦🏾'.length // 4
'🏳️🌈'.length // 6
'👨👨👧👧'.length // 11
完了,這不看還不知道,這越看越離譜。究竟是咋回事?為什麼 emoji 分割後有的 emoji 會亂碼而有的不會?為什麼 emoji 的長度不是 1?
讓我們帶著這些疑問,繼續往下看。
重新認識 emoji
繪文字(日語:絵文字 / えもじ ),也就是本文所說的 emoji,是日本在無線通信中所使用的視覺情感符號。在中國,emoji 通常叫做 “小黃臉”,或者直稱 emoji。自蘋果公司發布的 iOS 5 輸入法中加入了 emoji 後,這種表情符號開始席捲全球,emoji 已被大多數現代 計算機系統所兼容的 Unicode 編碼採納,普遍應用於各種手機短信和社交網絡中。
2010 年 10 月發布的 Unicode 6.0 版首次收錄了表情符號編碼,通過 Unicode 區塊來劃分不同的 emoji。
除了這些常規的 emoji 表情之外,Unicode 8.0 中還加入了 5 個修飾符: 🏻 🏼 🏽 🏾 🏿 ,加在部分人的 emoji 表情的後面,用來調節人形表情的膚色,這些叫做表情符號菲茨帕特里克修飾符,對應了菲茨帕特里克度量對人類膚色的分類。
比如:👦 👦🏻 👦🏼 👦🏽 👦🏾 👦🏿 和 👧 👧🏻 👧🏼 👧🏽 👧🏾 👧🏿。
此外,還有組合的方式產生的 emoji,比如使用 U+200D 零寬連字 (ZWJ) 將兩個表情符號連起來,使其看起來像是一個表情符號(如:👨👩👧)。這在系統支持的情況下會顯示為一個男人一個女人和一個女孩組成的家庭表情符號,而不支持的系統則會依序顯示這三個表情符號 (👨👩👧)。此外還有男女的組合 emoji,比如某個女性 emoji 表情,加上零寬連字和 ♂ 就成為了男性版本。
有關 Unicode 中 emoji 的規範標準參見這裡,定義了 Unicode 表情符號字符和序列的結構,並提供了支持該結構的數據。
正是由於以上提到的 emoji 的這些多樣性,使得其在作為常規字符串進行分割處理時,分割結果不符合我們的直覺。
那我們有哪些辦法來解決這個問題?
解決方案初探(文末有最終方案總結)
我們可以試試根據 Unicode 中 emoji 的定義,通過正則的方式對 Unicode 中分給 emoji 的區塊進行匹配,然後進行分割,並過濾掉為空的或者未定義的區塊。
function emojiStringToArray(str) {
const reg = /([\uD800-\uDBFF][\uDC00-\uDFFF])/
return str.split(reg).filter(Boolean)
}
讓我們來測測這個函數在實際使用中表現如何:
emojiStringToArray('😴⛔🎠🚓🚇') // ['😴', '⛔', '🎠', '🚓', '🚇']
看起來常規 emoji 的效果還可以,但是對剛才提到的膚色 emoji 或者是組合 emoji 就有點力不從心了,比如下面的例子。
emojiStringToArray('👨👨👧👧') // ['👨', '', '👨', '', '👧', '', '👧']
emojiStringToArray('👦🏾') // ['👦', '🏾'] 如果這裡顯示方框問號,其實是膚色的 emoji,可能顯示不出來
好家伙,這第一個 👨👨👧👧 直接給人家一家子拆散了,真有你的。
等會兒,剛才不是做了 filter(Boolean)
判空過濾處理嗎,為什麼上述結果數組裡還有 “空字符串”?
馬薩卡...(難道說)
我們拿這個輸出的 “空字符串” 測試一下:
'' === '' // false
if ('') {
console.log('This is true!') // 成功打印 This is true!
}
好家伙,原來這並不是空字符串。
細心的你也許在上述 Unicode 裡有關組合 emoji 的介紹中發現,這個 “空字符串” 其實就是剛才提到的 U+200D 零寬連字 (ZWJ) ,直觀看來,他和空字符串沒有什麼兩樣,但這完全是不同的字符,這個字符專門用於連接特定的 emoji 組成組合 emoji。
我們還可以試試 ES6 的展開運算符(spread operator
)。
;[...'😴⛔🎠🚓🚇'] // ['😴', '⛔', '🎠', '🚓', '🚇']
[...'👨👨👧👧'] // ['👨', '', '👨', '', '👧', '', '👧']
[...'👦🏾'] // ['👦', '🏾']
還有 Array.from()
,嘗試後發現,其實也是這個情況。
Array.from('😴⛔🎠🚓🚇') // ['😴', '⛔', '🎠', '🚓', '🚇']
Array.from('👨👨👧👧') // ['👨', '', '👨', '', '👧', '', '👧']
Array.from('👦🏾') // ['👦', '🏾']
這幾種方法實質是一樣的,本身也是沒有問題的。問題出在,有些 emoji 並不是 “單個存在” 的,他可能會有一些附加的,比如膚色,或者組合 emoji。要想準確的判斷 emoji,得把這兩個特殊情況也考慮進去。
優化方案
使用 Intl.Segmenter
分割。
很多人可能連
Intl
都不太了解,甚至是第一次見到,我承認我基本沒見過,也確實沒怎麼用過。這裡引用 MDN 關於 Intl 的解釋:Intl
對象是 ECMAScript 國際化 API 的一個命名空間,它提供了精確的字符串對比、數字格式化,和日期時間格式化。
Intl.Segmenter
對象支持語言敏感的文本分割,允許你將一個字符串分割成有意義的片段(字、詞、句)。
我們來試試 Intl.Segmenter
。
const splitEmoji = string => {
const segment = new Intl.Segmenter().segment(string)
return [...segment].map(e => e.segment)
}
splitEmoji('😴😄😃⛔🎠🚓🚇') // ['😴', '😄', '😃', '⛔', '🎠', '🚓', '🚇']
不錯不錯,但是分割這些基本的 emoji,用剛才的方法一樣能實現,我們再來看看複雜的膚色 emoji 和組合 emoji 的情況。
splitEmoji('👨👨👧👧👦🏾') // ['👨👨👧👧', '👦🏾']
Nice! 這不就是我們想要的結果嗎?這個方式完美的解決了我們的需求,真不錯。
但是,這個東西都沒怎麼聽說過,他的兼容性如何,可以被用到生產環境嗎?通過到 Can I Use
上搜索和查閱可以發現,全球 89.7% 的瀏覽器(報包括移動端和 PC 端)都是兼容的,那除了確實需要更大覆蓋面的設備兼容之外,我們基本上都可以放心的使用 Intl.Segmenter
了。
開源社區的方案
其實,emoji 使用了這麼長時間,開源社區肯定早就遇到過了類似問題,這裡引用社區裡比較成熟的解決方案:graphemer
。
安裝依賴
npm i graphemer
基本用法如下:
// CommonJS
const Graphemer = require('graphemer').default
const splitter = new Graphemer()
const graphemes = splitter.splitGraphemes('😃⛔👨👨👧👧👦🏾')
console.log(graphemes) // ['😃', '⛔', '👨👨👧👧', '👦🏾']
// 或者 ESM
import Graphemer from 'graphemer'
const splitter = new Graphemer()
const graphemes = splitter.splitGraphemes('😃⛔👨👨👧👧👦🏾')
console.log(graphemes) // ['😃', '⛔', '👨👨👧👧', '👦🏾']
graphemer
的定位是 Unicode character splitter,也就是說,不僅僅是 emoji,其他有類似情況的 Unicode 碼也能被正確的分割,非常棒一個解決方案。
解決方案總結
- 利用
Intl.Segmenter
的特性進行分割。(詳情參考上文) - 使用開源社區比較成熟的方案:
graphemer
(推薦,詳情參考上文)
相關科普
每年的 7 月 17 日是世界表情圖標日(英語:world emoji day
)。這是一個非官方的紀念日,為了慶祝 emoji 的廣泛使用,最早從 2014 年開始舉辦。通常,在這一天會舉辦一些 emoji 活動,並發布一些 emoji。
澳大利亞昆士蘭州車輛管理部門推出新規:2019 年 3 月 1 日起,允許車主在車牌上增加一個 emoji 表情。
“😂”(中文:喜極而泣的表情,英語: Face with Tears of Joy)被《 牛津詞典》評選為 2015 年度詞彙。
相關網站
參考