at backyard

Color my life with the chaos of trouble.

Reactアプリでのi18n対応(国際化/多言語対応)にi18nextを使ってみる

これはi18n対応のために i18nextreact-i18next を使ってみた備忘録となる。

目次

Reactアプリでのi18n対応(国際化/多言語対応)にi18nextを使ってみる

仕事で多言語対応をすることになり、i18nextを使うことにした。

Introduction - i18next documentation

Introduction - react-i18next documentation

react-intlとi18next

ちなみに react i18n 対応 で検索すると、なんと驚いたことに過去にQiitaに書いた自分の記事が一番目にヒットした(2019年9月時点)。

qiita.com

2019年9月時点で 16イイね がついているし、私にしては大健闘な状況である。
QiitaのSEO効果の偉大さを改めて思い知った。

こちらの記事では react-intl を使用しており、一応の使い方は理解したつもりだが、なぜ今回 i18next を使うことにしたかというと、うちの会社の別のサービスで既に導入実績があるからだ。
私が勤めている会社は、基本的に私以外は皆 凄腕エンジニアばかりで(もちろん、これはお世辞ではない)、 多言語対応周りの実装のレビューをしたりしながら、ふむふむこうやって使うのか、なるほどなーといつも勉強させてもらっていた。
そうこうしているうちに自分がメインで担当しているサービスでも一部多言語対応をしていくことになったため、既に皆が触っている i18next のほうがメンテナンスもしやすいし、社内に知見もたまってきているというわけで、こちらを導入することにしたというわけである。

というわけで、これは i18next を使ってみた、的な備忘録となる。

実際の仕事で使うやり方とは多少異なってくると思うが、自分の理解を深めるために、一通り触ってみたメモをこちらに残しておく。

サンプルアプリの雛形生成

create-react-appを使ってサンプルアプリの雛形を作成していく

npx create-react-app react-i18next-sample
cd react-i18next-sample
yarn start

create-react-appでブラウザが勝手に立ち上がらないようにする

ちなみに私はcreate-react-appyarn startしたときに勝手にブラウザが立ち上がる挙動が許せない質なので、下記のようにpackage.jsonを書き換えている。

diff --git a/package.json b/package.json
index 52a2f12..0730227 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,7 @@
     "react-scripts": "3.1.2"
   },
   "scripts": {
-    "start": "react-scripts start",
+    "start": "BROWSER=none react-scripts start",
     "build": "react-scripts build",
     "test": "react-scripts test",
     "eject": "react-scripts eject"

これらの設定は下記に記述がある。

Advanced Configuration | Create React App

react-i18nextのセットアップ

ここからreacti18nextを使っていくための設定を行っていく。 これらの記述はだいたい下記のドキュメントを読んでいけば、わかるようになっている。

Getting started - i18next documentation

Getting started - react-i18next documentation

実際の実装については下記が参考になる

react-i18next/example/react at master · i18next/react-i18next · GitHub

一つだけ注意点。
自分がここから書いていく記述では、 SSR は考慮しないものとしていることを先に書いておく

yarn add i18next react-i18next 

i18n.js というファイルを作成する

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

const resources = {
  en: {
    translation: {
      'Learn React': 'Learn React'
    }
  },
  ja: {
    translation: {
      'Learn React': 'Reactを学ぶ'
    }
  }
};

i18n.use(initReactI18next).init({
  resources,
  fallbackLng: 'en',
  debug: true,

  interpolation: {
    escapeValue: false
  }
});

export default i18n;

HOCで使う場合はwithTranslationを使う

HOCを使う場合は withTranslation を用い、下記のようにしてi18nextの機能を使う

src/App.jsを下記のように書き換える

import React from 'react';
import logo from './logo.svg';
import './App.css';
import './i18n';
import { withTranslation } from 'react-i18next';

function App({ t }) {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          {t('Learn React')}
        </a>
      </header>
    </div>
  );
}

export default withTranslation()(App);

i18n.js内のfallbackLng: 'en'fallbackLng: 'ja'に書き換えることで言語が変わるのが確認できる。

hookで使う場合はuseTranslationを使う

hookで使う場合は useTranslation を使う。
上のサンプルと見比べてみると、使い方の違いがより分かるかと思う。

import React from 'react';
import logo from './logo.svg';
import './App.css';
import './i18n';
import { useTranslation } from 'react-i18next';

function App() {
  const { t, i18n } = useTranslation();
  //  下記のような書き方でもOK
  //  const [ t, i18n ] = useTranslation();

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          Edit <code>src/App.js</code> and save to reload.
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          {t('Learn React')}
        </a>
      </header>
    </div>
  );
}

export default App;

namespacesについて

ちなみにここまでしれっと translation という namespacesを使ってきたが、ここについては下記のドキュメントが参考になるかと思う。

Namespaces - i18next documentation

i18nextのnamespaceについてだが、小規模なプロジェクトであればすべてのテキストを一つのファイルに纏めて利用する形で事足りると思うが、大規模なサービスを運用したい場合はそうも言ってられなくなる。
そこでnamespaceを区切って、必要なテキストだけを読み込んでいこうということになる。

当然全てのテキストファイルが一度に読まれなくなるので読み込み時間の短縮などにもつながる。

ただ、namespaceを分けることによって、テキストの管理コストは上がるため、そこも考えてプロジェクト内で慎重に検討していったほうが良さそうというのが個人的な考え。

ドメインごとのテキストと共通で利用するテキストなど、どういう考え方で分けていくか?などを考えていく必要がある。

ちなみに i18next の公式サイトでは、下記のような分け方で分けているという書かれている。参考にしてみるのも良いかも。

common.json -> あらゆる場所で再利用されるもの、例えばボタンのラベル「save」や「cancel」など。
validation.json -> すべての検証テキスト
glossary.json -> テキストの中で一貫して使われて欲しい単語

https://www.i18next.com/principles/namespaces#semantic-reasons

translation というnamespaceの挙動について

translation はdefaultで利用されるnamespaceとなる。

namespaceが必要がない場合は translation というnamespaceがルートに位置する形となる。

例えば、このtranslation というnamespaceがない場合、i18next自体が機能しなくなる。

例として、下記のような構成の場合、動かない。 (日本語の言語ファイルは割愛)

{
  "Learn React": "Learn React",
  "Save to Reload text": "Edit <1>{{filename}}</1> and save to reload."
}
import en from './en/translation';
import ja from './ja/translation';

export default {
  en,
  ja
};
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import resources from './locales/resources';

i18n.use(initReactI18next).init({
  resources,
  fallbackLng: 'en',
  debug: true,

  interpolation: {
    escapeValue: false
  }
});

export default i18n;

この状態では動かない。

下記のように translation をルートに持ってくることで動くことが確認できる。

{
  "translation": {
    "Learn React": "Learn React",
    "Save to Reload text": "Edit <1>{{filename}}</1> and save to reload."
  }
}

実際に動かすときは下記のように呼び出すことになる。

// 呼び出すとき
t('Learn React')

この挙動からも translation がルートには必要であることが分かる。

次はこの translation を別の namespace1 というnamespaceに変えてみる。

すると呼び出し方が変わる。

{
  "namespace1": {
    "Learn React": "Learn React",
    "Save to Reload text": "Edit <1>{{filename}}</1> and save to reload."
  }
}

呼び出すときはこのnamespace1を込みで呼び出す必要がある。
(勿論この例はあくまでresourceファイルをそのままi18next側に読み込ませているからなわけで、読み込ませ方によって異なる呼び出し方になると思う)

// 呼び出すとき頭にnamespaceを指定する
t('namespace1:Learn React')

さらには上の状態にルートに translation を持ってくる

{
  "translation": {
    "namespace1": {
      "Learn React": "Learn React",
      "Save to Reload text": "Edit <1>{{filename}}</1> and save to reload."
    }
  }
}

この場合呼び出し方が下記のように変わってくる。

t('namespace1.Learn React')

以上のような挙動だが、たぶんここらへんで実装しているような気がするので、個人的なメモとして残しておく。

https://github.com/i18next/i18next/blob/master/src/ResourceStore.js#L36-L50

またこのような挙動については、ドキュメントの下記のページにも記載があるので、そちらを熟読したほうが本質的な理解に繋がりそう。

https://www.i18next.com/translation-function/essentials#accessing-keys-in-different-namespaces

Namespaces - i18next documentation

translationを言語のテキストファイルに含めたくない場合

このあとのサンプルにも登場させる書き方でいけば、言語テキストを格納するjsonファイルに translation を含める必要はなくなる。
(こちらについては 翻訳した言語の管理方法 という項目に書いている)

かんたんなサンプルを下記に記載する。

{
  "namespace1": {
    "Learn React": "Learn React",
    "Save to Reload text": "Edit <1>{{filename}}</1> and save to reload."
  }
}

下記のように言語ファイルは一つに予めまとめておくようにして、その後まとめたこれらのresourceをi18nextの初期化処理時に食わせる流れとする。

import enTranslation from './en/translation';
import jaTranslation from './ja/translation';

export default {
  en: {
    translation: enTranslation
  },
  ja: {
    translation: jaTranslation
  }
};
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import resources from './locales/resources';

i18n.use(initReactI18next).init({
  resources,
  fallbackLng: 'en',
  debug: true,

  interpolation: {
    escapeValue: false
  }
});

export default i18n;

という形でセットすることで、下記のように呼び出すことが可能となる。

t('namespace1.Learn React')

言語を切り替える機能を実装する

i18n.changeLanguage という関数が用意されているので、こちらで切り替えを実装できる。 (雑なサンプルで恐縮である)

import React from 'react';
import logo from './logo.svg';
import './App.css';
import './i18n';
import { withTranslation, Trans } from 'react-i18next';

function changeLanguage(i18n, lang) {
  i18n.changeLanguage(lang);
}

function App({ t, i18n }) {
  const filename = 'src/App.js';

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          <Trans i18nKey="Save to Reload text">
            Edit <code>{{ filename }}</code> and save to reload.
          </Trans>
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          {t('Learn React')}
        </a>
        <div>
          <p>Change Language</p>
          <button onClick={() => changeLanguage(i18n, 'en')}>en</button>
          <button onClick={() => changeLanguage(i18n, 'ja')}>ja</button>
        </div>
      </header>
    </div>
  );
}

export default withTranslation()(App);

f:id:shinshin86:20190926084817g:plain

翻訳した言語の管理方法

ここでは localesというディレクトリを作り、その配下に各種言語ごとに翻訳したリソースを格納、 これらはresources.jsというものでまとめて export することで、アプリ側に読み込んでいくような設定とする。

このやり方の場合、言語の変更を行う場合はソースコード自体を変更するしかないので、実際の運用ではもう少し考える必要がありそうだ。

本来であれば、翻訳した言語は別のSaaS的なもので管理していき、ソースコードを変更せずとも言語ごとのテキストを変えられるようにしたほうが良いと思われる。
例えばi18nextでは locize という多言語管理用のSaaSが親和性が高いようだ。が、結構エンジニアフレンドリーなサービスのようにも感じるので、多言語リソース管理をエンジニアではない人間が担当する場合は、管理を少しばかり考える必要がある。

localization & translation management platform | locize

例えば、先日ブログにも書いたが、contentfulなどのHeadless CMSなどで管理していくのも一つだと思う。

Headless CMS の Contentful からデータを取得して、Next.jsのページで表示させるメモ - at backyard

少し話がそれてしまったが、多言語リソースの管理について書いていく。

先程も書いたが、localesというディレクトリを作成し、下記のように言語を管理していく

src/locales
├── en
 │   └── translation.json
├── ja
 │   └── translation.json
└── resources.js

ここのtranslation.json というファイルには、下記のようにそれぞれの言語でのテキストが入ることになる。

en/translation.json

{
  "Learn React": "Learn React"
}

ja/translation.json

{
  "Learn React": "Reactを学ぶ"
}

これらの言語ファイルをresources.jsでまとめてexportする形だ

import enTranslation from './en/translation';
import jaTranslation from './ja/translation';

export default {
  en: {
    translation: enTranslation
  },
  ja: {
    translation: jaTranslation
  }
};

そして、i18n.jsでは下記のようにresouces.jsを読み込む形に修正する

diff --git a/src/i18n.js b/src/i18n.js
index 5673005..bed5f11 100644
--- a/src/i18n.js
+++ b/src/i18n.js
@@ -1,18 +1,6 @@
 import i18n from 'i18next';
 import { initReactI18next } from 'react-i18next';
-
-const resources = {
-  en: {
-    translation: {
-      'Learn React': 'Learn React'
-    }
-  },
-  ja: {
-    translation: {
-      'Learn React': 'Reactを学ぶ'
-    }
-  }
-};
+import resources from './locales/resources';

 i18n.use(initReactI18next).init({
   resources,

これでひとまずのところ、言語ファイルを分離して管理することができるようになった。
ここについては正直もう少し考えてみる必要がありそうだが、とりあえずの ことはじめ として一旦このような形にした。

Transを用いて、HTMLタグが使われている箇所を多言語化する

下記のようにTranscomponentを用いて多言語化できる。

diff --git a/src/App.js b/src/App.js
index 67b04a9..bbaa97d 100644
--- a/src/App.js
+++ b/src/App.js
@@ -2,19 +2,23 @@ import React from 'react';
 import logo from './logo.svg';
 import './App.css';
 import './i18n';
-import { withTranslation } from 'react-i18next';
+import { withTranslation, Trans } from 'react-i18next';

 function changeLanguage(i18n, lang) {
   i18n.changeLanguage(lang);
 }

 function App({ t, i18n }) {
+  const filename = 'src/App.js';
+
   return (
     <div className="App">
       <header className="App-header">
         <img src={logo} className="App-logo" alt="logo" />
         <p>
-          Edit <code>src/App.js</code> and save to reload.
+          <Trans i18nKey="Save to Reload text">
+            Edit <code>{{ filename }}</code> and save to reload.
+          </Trans>
         </p>
         <a
           className="App-link"

このように記述した場合、テキストファイルには下記のように記述する。

日本語テキスト

{
  "Learn React": "Reactを学ぶ",
  "Save to Reload text": "<1>{{filename}}</1>を編集して保存すると、リロードされます。"
}

英語テキスト

{
  "Learn React": "Reactを学ぶ",
  "Save to Reload text": "<1>{{filename}}</1>を編集して保存すると、リロードされます。"
}

<1> で囲むというところが若干分かりにくい気もするが、、、こういうものなのだろうか?
自分自身あまり使い込めていないので、とりあえず使っていってみる!

この Trans コンポーネントに関する詳細については、下記のページに詳しく書かれている。

Trans Component - react-i18next documentation

addResourceBundleとremoveResourceBundleを用いて、必要て最低限のresourceのみを利用する方法

例えば一度に複数の言語ファイルを読み込みたくないという要望はあると思う。

そういうとき、必要な言語ファイルだけを読み込むにはどうすればよいか?

そこで登場するのが addResourceBundle という関数になる。

また、逆にresourceを削除するための removeResourceBundle という関数もある

https://www.i18next.com/how-to/add-or-load-translations#add-after-init

そこでこれらのコードを利用して、下記のようなコードを作成してみた

まずi18n.jsである。

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

i18n.use(initReactI18next).init({
  resources: {},
  fallbackLng: 'en',
  debug: true,

  interpolation: {
    escapeValue: false
  }
});

export default i18n;

ここでは空のresourcesを渡しているが、最初からなんらかの言語情報をresourcesとして渡しても問題ない

ひとまずここでは空のresourcesを渡して、後で動的にリソースを格納していくことにする。

さらに言語resourceの追加・削除、並びに利用する言語の変更を行うための関数を i18-utils.js という名前で作成した。

import resources from './locales/resources';

const resourceKeys = Object.keys(resources);

export const changeLang = (i18n, lang) => {
    // 更新対象のresourceを追加する
    i18n.addResourceBundle(lang, 'translation', resources[lang].translation);

    // 言語変更処理
    i18n.changeLanguage(lang);

    // 不要になったresourceは削除する
    const removeLnaguages = resourceKeys.filter((languag) => languag !== lang);
    for (const removeLang of removeLnaguages) {
        i18n.removeResourceBundle(removeLang, 'translation')
    }

    return;
}

そしてこれらのコードを使った形で下記のようなコードを書く。 ここでは useTranslation を用いたコードで書いていく。

import React, { useCallback, useEffect } from 'react';
import logo from './logo.svg';
import './App.css';
import './i18n';
import { useTranslation, Trans } from 'react-i18next';
import { changeLang } from './i18n-utils';

const App = () => {
  const [ t, i18n ] = useTranslation();

  const filename = 'src/App.js';

  const onClickChangeLanguage = useCallback((lang)  => {
    changeLang(i18n, lang)
  }, [i18n]);

  useEffect(() => {
    changeLang(i18n, 'en')
  }, [])

  console.log(i18n.logger.options.resources)

  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
          <Trans i18nKey="Save to Reload text">
            Edit <code>{{ filename }}</code> and save to reload.
          </Trans>
        </p>
        <a
          className="App-link"
          href="https://reactjs.org"
          target="_blank"
          rel="noopener noreferrer"
        >
          {t('Learn')}
        </a>
        <div>
          <p>Change Language</p>
          <button onClick={() => onClickChangeLanguage('en')}>en</button>
          <button onClick={() => onClickChangeLanguage('ja')}>ja</button>
        </div>
        {i18n.languages.map(lang => (
          <p key={lang}>{lang}</p>
        ))}
      </header>
    </div>
  );
}

export default App;

これを実行すると、正常に言語切替が行われる。

また言語切替が行われるタイミングでi18n内に格納されている言語のうち、選択されたもの以外のresourceが削除される。

これは console.log(i18n.logger.options.resources) というデバッグ用の関数を差し込むことで確認が可能。

i18n.languagesの中身について

上のコードでデバッグ目的な下記のような記述を書いた

        {i18n.languages.map(lang => (
          <p key={lang}>{lang}</p>
        ))}

例えば英語を選択した場合、 en が入るが、日本語を選択した場合、ここには en, jaという2つが入る。

なぜ en はこの配列の中に居座り続けているのだろうかと考えたが、ここは

i18n.use(initReactI18next).init({
  resources: {},
  fallbackLng: 'en',
  debug: true,

  interpolation: {
    escapeValue: false
  }
});

ここで fallbackLng として指定していたからだった。

ここで指定すると、i18n.languages の中には居座り続ける仕様となっている、というのは実装する上で頭の中に入れておいたほうが良いかもしれないと思った次第。

react-i18nextでObject(...) is not a functionというエラーに悩まされた

react-i18nextを触る上で、少しハマったことがあったので、最後に書き記しておく。

ここまで書いていたようなことを、とある環境で試していたところ、なぜだか Object(...) is not a function というエラーが出る。
最初、自身のReactの使い方が間違っているのかと疑っていたが、なんてことはなく、react, react-domのバージョンが古いだけだった。。

github.com

私はこの原因を突き止めるのに3時間ほどかけてしまった。猛省した。