🌊

特定のOSでのみ絵文字をTwemojiで表示するときのコツ

2022/01/28に公開

Apple以外のOSではTwemojiを使いたい

自分はAppleの絵文字が好きです。しかし、Appleの絵文字はオープンソースではなく、絵文字を画像として保存して他のプラットフォームで表示することは好ましくありません。

次に自分が好きなのは、オープンソースのTwemojiです。

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に処理を書きます。

_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>タグにはdeferasync属性はつけず、あえてレンダリングをブロックします(この程度の処理であれば無視できるレベルだと思います)。これによりCSSが適用される部分でのちらつきを防ぐことができます。window.onloaduseEffectを使っていない理由も同じです。
  • <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で非表示になっていればリクエストは行われません。

https://qiita.com/shouchida/items/22c4e22f65cb1a66b037

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を表示すべきか」というステートを持つ必要がない
  • 無駄な画像のリクエストが発生しない

他に良い方法が思いついたら追記します。

脚注
  1. process.browser等によりブラウザでのみチェックがする形を取ればこのエラーは解消できますが、今度はサーバーとクライアントでDOMが一致しないことによる警告が発生します。 ↩︎

  2. User-Agentの値ごとにCDNにキャッシュすることも可能だが、User-Agent × 絵文字のパターンはかなり膨大なのでさほど効果がないかもしれない ↩︎

Discussion