特定のOSでのみ絵文字をTwemojiで表示するときのコツ
Apple以外のOSではTwemojiを使いたい
自分はAppleの絵文字が好きです。しかし、Appleの絵文字はオープンソースではなく、絵文字を画像として保存して他のプラットフォームで表示することは好ましくありません。
次に自分が好きなのは、オープンソースのTwemojiです。
Twemojiであればどのプラットフォームでも自由に表示できますし、絵文字をTwemojiの画像に変換するためのパーサも用意されています。
そこで、自分はAppleのOS(macOS/iOS/iPadOS)でのみネイティブの絵文字を表示して、それ以外の環境ではTwemoji(画像)を表示するという方法を取ることがよくあります。
ReactでクライアントサイドでOSを判定し、必要に応じてTwemojiを表示してみる
たとえばReactでは、以下のように書くことで簡単に環境に応じた出し分けができます。コンポーネントのマウント時にnavigator.userAgent
の値をもとにmacOS、iOS、iPadOSのいずれかに当てはまるかをチェックし、当てはまらなければTwemojiを表示するイメージです。
import twemoji from "twemoji";
import { memo, useEffect, useState } from "react";
export const EmojiOrTwemoji = memo(({ emoji }) => {
const [shouldUseTwemoji, setShouldUseTwemoji] = useState(false);
useEffect(() => {
if(isAppleOS() === false) setShouldUseTwemoji(true);
}, []);
return (
<span
dangerouslySetInnerHTML={{
__html: shouldUseTwemoji ? twemoji.parse(emoji) : emoji
}}
/>
);
});
function isAppleOS() {
return /(macintosh|macintel|macppc|mac68k|macos|iphone|ipad)/i.test(
window.navigator.userAgent
);
}
ただ、複数箇所で何度もこのコンポーネントを呼び出す場合、コンポーネントごとにUser-Agentの判定とステートの保持が行われるため、効率の良いやり方とは言えません。
ちなみに「そんな周りくどい書き方をしなくても、次のように書けば良いのでは?」と思う方もいるかもしれません。
export const EmojiOrTwemoji = memo(({ emoji }) => {
return (
<span
dangerouslySetInnerHTML={{
__html: isAppleOS() ? emoji : twemoji.parse(emoji)
}}
/>
);
});
これだとNext.jsやGatsbyのようなフレームワークでSSRをする場合にエラー(サーバーサイドでwindowが未定義であることによるエラー[1])や警告(サーバーとクライアントそれぞれで生成されたDOMが一致しないことによる警告)が発生します。
絵文字がTwemoji画像に置き換わる瞬間のちらつきを回避する
クライアントサイドでTwemojiへの変換を行う場合、変換が行われてDOMに反映されるまでの間、一瞬もとの絵文字が見えてしまうことがあります。
<span class="emoji">😸</span>
↓ 変換
<img class="emoji" alt="😸" src="https://twemoji.maxcdn.com/v/latest/svg/1f638.svg">
このちらつきはあまり体験の良いものではありません。本記事では、このちらつきを回避する方法を考えてみたいと思います。
案1: サーバーサイドでOSを判定する
サーバーサイドでリクエストヘッダのUser-AgentをもとにOSを判定できればこのチラツキを防ぐことはできます。ただ、ページのHTML自体をCDNにキャッシュするようなケースではサーバーサイドでの判定は困難です。
また、フレームワークによっては、User-Agentを元にした「Twemojiで表示するべきか」というプロパティを子コンポーネントにバケツリレーで渡していく必要があり、なかなか辛かったりします。
(こういうシーンでReact Server Componentは便利なのかもしれません)
案2: opacityなどのCSSでちらつきを軽減する
絵文字に切り替わる瞬間のチラツキが気になるのであれば、User-Agentの判定が完了するまでは絵文字を隠しておくというような手もあります。
export const EmojiOrTwemoji = memo(({ emoji }) => {
const [shouldUseTwemoji, setShouldUseTwemoji] = useState(undefined);
useEffect(() => {
setShouldUseTwemoji(!isAppleOS());
}, []);
return (
<span
dangerouslySetInnerHTML={{
__html: shouldUseTwemoji ? twemoji.parse(emoji) : emoji
}}
+ style={{
+ opacity: shouldUseTwemoji === undefined ? 0 : 1
+ }}
/>
);
});
これで判定が終わるまでは該当の部分が透明になり、Twemojiの画像に置き換わる瞬間の不自然なちらつきは回避できます。ただし、useEffect
からステートが更新されるまでは何も表示されなくなるため、ベストな方法とは言えないかもしれません。
案3: ネイティブの絵文字も画像として読み込む(動的に画像を返す)
これはまだ試していないのですが、逆にネイティブの絵文字を画像として<img>
から読み込む手も考えられます。OS(User-Agent)の判定と画像の生成をCloudflare WorkersやCloudfront FunctionsなどのFaaSに任せるようなイメージです(エッジで動くものがパフォーマンス的にベスト)。
具体的にはFaaSで以下の処理を行うようにします。
- リクエストヘッダのUser-Agentの値をもとに「Appleの絵文字が表示できる環境からのリクエストか」を判定
- YES → ネイティブの絵文字をsvgの中に入れて返す
- NO → Twemojiの画像のURLへリダイレクトする
ネイティブの絵文字を画像として表示する
たとえば以下のような書き方をすることで絵文字をsvgの画像として表示できます(サイズの調整は必要そう)。
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
<text x="50%" y="50%" style="dominant-baseline:central;text-anchor:middle;font-size:90px;">
😸
</text>
</svg>
参考: 絵文字をファビコンとして表示する
あとは、絵文字を表示したい箇所から<img src="functionのURL" />
を読み込むだけで、環境に応じてネイティブ絵文字 / Twemojiの表示が切り替わるようになります。
この方法はtwemoji
のライブラリをクライアントのバンドルに含める必要がなくなり、ブラウザの負荷的にはベストな気がしますが、<img />
からのリクエストごとに処理が実行されるため[2]、絵文字をガンガン読み込んでいる場合にはコストが高くつく可能性があります。
案4: bodyタグに「Twemojiを使うべきかどうか」を示す属性を持たせ、あとはCSSで表示を切り替える
絵文字をたくさん表示するようなケースでは、ネイティブの絵文字とTwemojiの画像要素の両方をHTML上には出力しておき、CSSで表示・非表示を切り替える方法も良いかもしれません。
事前準備として<body>
タグに「Twemojiで表示するべきか」という情報を持たせるようにします。たとえばNext.jsの場合、_document.tsxに処理を書きます。
<body>
+ <script
+ dangerouslySetInnerHTML={{
+ __html: `
+ const shouldUseTwemoji = !/(macintosh|macintel|macppc|mac68k|macos|iphone|ipad)/i.test(window.navigator.userAgent);
+ if(shouldUseTwemoji) document.body.setAttribute("data-use-twemoji", "true");
+ `,
+ }}
+ />
<Main />
<NextScript />
</body>
↑ この例では<script>
の中でnavigator.userAgent
の値をもとにAppleのOSでなければ<body>
にdata-use-twemoji="true"
という属性をセットする処理を書いています。
ポイントは以下です。
-
<body>
の開始タグの直後に<script>
を書くことでdocument.body
を参照できるようにします。 -
<script>
タグにはdefer
やasync
属性はつけず、あえてレンダリングをブロックします(この程度の処理であれば無視できるレベルだと思います)。これによりCSSが適用される部分でのちらつきを防ぐことができます。window.onload
やuseEffect
を使っていない理由も同じです。 -
<body>
にclass
をセットする形でもOKだと思います。今回は他の何らかの処理でclass
の値が書き換えられる可能性も考えてカスタム属性(data-use-twemoji
)を選びました。
絵文字のコンポーネントでは以下のようにネイティブの絵文字とTwemojiの両方が出力されるようにします。
export const EmojiOrTwemoji = memo(({ emoji }) => {
return (
<>
{/* Twemoji */}
<span className="twemoji">
<span
className="twemoji-inner"
style={{
backgroundImage: `url(${getTwemojImgSrc(emoji)})`,
}}
/>
</span>
{/* ネイティブ絵文字 */}
<span className="native-emoji">
{text}
</span>
</>
);
});
// 絵文字からTwemojiの画像のURLを取得する関数
function getTwemojImgSrc(emoji: string) {
// ここでは省略しますが twemoji-parser というパッケージを使うと簡単です
// https://github.com/twitter/twemoji-parser
}
Twemojiの画像をCSSのbackground-image
プロパティで読み込み、なおかつ<span>
でネストさせているのは、Twemojiの画像が無駄にリクエストされてしまうことを防ぐためです。
<img>
に対してdisplay: none
を設定する形では見た目は非表示にはなりますが、画像に対してのリクエストは飛んでしまいます。
しかし、主要ブラウザではbackground-image
で指定されているURLは、親要素がdisplay: none
で非表示になっていればリクエストは行われません。
CSSは以下のようにします。
.twemoji {
display: none;
}
.twemoji-inner {
display: inline-block;
height: 1em;
width: 1em;
}
/* bodyに data-use-twemoji='true' がセットされているときはTwemojiを表示 */
body[data-use-twemoji='true']) .twemoji {
display: inline-block;
}
/* bodyに data-use-twemoji='true' がセットされているときはネイティブ絵文字を非表示 */
body[data-use-twemoji='true']) .native-emoji {
display: none;
}
これで以下を満たす絵文字コンポーネントが実現しました。
- Apple以外のプラットフォームではTwemojiを表示する
- チラツキが発生しない(ただしTwemojiの画像の読み込みが遅れるとちらつく)
- コンポーネントごとに「Twemojiを表示すべきか」というステートを持つ必要がない
- 無駄な画像のリクエストが発生しない
他に良い方法が思いついたら追記します。
Discussion