Shopifyが提供する多言語モジュールについて調べてみた

この記事は、Shopify開発を盛り上げる(Liquid, React, Node.js, Graph QL) Advent Calendar 2020の投稿です。

ShopifyにおけるReactの多言語化

Reactで多言語化と聞くと、react-intlを思い浮かべる方も多いと思います。が、Shopifyでは自前の多言語化モジュールを利用しています。

理由について、react-i18nライブラリのREADMEに記載がありますが、「コンポーネント毎に翻訳を提供したい」「非同期に翻訳ファイルを読み込みたい」など、Shopifyに組み込むにあたって必要だった要件とreact-intlに向いている要件がマッチしなかったことが伺えます。

react-i18nを使った多言語化

ライブラリは、yarn add @shopify/react-i18nでインストールできます。

Providerの配置

また、Context APIを利用していますので、親要素でI18nContext.Providerを配置する必要があります。

import {useI18n,I18nContext, I18nManager} from '@shopify/react-i18n';

export function Index() {
  const i18nManager = new I18nManager({
    locale: 'en'
  })
  return (
    <I18nContext.Provider value={i18nManager}>
      <ChildElements />
    </I18nContext.Provider>
)

このサンプルでは、ChildElementsまたはその子孫要素でreact-i18nのAPIが利用できます。

useI18nによるコンポーネント毎の多言語化

react-intlでは1つの辞書を共有する形で多言語化を実装します。一方でShopifyのreact-i18nは、コンポーネント単位での多言語化します。

const en = {
  "NotFound": {
    "heading": "Page not found",
    "action": "Back"
  }
}
const ja = {
  "NotFound": {
    "heading": "ページが見つかりませんでした",
    "action": "戻る"
  }
}

function NotFound() {
  const target = "NotFound.heading"
  const [i18n] = useI18n({
    id: 'NotFoundComponent',
    fallback: en,
    translations: () => en,
  });
  if (!i18n.translationKeyExists(target)) {
    return <div>None</div>;
  }
  return (
      <div>{i18n.translate(target)}</div>
  )
}

react-i18nの特徴として、「翻訳するキーの有無をチェックできる」という点もあげることができます。上のサンプルでは、translationKeyExistsを使って翻訳の有無をチェックしてから翻訳処理を実行しています。react-intlの場合はdefaultMessageを設定する形でしたので、useMemoなどを組み合わせると似たようなことが可能と思われます。

const useTranslate = (target: string, fallbackMessage: string) => {
  const [i18n] = useI18n({
    id: target,
    translations: (locale: string) => {
      if (/ja/.test(locale)) return ja
      return en;
    },
  });
  const translatedText = useMemo(() => {
    if (!i18n.translationKeyExists(target)) {
      return fallbackMessage
    }
    return i18n.translate(target)
  }, [i18n, target, fallbackMessage])
  return translatedText
}

動的な翻訳

react-i18nの場合、翻訳のキーを動的にすることも可能です。以下の3つは全て同じ動きをします。もちろん変数も使えますので、細かく翻訳を制御できます。

i18n.translate("Polaris.Common.undo")

i18n.translate('undo', {
        scope: 'Polaris.Common'
})

i18n.translate('undo', {
        scope: ['Polaris', 'Common']
})

HTMLなどの挿入

翻訳辞書側に{プレースホルダー}を用意すると、HTMLタグなども挿入できます。Linkが使えるのは結構ありがたいです。

const ja = {
  "NotFound": {
    "heading": "ページが見つかりませんでした",
    "action": "{link} に戻る",

...
      <p>{
        i18n.translate('NotFound.action', {
          link: <Link>Home</Link>
        })  
      }</p>

フォーマット系は通貨などもサポート

EC向けSaaSが作ったライブラリだけあって、通貨週初めの取得といった通販で欲しそうな機能もサポートされています。

SSR対応

Next.jsなどのSSRを行うアプリケーションや、GatsbyのようにSSGを行うサイトの場合は@shopify/react-i18n-universal-providerを使います。

import {I18nUniversalProvider} from '@shopify/react-i18n-universal-provider';

export function Index() {
  return (
    <I18nUniversalProvider locale="ja">
      {/* App contents */}
    </I18nUniversalProvider>

とはいえProviderを変えるだけで動きますので、知っていれば困ることもあまりなさそうです。

配布を前提とするならreact-18n、1つのアプリとして管理するならreact-intl

個人的な感想としては、コンポーネント単位で配布する想定のライブラリにはShopifyのreact-i18nがむいていると思います。配布想定の場合、コンポーネントレベルで翻訳を管理できる方が、そのライブラリを利用する側の辞書を更新せずとも新しい翻訳文を提供することができます。

Material UIのようにThemeProviderコンポーネントを提供するように実装し、その中に言語切り替え系のAPI(フック)とI18nContext.Providerコンポーネントを配置すると良いでしょう。

一方で1つのリポジトリの中にある、1つのアプリケーションを運用するという場合には、翻訳文章がコンポーネントレベルで分散してしまうreact-i18nは少し扱いにくいのではないかと思います。

ですので、「複数のサービスを運用し、それらで共通の自作ライブラリを利用している」というケースに該当したときには、このShopifyのreact-i18nを検討しても良さそうです。

余談: Polarisはこれ使ってない模様

と、いろいろ書いたのですが、Shopifyアプリ開発でお世話になるPolaris本体は@shopify/react-i18nを使っていなかったりします。

ライブラリの中に自前でi18n系のファイルが配置されていますので、Polarisを使って開発する際にreact-i18nのドキュメントを見てしまうとちょっと混乱するかもしれません。実際しました。

とはいえ互換性がないわけでは無い様子で、react-i18nでPolarisの辞書をimportして使うことは可能そうです。

import enTranslations from '@shopify/polaris/locales/en.json';
import jaTranslations from '@shopify/polaris/locales/ja.json';

const App = () => {
  const target = "Polaris.Common.undo"

  const [i18n] = useI18n({
    id: 'PolarisComponent',
    translations: (locale: string) => {
      if (/ja/.test(locale)) return jaTranslations
      return enTranslations;
    },
  });

  const translatedText = useMemo(() => {
    if (!i18n.translationKeyExists(target)) {
      return 'Fallback'
    }
    return i18n.translate(target)
  }, [i18n, target])

Comment