コンポーネント時代のi18n

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

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

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

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

https://github.com/react-boilerplate/react-boilerplate/blob/master/app/components/Footer/messages.js
https://github.com/react-boilerplate/react-boilerplate/blob/master/app/components/Footer/index.js#L13

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

https://github.com/react-boilerplate/react-boilerplate/blob/master/app/translations/de.json#L2-L3

ここでのポイントは、キーの管理にコンポーネントベースのプレフィックスを用いている点。これによってidの管理コストを抑えている。

しかし、これは非常に面倒だし、タイポも検知できない😩 実質、グローバル変数と同じでもあるとも言える。そこで、babel-plugin-react-intl-autoを使ってこの問題を解決する。

babel-plugin-react-intl-auto

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

app/components/Greeting/messages.js

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

// ↓ ↓ ↓

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

})

マジック!!もはやidとdefaultMessageなんて書く必要はない。文字列を書くだけだ。

このプラグインはコンポーネントのファイルパスに従ってビルド時にidを生成し、文字列をdefulatMessageの値へと置き換える変換を行う。これによって、記述がシンプルになる。そして、もはやidを管理する必要はない。

インストール

$ yarn add --dev babel-plugin-react-intl-auto

.babelrc

{
"plugins": [
["react-intl-auto", {
"removePrefix": "app/",
"filebase": false
}]
]
}

2つのオプションを指定できる。

removePrefix: デフォルトではルートパスからidを生成するがそれを取り除くことができる。

filebase: messages.jsのように別ファイルに分けずに構成している場合はこれをtrueにする。しかし、個人的にはディレクトリベースでプロジェクトを構成するのを強く勧める。

抽出 ~extract-react-intl-messages~

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

まさか!自動生成するに決まってる。extract-react-intl-messages をインストールする。

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

いくつかのオプションを取る。詳しくは、readme まで。-dでデフォルトのロケールを設定し、-lでロケールをカンマ区切りで指定する。-oでアウトプットディレクトリを指定する。すでにファイルが存在すれば、適切にマージしてソートする。

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

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


翻訳ファイルの可読性

「フラットなJSONは読みづらい。プレフィックスがついているとなおさらだ。」
フラットなJSONからYAMLへ切り替えたときの様子

フラットなキーが長いJSONより、ネストしたJSONのほうが読みやすいことに異論はないだろう。しかし、ネストしたJSONを使う場合、実行時にflatのようなライブラリを使用しフラットなJSONに変換する必要がある。

webpackのローダーを使えば、ビルド時にフラットなJSONに変換可能だ。さらに進んで、JSONより可読性の高いYAMLを使うこともできる。YAMLをビルド時にフラットにしたい場合は、yaml-flat-loaderを使う。詳しい使い方はREADMEを参照してほしい。


型 ~ flowtype~

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

https://flow.org/en/docs/types/utilities/#toc-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! 型は偉大だ!さよならタイポ!ようこそ補完!

Flowtypeによる補完とタイポの検知 (Atom & nuclide)

i18nのテスト 〜スナップショットテスト〜

自動生成によりキーはあるが、翻訳を入れ忘れるということが大いに有り得る。マーフィーの法則だ。失敗する可能性のあるものは、失敗するのである。つまるところ、入力忘れなんて人間が意識する必要なんてないのでテストによって検知する。また、変更があればそれをテストしたい。

喜ばしいことに我々にはJestのsnapshotテストがある。基本的にはオブジェクトのスナップショットを行えばいい。もしYAMLを使っているならJestにそれをJSONと認識させるためにちょっとしたトリック(jest-yaml-flat-transfrom)が必要になる。

 // @flow
import ja from './ja.yml'
test('snapshot', () => {
expect(ja).toMatchSnapshot()
})
Jestによるスナップショット

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

// @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()
})
もちろん実際にはusernameにはちゃんとした値を入れる。これは例だ。

Next Step…

さて、コンポーネントベースになったことによって、さらなる利点について少し述べておく。最近のフロントエンドでは、Code Splittingによる遅延ロードによって初期ロードを高速化する流れがある。察しのよい人なら分かると思うが、i18nがコンポーネントベースになったことによってそれぞれのコンポーネントで分割された翻訳メッセージのjsonを読み込むだけで簡単に対応できる。(しかし必要になるまで取り組む必要はないと個人的には思う)


おわりに

Reactのi18n対策をreact-inlt-autoを使ってコンポーネントベースで行う方法、default messageの自動抽出、Flowtypeによる補完、i18nのテストなどについて述べた。これらが、あなたのアプリケーションの国際化について役に立てば嬉しい。

何か議論があれば、この記事のコメントまたはTwitter(akameco)までお気軽にどうぞ。また、GitHubでスターをくれるとモチベーションの維持に繋がるのでよろしくお願いします。


重要

無職につき、何かお仕事の話があればお願いします(まじで)

One clap, two clap, three clap, forty?

By clapping more or less, you can signal to us which stories really stand out.