JavaScript
I18n
React
babel
react-intl

コンポーネント時代のi18n

More than 1 year has passed since last update.

グローバルからコンポーネントベースのid管理へ

サービスを海外展開したい場合、国際化対応を行う必要性があります。これをi18n対応と呼びます。Reactでフロントエンドを構築する場合、i18nのための多くのライブラリがありますが、ダウンロード数的にyahoo製のreact-intl が最も使われているライブラリです。react-intlを実際に使っている例としては、スター15000を超えるReactボイラープレートであるreact-boilerplate やSNSの マストドンがあります。

react-boilerplate/react-boilerplate: A highly scalable, offline-first foundation with the best developer experience and a focus on performance and best practices.
tootsuite/mastodon: Your self-hosted, globally interconnected microblogging community

しかし、実際にreact-intlを使うとidの管理が非常に面倒です。(他すべてのi18nライブラリも同様ですが)。
ここでは、react-boilerplateを参考にidの管理を見てみます。

まず前提としてreact-boilerplateでは、ディレクトリベースでコンポーネントを管理しています。その中で、messages.jsにdefineMessagesを使いidとデフォルトメッセージを定義し、それをindex.jsでimportしてFomattedMessageコンポーネントに渡しています。


他の言語の翻訳はフラットなjsonに書き出しています。(例: de.json)

ここでのポイントは、キーの管理にコンポーネントベースのプレフィックスを用いている点です。これによってidの管理コストを抑えています。しかし、これは非常に面倒であるし、重複やタイポも検知できません😩 実質、グローバル変数と同じでもあるとも言えます。
そこで、babel-plugin-react-intl-autoを使ってこの問題を解決します。

akameco/babel-plugin-react-intl-auto
i18n for the component age. Auto management react-intl ID.

babel-plugin-react-intl-auto

このバベルプラグインは、その名の通りidを自動生成します。

app/components/Greeting/messages.jsというファイルの場合以下のように変換されます。

export default defineMessages({
  hello: 'hello {name}',
  welcome: 'Welcome!',
})


// ↓ ↓ ↓


export default defineMessages({
  hello: {
    id: 'app.components.Greeting.hello',
    defaultMessage: 'hello {name}'
  },
  welcome: {
    id: 'app.components.Greeting.welcome',
    defaultMessage: 'Welcome!'
  },
})

もはやiddefaultMessageなんて書く必要はありません。純粋に文字列を書くだけです。

このプラグインは、ファイルパスとオブジェクトのキーを使ってバベル時にidを生成し、また、文字列をdefulatMessageの値へと置き換える変換を行います。これによって、記述がシンプルになります。よって、手動でidを決めてグローバルで管理なんてする必要はなくなりました。

いくつかの便利なオプションがあります。提案や実装をしてくれた全てのコントリビューターに感謝します。
詳しくはreadmeを読んでください。

例: コメントを説明に変換するオプション extractComments

export const test = defineMessages({
  // Message used to greet the user
  hello: 'hello {name}',
})

           

export const test = defineMessages({
  hello: {
    id: 'path.to.file.test.hello',
    defaultMessage: 'hello {name}',
    description: 'Message used to greet the user',
  },
})

抽出 ~extract-react-intl-messages~

「idを生成できるのはわかった。でも、結局jsonに自分でidを書く必要があるでしょう?」

まさか!まさかです!i18nのキーの管理を手動でやるなんて前時代的すぎます。コードから自動生成しましょう。 extract-react-intl-messages をインストールしてください。

$ yarn add --dev extract-react-intl-messages

akameco/extract-react-intl-messages
extract react intl messages

いくつかのオプションを取ります。詳しくは、readmeを読んで下さい。
もちろん、愚直に上書きなんてせず、すでにファイルがあれば適切にマージしてキーをソートします。

$ yarn run extract-messages -d en -l=en,ja -o translations 'src/**/*.js'

デフォルトの出力ではフラットなJSONです。オプションによりネストしたJSONやYAMLの出力も可能です。

en.json
{
  "components.App.hello": "hello {name}",
  "components.App.welcome": "Welcome"
}
ja.json
{
  "components.App.hello": "",
  "components.App.welcome": ""
}

翻訳ファイルの可読性 (json to yaml)

フラットでキーが長いJSONより、ネストしたJSONのほうが読みやすいことに異論はないでしょう。しかし、react-intlでネストしたJSONを使う場合は、実行時にflatのようなライブラリを使用しする必要があります。ここで、webpackのローダーを使えば、ビルド時にフラットなJSONに変換可能です。
さらに進んで、JSONより可読性の高いYAMLを使うこともできます。YAMLをビルド時にフラットにしたい場合は、yaml-flat-loaderを使うと便利です。
詳しい使い方はREADMEを参照してください。

hello:
  world:
    webpack
import hello from './hello.yml'

console.log(hello)
// => { 'hello.world': 'webpack' }

akameco/yaml-flat-loader

型 (flowtype)

グローバルなid管理で困ることの一つは、補完が効かないこと。また、タイポを防ぐ仕組みがないことです。これは、Flowtypeの力を借りて解決出来ます。Flowtypeの機能の一つである$ObjMapによって型の補完を試みます。

flow-typedにある型定義を少し変更します。これで引数のオブジェクトのkeyでなければ型レベルでエラーが起きます。

declare function defineMessages<T: { [key: string]: MessageDescriptor }>(
  messageDescriptors: T,
): T;

   

declare function defineMessages<T: {[key: string]: string}>(
   messageDescriptors: T
): $ObjMap<T, string => MessageDescriptor>

OK!型は偉大です!さよならタイポ!ようこそ補完!

i18nのテスト (スナップショットテスト)

自動生成によりキーはあるが、翻訳を入れ忘れるということが大いに有り得ます。ええ、マーフィーの法則です。失敗する可能性のあるものは、必ず失敗するものです。
つまるところ、入力忘れなんて人間が意識する必要なんてないのでテストによって検知しましょう。また、変更があればそれもテストしたいですよね。喜ばしいことに我々にはJestのsnapshotテストがあります。基本的にはオブジェクトのスナップショットを行えばいいですね。
ここでもし、YAMLを使っているならJestにそれをJSONと認識させるためにjest-yaml-flat-transfromが必要になります。

 // @flow
import ja from './ja.yml'

test('snapshot', () => {
  expect(ja).toMatchSnapshot()
})

入力忘れは、オブジェクトのそれぞれの値が""でないかを確認するだけでいい場合が多いでしょう。しかし、ライセンス表記などあえてデフォルトと同じ値にしたい状況が存在します。これは、キーのホワイトリストをスナップショットテストすることで解決しましょう。従来であれば、テストコードに列挙する必要がありました(もしくはテストしない)が、スナップショットテストの登場でそれは過去の話です。

// @flow
import ja from './ja.yml'

const getBlanks = (obj: Object) => Object.keys(obj).filter(v => obj[v] === '')

test('check whitelist [ja]', () => {
  const blanks = getBlanks(en)
  expect(blanks).toMatchSnapshot()
})

おわりに

Reactのi18n対策をreact-inlt-autoを使ってコンポーネントベースで行う方法、default messageの自動抽出、Flowtypeによる補完、i18nのテストなどについて述べました。これらが、あなたのアプリケーションの国際化について役に立てば嬉しいです。
何か議論があれば、この記事のコメントまたはTwitter(akameco)までお気軽にどうぞ。また、GitHubでスターを頂けるとモチベーションの維持に繋がるのでよろしくお願いします。

akameco/babel-plugin-react-intl-auto
i18n for the component age. Auto management react-intl ID.


この記事は、mediumからの転載です。
事実として、mediumよりQiitaの方が現状10倍のPVが見込めるので時間をおいてこちらでも公開しようと思った次第であります。