絵文字一覧から好きな絵文字を選んでコピーできるUIを作っていたとき、最初はかなり単純に考えていました。
文字列を1文字ずつ分割して、ボタンとして表示すればいい。
const emojis = "😀😂❤️👍👨👩👧👦";
const chars = emojis.split("");
console.log(chars);
ところが、実際に表示してみると一部の絵文字が壊れました。
特に問題になったのが、
❤️
👍🏻
🇯🇵
👨👩👧👦
のような絵文字です。
今回は、JavaScriptで絵文字を分割すると壊れる問題について、
- 何が起きたのか
- なぜ
split("")ではダメなのか -
Array.from()だけでも完全には解決しない理由 -
Intl.Segmenterでどう直したか - 絵文字コピペUIでどう扱うようにしたか
をまとめます。
発生した問題
作っていたのは、絵文字を一覧表示して、タップするとクリップボードへコピーするシンプルなUIです。
イメージとしては、
😀 😂 🥰 ❤️ ✨ 🎉
のように並べて、
クリック
↓
コピー
↓
SNSやチャットへ貼り付け
というものです。
最初は次のように実装しました。
const text = "😀😂🥰❤️✨🎉";
const emojis = text.split("");
emojis.forEach((emoji) => {
console.log(emoji);
});
ASCII文字の感覚なら、
A
B
C
と同じように分割できそうに見えます。
でも絵文字ではそうなりません。
原因1:JavaScriptの文字列はUTF-16
JavaScriptの文字列はUTF-16で扱われます。
そのため、一部の絵文字は内部的に1つのコードユニットでは表現できません。
split("") は、人間が見たときの「1文字」ではなく、UTF-16のコードユニット単位で分割します。
その結果、
"😀".length
は、
2
になります。
つまり、
"😀".split("")
すると、表示上は1文字の絵文字でも内部では分割されてしまいます。
Array.from() に変えれば全部直ると思った
そこで次に試したのが Array.from() です。
const emojis = Array.from("😀😂🥰");
console.log(emojis);
これは split("") よりかなり改善します。
単純なサロゲートペアの絵文字なら、1つのコードポイントとして扱えます。
しかし、ここで別の問題が出ました。
❤️
👍🏻
🇯🇵
👨👩👧👦
です。
原因2:見た目が1文字でも、複数コードポイントの場合がある
ユーザーから見れば、
❤️
は1文字です。
でも実際には、単純な1コードポイントとは限りません。
同じように、
👍🏻
も、
👍
+
🏻
という複数要素の組み合わせです。
国旗も同じです。
🇯🇵
は、見た目では日本国旗1つですが、内部では複数のRegional Indicator Symbolから構成されています。
さらに分かりやすいのが家族の絵文字です。
👨👩👧👦
これは複数の人物絵文字をZWJ(Zero Width Joiner)でつないで、1つの絵文字として表示しています。
つまり、
コードポイント単位 = ユーザーが認識する1文字
ではありません。
必要だったのは「grapheme cluster」単位の分割
今回欲しかったのは、
Unicodeコードポイント単位ではなく、
画面上でユーザーが1文字として認識する単位
でした。
ここで使えるのが Intl.Segmenter です。
const segmenter = new Intl.Segmenter("ja", {
granularity: "grapheme",
});
const text = "😀❤️👍🏻🇯🇵👨👩👧👦";
const emojis = [
...segmenter.segment(text)
].map(item => item.segment);
console.log(emojis);
結果は、
😀
❤️
👍🏻
🇯🇵
👨👩👧👦
という形で扱えます。
今回欲しかったのはまさにこれでした。
関数にしておく
実装では、そのまま書かずに関数にしました。
function splitGraphemes(text) {
const segmenter = new Intl.Segmenter("ja", {
granularity: "grapheme",
});
return Array.from(
segmenter.segment(text),
item => item.segment
);
}
使用側は、
const emojis = splitGraphemes(
"😀❤️👍🏻🇯🇵👨👩👧👦"
);
console.log(emojis);
これでUI側は、
emojis.forEach((emoji) => {
renderEmojiButton(emoji);
});
のように扱えます。
絵文字コピペ機能では「表示」と「コピー」を分けた
もう一つ変更したのがデータの扱いです。
最初は表示されている文字列から値を再取得してコピーしていました。
でも、絵文字の場合は途中で文字列操作を挟まないほうが安全です。
そこでデータを、
{
"value": "👨👩👧👦",
"category": "人物",
"keywords": [
"家族",
"ファミリー"
]
}
のように持たせます。
HTMLでは、
<button
class="emoji-button"
data-value="👨👩👧👦"
>
👨👩👧👦
</button>
コピーするときは、その値をそのまま渡します。
document.addEventListener("click", async (event) => {
const button = event.target.closest(".emoji-button");
if (!button) return;
const emoji = button.dataset.value;
await navigator.clipboard.writeText(emoji);
});
これならコピー直前に、
split
substring
slice
などをかける必要がありません。
絵文字検索でも文字列を不用意に切らない
この問題は表示だけではなく、検索機能でも注意が必要でした。
たとえば、
絵文字一覧
かわいい絵文字
ハート絵文字
家族の絵文字
国旗絵文字
絵文字 コピペ
のような検索UIを作る場合、絵文字そのものと検索用テキストは分離しておいたほうが扱いやすいです。
{
"value": "❤️",
"name": "ハート",
"category": "ハート",
"keywords": [
"赤",
"恋愛",
"好き",
"かわいい"
]
}
検索するのは、
name
category
keywords
で、
コピーするのは、
value
です。
絵文字そのものを加工して検索インデックスを作る必要がありません。
実際の絵文字一覧UIでもこの分離が重要
たとえばユーザーが探すのは、
かわいい絵文字
ハートの絵文字
キラキラ絵文字
笑顔の絵文字
SNSで使う絵文字
プロフィール用の絵文字
のような用途です。
完成形を考えるときは、WowEmoji 絵文字 のように、絵文字一覧から笑顔、ハート、キラキラ、動物、食べ物などを探し、SNS投稿・プロフィール・コメント・チャット用の絵文字をそのままコピペする構造を見ると分かりやすいです。
この場合も、
検索用データ
と、
実際にコピーする絵文字文字列
を分離しておくと実装がシンプルになります。
顔文字はまた別の扱いにした
顔文字では、さらに考え方を変えました。
たとえば、
(*´꒳`*)
を Intl.Segmenter で1文字として扱いたいわけではありません。
これは複数の文字を組み合わせた1つのコンテンツです。
つまり、
{
"value": "(*´꒳`*)",
"category": "笑顔",
"keywords": [
"かわいい",
"嬉しい",
"笑顔"
]
}
という1レコードとして管理します。
コピーするときは、
navigator.clipboard.writeText(
item.value
);
だけです。
顔文字一覧 のように、かわいい顔文字、笑顔、泣く、猫、ありがとう、応援、挨拶などを一覧から探してコピペするUIでは、顔文字全体を1つの値として扱うほうが自然です。
絵文字と顔文字は見た目が近くても、
絵文字
→ Unicode graphemeを意識
顔文字
→ 完成した文字列を1アイテムとして管理
と分けたほうが実装しやすくなりました。
途中でやった失敗
今回の実装では、最終的に次の3段階を通りました。
1. split("")
text.split("");
これはNG。
UTF-16のコードユニット単位で分割されるため、絵文字が壊れます。
2. Array.from()
Array.from(text);
split("") より改善します。
ただし、ZWJシーケンスや国旗、Variation Selector、Emoji Modifierなどを含む「見た目で1文字」の単位を完全には扱えません。
3. Intl.Segmenter
new Intl.Segmenter("ja", {
granularity: "grapheme"
});
今回の目的にはこれが合っていました。
最終的な構成
最終的には、
絵文字データ
↓
1アイテム単位で保持
↓
検索はname/category/keywords
↓
表示時はvalueをそのまま使用
↓
必要な文字列分割だけIntl.Segmenter
↓
Clipboard APIでvalueをそのままコピー
という構成にしました。
顔文字は、
顔文字文字列
↓
1レコードとして保持
↓
感情・シーンで検索
↓
文字列全体をコピー
です。
結果
修正後は、
❤️
👍🏻
🇯🇵
👨👩👧👦
のような複数コードポイントからなる絵文字でも、途中で分解されずに扱えるようになりました。
同時に、
- 絵文字一覧表示
- 絵文字検索
- 絵文字コピペ
- SNSプロフィール用の絵文字選択
- かわいい絵文字の組み合わせ
- 顔文字一覧
- 顔文字コピペ
も、同じデータ構造の中で整理しやすくなりました。
まとめ
今回一番ハマったのは、
「画面上の1文字」と「JavaScript上の1文字」は同じではない
という点でした。
単純な文字なら、
split("")
で問題ありません。
でも絵文字には、
- サロゲートペア
- Variation Selector
- Emoji Modifier
- Regional Indicator
- ZWJ sequence
などがあります。
絵文字をユーザーが認識する単位で扱いたいなら、
Intl.Segmenter
で grapheme 単位に処理する。
そして絵文字コピペ機能では、可能な限り完成した絵文字文字列を1アイテムとして保持し、その値をそのままClipboard APIへ渡す。
この形にしてから、絵文字一覧・検索・コピー周りの処理がかなり安定しました。