react-intlでReact Nativeアプリの国際化対応をする

はじめに

だいぶ前にReact Nativeで開発中のアプリを国際化するにあたってreact-intlを使ったのでそのあたりのメモをブログに書いていたが、下書きのまま公開していなかった。今回、改めて別のアプリで同様のことをする必要があって過去の自分の下書きを見つつ作業をしたので、改めて整理したりして書き直して世に出すことにした。

国際化対応のライブラリ

簡単に調査したところReactNativeでのi18n対応を行うためのライブラリとしてはよく見かけたのは以下。

  • react-intl (format.js)
  • react-i18next
  • react-native-localize

ract-native-localize以外はReact Native用というわけではない。Github上のスター数は2022年5月時点で上から順に13.1K、7.3K、1.8Kとreact-intlが圧倒的ではあるが、NPMの週間ダウンロード数ではreact-i18nextが上回っている状況だった。

参考までにNext.jsの公式が紹介していた比較的新しめのライブラリとして以下のようなものもあるがこれらはそもそもReact Nativeで使えるかもわからない。

ちなみにnext-intlはreact-intlが使ってるFormat.jsをベースにしてるってことなので似た感じなのかも。

react-intlはメッセージファイルを用意して、FormattedMessageというコンポーネントで記述する。react-i18nextはファイルを用意するのは同じだがt()という関数でメッセージを引っ張ってくるだけとシンプルな感じ。全般的にreact-intlのほうが記述は冗長な感じなのでラッパー関数なりユーティリティなりを作ったほうが良さそう。特にJSX内の対象文字列の箇所でFormattedMessageという長めの名前のコンポーネントが出現するのは個人的にはちょっと嫌だなと思った。

一方で、i18nextは使ったことがある開発メンバーからは不評だった。

結論としては冒険するところでもないので一番ユーザベースの多いreact-intlを導入することにした。メンバーの利用経験もあるし、フォーマットも国際化対応してくれるみたいだし。

導入

まずはインストール。

yarn add react-intl

とりあえず英語と日本語のロケールのファイルを作成する。フォルダ構成は推奨?の構成にならってひとまずプロジェクトルート直下にlangディレクトリを作ってen-US.jsonja.jsonというファイルを作成する。

{
  "title": "ホーム",
  "name": "{name} 様",
  "message": "こんにちは"
}
{
  "title": "HOME",
  "name": "Mr/Ms {name}"
}

内容的には普通のjsonで、jsonのキーがそのままメッセージのキーになっている。 このキーをどうするかは悩ましい。昔国際化対応したときもここで悩んだ思い出。実はこのあたりを自動化する仕組みがreact-intlというかformat.jsには存在する。が、その辺は後述。

さて、ファイルを作ったらApp.tsxを以下のように。<IntlProvider />でまるっと囲む。

import { IntlProvider, FormattedMessage, FormattedNumber } from 'react-intl'
import ja from './lang/ja.json'
import en from './lang/en-US'

  return (
    <IntlProvider messages={ja} locale="ja" defaultLocale="en">
      <Root>
        <Provider store={store}>
          <View style={{ flex: 1 }}>
            <Routes />
          </View>
        </Provider>
      </Root>
    </IntlProvider>
  )

そして文字列を表示している箇所をこんな感じで指定するだけだ。

<FormattedMessage id="title" />

ただし、これだとInput要素などのプレースホルダの値に設定したくてもいれられない。というわけでローレベルAPIも用意されている。

export const InputSomething = (): React.ReactElement => {
  const intl = useIntl()
  const placeholder = intl.formatMessage({ id: 'title' })
  return (
    <View>
      <TextInput
        placeholder={placeholder}
        value=""
      />
    </View>
  )

基本的にはこんなところだ。あとは各言語ごとにすべてのメッセージを用意して、置き換えることになる。

もう一歩すすめる

さて、ここまでのやり方はどちらかというと古き良き国際化対応って感じだ。ここで気になるのが前述のとおりキーをどうやって作成管理していくかが課題になりがちではある。メッセージが少ないうちはいいが画面数が増えたりメッセージ数が増えてくると結構大変。また、ソースコード上にはそのキーが表示されているので実際のメッセージがわかりにくいという問題もある。

そんな問題を少しばかり解決してくれるのがMessage ExtractionとMessage Distributionの仕組み。これはソースコード中でid (キー) を指定するのではなく、descriptionとデフォルトのメッセージを指定した上でextractを実行すると指定した言語のファイルが生成されるというもの。その後、そのファイルをcompileすることでキーが自動で生成されてくるのだ。

というわけでやっていく。

まず、cliをインストールする。これでextractが使えるようになる。

yarn add -D @formatjs/cli

そうしたら package.jsonに設定を入れる。scriptsにとりあえずは以下のように追記すればいい。

  "scripts": {
    "extract": "formatjs extract",
    "compile": "formatjs compile"
  },

なお、これでももちろん動くけどコマンドの引数とかが面倒なので自分の場合は実際にはこんな感じにしている。

  "scripts": {
    "extract": "formatjs extract 'src/**/*.ts*' --out-file lang/en.json",
    "compile": "formatjs compile lang/en.json --ast --out-file src/compiled-lang/en.json",
  },

ここでは英語のメッセージファイルだけを対象にしているがこのあたりの話は後述する。

あとはeslintとbabelのプラグインなども入れておく

yarn add -D eslint-plugin-formatjs
yarn add -D babel-plugin-formatjs
yarn add -D @formatjs/ts-transformer

eslintの設定に以下の内容を追加する。自分の場合は.eslintrc.jsを編集。

{
  "plugins": ["formatjs"],
  "rules": {
    "formatjs/no-offset": "error"
  }
}

といっても実際には別のプラグインがすでに導入されている等のケースも多いと思うので、その場合は既存の部分に追加しておく。

babelのほうはbabel.config.jsを編集した。こちらにも以下の内容を追加する。

{
  "plugins": [
    [
      "formatjs",
      {
        "idInterpolationPattern": "[sha512:contenthash:base64:6]",
        "ast": true
      }
    ]
  ]
}

最後にjest.config.jsonに以下を追加。

module.exports = {
  globals: {
    'ts-jest': {
      astTransformers: {
        before: [
          {
            path: '@formatjs/ts-transformer/ts-jest-integration',
            options: {
              // options
              overrideIdFn: '[sha512:contenthash:base64:6]',
              ast: true,
            },
          },
        ],
      },
    },
  },
};

ここまでしたら後は書くだけ。まずはメッセージは以下のように指定する形に変更する。

<FormattedMessage defaultMessage="HOME" description="the title for something" />

description、つまりこのメッセージの説明とデフォルトで表示するメッセージをdefaultMessageで指定する。ポイントは先のやり方とは違ってキー(id)を指定していないことだ。ソースコード中にキーしか書かれていないときと比べて可読性があがると言えるのではないか。

次にextractを実行していく。ソースコード中に記されたメッセージを文字通り『抽出』してキー(id)を自動生成したメッセージファイルをlang以下にjson形式で作成してくれる。ファイル名はpackage.jsonに指定したファイル名になる。

ファイルの中身はこんな感じになっている。ソースコード中で指定したものがすべて抽出されてキーが設定されて羅列されているはずだ。以下の例ではFoqPXLというのが自動で作成されたキーだ。

{
  "FoqPXL": {
    "defaultMessage": "HOME",
    "description": "the title for something"
  },

なお、この自動生成されたキーはdefaultMessagedescriptionをもとにしたハッシュのようでこれらを変更すると新たに生成されて上書きされるので要注意。

そして、生成されたファイルを編集する。つまり翻訳作業だ。翻訳作業に関してはyarn extractで生成されたファイルであるlang/en.jsonをコピーするなりして翻訳後の内容に書き換えていけばいい。このときキーについては変更してはいけない。先の内容だとこんな風に翻訳する。descriptionはどちらでも構わない。

  "FoqPXL": {
    "defaultMessage": "ホーム",
    "description": "the title for something"
  },

翻訳作業が終わったらこのファイルをlang/ja.jsonとでもして保存する。日本語以外にも翻訳対象があるなら同様の手順を踏む。なお、このextractコマンドはdescriptiondefaultMessageに変更がなければキーはそのまま前回以前のものが維持される。

最後、編集が終わったらcompileする。compile結果のファイルはアプリの中から参照できる場所に保存しておく必要がある。compile自体はformatjs compileを実行するだけなんだけど翻訳対象の言語が複数ある場合、つまりメッセージファイルが複数ある場合にはcompileも言語ごとに実行する必要がある。yarn compileで設定しておく場合はこのあたりも考慮しておく必要がある。自分の場合は以下のようになっている。

  "scripts": {
    "extract": "formatjs extract 'src/**/*.ts*' --out-file lang/en.json",
    "compile": "yarn intl:compile:en && yarn intl:compile:ja",
    "compile:en": "formatjs compile lang/en.json --ast --out-file src/compiled-lang/en.json",
    "compile:ja": "formatjs compile lang/ja.json --ast --out-file src/compiled-lang/ja.json",
  },

さて、実行するとこういうファイルが生成される。

{
  "FoqPXL": [
    {
      "type": 0,
      "value": "HOME"
    }
  ]
}

ここまでで言語ごとのメッセージ切り替えはひとまず完了だが実際にアプリケーションに組み込むとなるとこれだけでは終わらない。例えば言語設定の変更をどうするか。ユーザが設定可能にするならばそのUIを用意することに加えてユーザ設定に応じてApp.tsxに設定したロケールを切り替える処理を実装することが必要。なお、自分の場合はデバイスロケール設定をもとに設定する方法をとっている。

バイスからロケールを取得する

さて、ついでと言ってはなんだけどどうせならデバイスロケール情報をもとに表示する言語を自動的に切り替えたい。これは以下のような感じでデバイスから言語設定を取得して条件分岐して IntlProvider に渡してあげればいい。React Nativeの場合、iOSAndroidで取得方法が微妙に違うので注意。

こんな感じ。

import { Platform, NativeModules } from 'react-native';

export const getLocale = (): string => {
  const deviceLanguage =
    Platform.OS === 'ios'
      ? NativeModules.SettingsManager.settings.AppleLocale ||
        NativeModules.SettingsManager.settings.AppleLanguages[0] // for iOS 13+
      : NativeModules.I18nManager.localeIdentifier;

  let locale: 'ja' | 'en' | 'pt' | 'es';
  if (/^ja/.test(deviceLanguage)) {
    locale = 'ja';
  } else if (/^en/.test(deviceLanguage)) {
    locale = 'en';
  } else if (/^pt/.test(deviceLanguage)) {
    locale = 'pt';
  } else if (/^es/.test(deviceLanguage)) {
    locale = 'es';
  } else {
    locale = 'en';
  }

  return locale;
};

前半の処理がiOSとAndroidそれぞれでデバイスのロケール設定を取得する処理。後半は実際に取得するロケール情報は`言語_地域`みたいな感じだったりもする。`en_US`のように。なので地域は考慮せずにロケール設定するようにしているだけ。ここはお好みで構わないかと。

## ロケールに応じたメッセージファイルを読み込む
ここは正直やり方はなんでもいいかと思う。普通にロケールに応じて`if`で条件分岐したり`switch`で処理したりとお好みで。

## 実際の翻訳フローについて
さて、ここまでreact-intlおよびformatjsを道入してきたわけだけど実際の翻訳フローについても考える必要がある。ここはいろんな状況によるので一概には言えないがFormat.jsとしては以下のような流れを想定しているようだ。

1. メッセージをextractしたらその結果をリポジトリにコミット
2. CIでTMS(Trascription Management System)が取り出し(pull)
3. TMSで翻訳管理をしつつ翻訳担当が翻訳
4. 翻訳結果のファイルをリポジトリにコミット
5. コミットされたらcompileを実行

TMSはいろんなサービスやプロダクトがあって、Foramt.jsの形式に対応しているサービスもいくつかある。TMSを使うと翻訳済、未翻訳の管理や機械翻訳の実施などができたりする。

自分の場合はいろいろTransifexを試したりWeblateというOSSプロダクトを試したりしたものの、最終的にはextractで生成したファイルをもとにコピーして各言語のファイルを作成し直接翻訳する形を取ることにした。これは現状では翻訳担当が別にいるわけではなく開発担当=翻訳担当でもあるのでTMSを入れると逆に手間がかかると感じたため。

extractで生成したファイルをコピーすると言ったが、実際にはまるっと単純にコピーしてしまうと毎回翻訳内容が消えてしまうのでそれぞれのファイルの内容をキーをもとにチェックして、すでにキーが存在している場合はコピーしないというスクリプトを実装して実行している。これをデフォルト言語(英語)以外で実行している感じだ。
©Keisuke Nishitani, 2023   プライバシーポリシー