react-i18next を使った i18n 対応の始め方

React における i18n 対応ライブラリの候補

人気という点では 2 つ目の react-intl が最もありそうですが、今回は react-i18next を選びました。

理由は t('resource name') で公式のサンプルも書かれていて、 Rails での国際化の時の気持ちを思い出したぐらいの感じです。

最初は react-intl は hooks 対応が無いのかと思ったのですが、README や document の浅い所になかっただけで、API のページを見るとちゃんとありました。

github.com

今回のコード

GitHub - dany1468/react-i18next_sample

上記に create-react-app から作成したサンプルを置いてあります。

また、以下は今回の対応内容に登場するファイル群です。

.
├── package.json
├── public
│   └── locales
│       └── ja
│           └── app.json
├── src
│   ├── App.i18n.test.tsx
│   ├── App.test.tsx
│   ├── App.tsx
│   ├── Fakei18n.ts
│   ├── i18n.ts
│   ├── index.tsx
│   └── setupTests.ts
├── tsconfig.json
└── yarn.lock

今回の対応内容

  • リソースファイルは XHR を使って、実行時に public から取得する
  • リソースファイルの JSON はネストさせることで . の separator でリソースをコードから指定できる
  • 上記に対応したテストを書ける

リソースファイルは XHR を使って、実行時に public から取得する

リソースファイルをどう扱うかはいくつか方法があり、backend plugin という形で提供されています。(もちろんコードに直接記述することもできます)

Plugins and Utils - i18next documentation

今回は xhr backend を利用します。

GitHub - i18next/i18next-xhr-backend: backend layer for i18next using browsers xhr

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import XHR from 'i18next-xhr-backend';

i18n.use(initReactI18next)
    .use(XHR)
    .init({
        lng: 'ja',
        defaultNS: 'app',
        backend: {
            loadPath: '/locales/{{lng}}/{{ns}}.json'
        }
    });

export default i18n;

backend.loadPath に取得するパスを設定します。上記であれば、create-react-app の場合には public/locales 以下が対応します。

指定できるオプションに関しては以下を参照してください。

namespace は別リソースファイルで

上記の通り、言語だけでなく、namespace でもリソースファイルを分離しています。
こうすることで、画面別にリソースファイルを分離させることもできます。

ロード中はどうなる?

XHR での取得なので、場合によって長い待ちが発生してしまいます。
上述の React 用の設定オプションの中に useSuspense があるのですが、こちらがデフォルトで true になっているので、Suspense を追加することで、ロード時の代替表示を指定できます。

ReactDOM.render(
  <React.StrictMode>
      <Suspense fallback={<h1>Loading...</h1>}>
          <App />
      </Suspense>
  </React.StrictMode>,
  document.getElementById('root')
);

リソースファイルの JSON はネストさせることで . の separator でリソースをコードから指定できる

ここに関しては、特に何か追加したという訳ではありません。よって、以下で keySeparator の設定を追加していますが、 . はデフォルトなので本来無くても問題ありません。

i18n.use(initReactI18next)
    .use(XHR)
    .init({
        lng: 'ja',
        defaultNS: 'app',
+       keySeparator: '.',
        backend: {
            loadPath: '/locales/{{lng}}/{{ns}}.json'
        }
    });

この機能があることで、ネストした JSON に対して . でアクセスできるようになります。

Essentials - i18next documentation

上記に対応したテストを書ける

テストに関しては以下の 2 つのパターンを想定します。

  • 実際に変換された後の文字列には興味がなく、キーだけがテストできればいい
    • 多くの場合はこれで良いのだと思います
  • Interpolation を利用しているなど、実際にどう埋め込まれるかも確認したい

キーのみテストする場合

Testing - react-i18next documentation

上記に HOC の場合の jest.mock のサンプルがありますが、今回は useTranslation hook を使った場合です。

create-react-app であれば、最初から setupJest.js があるので、以下を書き足します。

jest.mock('react-i18next', () => ({
    useTranslation: () => ({ t: key => key })
}));

これでテスト対象の Component を render(<App />) した場合には、キー名が埋め込まれた状態になります。

どう文字列として展開されるかもテストする場合

こちらも上述のテストのドキュメントにテスト用の設定をどうするかというのが書かれています。今回は以下のように Fake の設定を作成しました。

import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

i18n.use(initReactI18next).init({
    resources: {},
    ns: ['app'],
    lng: 'ja',
    react: {
        useSuspense: false
    },
    debug: false // 調査したい時は true
});

export default i18n;

ポイントは resources: {} と空を指定している部分と、 react.useSuspense: false です。

1 点目は、テスト用のリソースは別途テストの setup で入れるのですが、一先ず枠が必要になるものです。
2 点目は、useSuspense を切っておかないと、 render(<Suspense ... のように、 テスト時にも Suspense で囲わなければいけなくなるためです。

これで、実際のテスト時には、以下のように Fake のリソースを設定して、その通りに render されているかを確認できます。

import i18n from './Fakei18n';

jest.unmock('react-i18next');

const testingResources = {
  hello: 'こんばんは {{target}}'
};

i18n.addResourceBundle('ja', 'app', testingResources);

// 略
expect(helloElement.textContent).toBe('こんばんは React i18n');

最後に

まだ、production で利用する際の設定や、最適化に関しては調べきれておらず、引き続き見てみたいと思っています。