Edited at
LOBDay 4

TypeScript + React で i18n (国際化/多言語) 対応を楽して続けるためのアレコレ

こんにちは! LOB のアドベントカレンダー の4日目は、またもや僕だよ!

前回の LOB での TypeScript + React 選定小話 に引き続き、またも TypeScript + React ネタです。

今回はちゃんとコードを添えた記事でお送りいたします。


これはなに


  • i18n (国際化/多言語) 対応ってめんどくさいよね!


    • 翻訳 id 覚えるのめんどくさいし

    • typo するし

    • その割に何度も書かされるし

    • たまに翻訳漏れしたりするし

    • おれはちゃんとやってるのに誰かがいつも忘れて事故る



という様々なつらみを解決するやつです。(上の例の一部は TypeScript 使っただけで補完きいて解決してるものも含んでますけど)

超しょぼいサンプルアプリを作りながらお送りします。

そのへんの過程はどうでもええんや!まとまったコードをみせろや!!という漢スタイル方のためにリポジトリごと公開しておりますので あとがきまでジャンプしてしまってください。


やっていく


準備編

なにはともあれ翻訳するためのアプリケーションが必要ですな。さすがにプロダクトのコードを公開できないので、さくっと Next.js で組み立てていきます。

そんなんどうでもえーわっていう方は本題まで飛んでいってください。

なお、以下は yarn がインストールされていること前提です。必要に応じて脳内変換してください。


App 作成

yarn init

いろいろ言われるので適当に設定してください


Next.js つかって TypeScript + React をシュッと用意

yarn add next react react-dom @zeit/next-typescript typescript

next にビルド系をおまかせしたいので package.json に以下の scripts を追加しましょう。


package.json

// 

"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}

お次は tsconfig.json ですね。内容はおまかせしますが、今回はこんな感じで設定してみます。


tsconfig.json

{

"compilerOptions": {
"outDir": "./dist/",
"target": "es2017",
"module": "commonjs",
"jsx": "react",

"strictNullChecks": true,
"noImplicitAny": false,
"noImplicitReturns": true,
"noImplicitThis": true,
"alwaysStrict": true,

"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"removeComments": true
}
}


すぐ忘れそうになりますが、 tsconfig.json に加えて .babelrc 経由でも typescript 使う宣言が必要になります。

以下のように。

{

"presets": [
"next/babel",
"@zeit/next-typescript/babel"
]
}

next.config.js を編集して next と一緒に yarn add した @zeit/next-typescript を使うようにします。


next.config.js

module.exports = require('@zeit/next-typescript')()


ここは個人の自由というか思想次第ですが、 tslint と prettier を入れないとやる気がでないので、ここでいれちゃいます。

yarn add tslint tslint-config-prettier tslint-eslint-rules tslint-plugin-prettier tslint-react prettier @types/next

無事インストールが完了したら tslint.json.prettierrc を用意してあげましょう。 .prettierrc は yaml 派です。

このへんも個人やら会社によってポリシーあると思うので、適宜加筆修正削除なりして設定してください。


tslint.json

{

"defaultSeverity": "error",
"extends": ["tslint:recommended", "tslint-react", "tslint-eslint-rules", "tslint-config-prettier"],
"rulesDirectory": ["tslint-plugin-prettier"],
"rules": {
"prettier": true,
"object-literal-sort-keys": true,
"no-namespace": true,
"no-console": true,
"radix": true,
"interface-name": [true, "never-prefix"],
"jsx-no-lambda": true
}
}


prettierrc.yml

printWidth: 120

bracketSpacing: true
singleQuote: true
semi: false
trailingComma: es5
jsxBracketSameLine: false
arrowParens: avoid
requirePragma: false

ここまでできたら一旦動作確認してみましょう。たくさんページ作る気もないので pages/index.tsx 一枚だけでいいでしょう。

mkdir pages

touch pages/index.tsx


pages/index.tsx

import * as React from 'react'

export default () => <div>It's works</div>


yarn dev で開発サーバーを立ち上げ、 http://localhost:3000 (デフォルトでポート 3000番になってるはず)にアクセスして、It's works と表示されたらここまでは準備編は完了です。

まだまだ続きますよ!


本題

さて、TypeScript + React な環境が用意できたということで、本題にはいっていきましょう。

がしかし、It's works だけ翻訳させてもつまらないので、「新年まであと何日カウンター」でもつくりましょう。たいしておもしろくないけど。

加えてちゃんと翻訳機構が動作しているかも確認したいので、デフォルト日本語、クエリパラメータに言語指定がはいっていればそれを使う、という仕様にしてみましょう。

というわけでまずはベースの実装から。日付処理といえばおなじみの moment をいれて楽をします。

yarn add moment @types/moment

日本語でカウンターが動くようにちょろっと書き換えてみます。どうせなら残りの日数に応じたコメントも表示させてみましょう。


pages/index.tsx

import * as React from 'react'

import * as Moment from 'moment'

const getRemain = (): number =>
Moment()
.endOf('year')
.diff(Moment(), 'days')

const getComment = (remain: number): string => {
if (remain < 31) {
return 'もう師走、よい一年でしたか?'
} else if (remain < 92) {
return '残り3ヶ月を切りましたね!冬がはじまるよ!'
} else if (remain < 183) {
return '早いもので今年も後半戦のはじまりです!はりきっていきましょう!'
}
return 'こんにちは!まだまだ今年は終わらないよ!'
}

export default () => {
const remain = getRemain()
const comment = getComment(remain)
return (
<>
<h1>今年がおわるまであと{remain}</h1>
<p>{comment}</p>
</>
)
}


こんな感じでしょうか。



動いているようにみえます。ではこれを i18n やっていきましょう。ようやくっすね。


翻訳系のパッケージの導入

定番の react-intl を使います。自前でやると数値のカンマ位置とか、通貨とか、そのへんに向き合わないといけないので皆様全力で Yahoo 様に感謝と祈りをささげましょう。

yarn add react-intl @types/react-intl

今回は intl ディレクトリを掘って、その中に翻訳系の処理やらファイルを突っ込んでいきます。

以下のような構成でつくっていく方向で進めます。

intl

├── index.ts // 共通処理はこちらで
├── locales // ここに翻訳ファイルいれる
└── messages
└── index.ts // 翻訳 id 管理するやつ

まずは intl/index.ts の実装から開始します。

こいつに期待するのは、現在の言語設定を渡すと、それに応じた翻訳済み文字列を返却してくれること、と於いてみましょう。


intl/index.ts

import { addLocaleData, IntlProvider } from 'react-intl'

import * as en from 'react-intl/locale-data/en'
import * as ja from 'react-intl/locale-data/ja'

addLocaleData([...ja, ...en])

export const locales = {
en: require('./locales/en.json'),
ja: require('./locales/ja.json'),
}

export default class IntlMessage {
protected intl: ReactIntl.InjectedIntl
constructor(locale?: string) {
if (locale) {
this.intl = this.makeIntlInstance(locale)
}
}
public format(
messageDescriptor: ReactIntl.FormattedMessage.MessageDescriptor,
values?: { [key: string]: ReactIntl.MessageValue }
): string {
return this.intl.formatMessage.bind(this.intl)(messageDescriptor, values)
}
public setLanguage(locale: string) {
this.intl = this.makeIntlInstance(locale)
}
protected makeIntlInstance(locale: string): ReactIntl.InjectedIntl {
const intlProvider = new IntlProvider({
locale,
messages: locales[locale],
})
return intlProvider.getChildContext().intl
}
}


コンストラクタ、もしくは public な口で用意してある setLanguage 経由で言語設定を渡すと、インスタンス内に intlProviderInjectedItl 型を this.intl にセットする実装です。

format には id (上の実装でいうところの messageDescriptor) と values を渡すと、 InjectedIntl を使って formatMessage を実行、文字列を返却するようにしてみました。

突貫工事なのでコンストラクタに引数渡さず、setLanguage も呼ばないまま format しようとしたときのエラーハンドリングが用意されておりませんが、今回はご愛嬌ということで…。


実際に翻訳を試してみましょう

では、実際に翻訳されるように書き換えていきましょう。

タイトルに該当する tiltles, 残日数に応じたテキストの comments, 言語切替ボタン用の links にオブジェクトを分割してみます。


intl/messages/index.ts

import { defineMessages } from 'react-intl'

export const titles = defineMessages({
index: {
id: 'titles.index',
defaultMessage: '{remain} days left until the New Year',
},
})

export const comments = defineMessages({
lessThanOrEqualTo31days: {
id: 'comments.lessThanOrEqualTo31days',
defaultMessage: 'It is already December. Was it a good year?',
},
lessThanOrEqualTo92days: {
id: 'comments.lessThanOrEqualTo92days',
defaultMessage: '3 months to go again this year. Winter will begin!',
},
lessThanHalf: {
id: 'comments.lessThanHalf',
defaultMessage: "It's the beginning of the second half of the year! Let's go do our best!",
},
moreThanHalf: {
id: 'comments.moreThanHalf',
defaultMessage: 'Hi, this year has just begun!',
},
})

export const links = defineMessages({
switchLanguage: {
id: 'links.switchLanguage',
defaultMessage: 'Switch Language',
},
})


こんなかんじですね。英語は苦手なのでツッコミを入れられると涙が出てしまうの。

次に intl/locales の下に翻訳ファイルを用意します。 en, ja ごとに実際に表示される文字列のベースとなるテキストデータを json で設置します。


intl/locales/en.json

{

"titles.index": "{remain} days left until the New Year",
"comments.lessThanOrEqualTo31days": "It is already December. Was it a good year?",
"comments.lessThanOrEqualTo92days": "3 months to go again this year. Winter will begin!",
"comments.lessThanHalf": "It's the beginning of the second half of the year! Let's go do our best!",
"comments.moreThanHalf": "Hi, this year has just begun!",
"links.switchLanguage": "Switch Language"
}


intl/locales/ja.json

{

"titles.index": "年越しまであと {remain} 日!",
"comments.lessThanOrEqualTo31days": "もう師走、よい一年でしたか?",
"comments.lessThanOrEqualTo92days": "残り3ヶ月を切りましたね!冬がはじまるよ!",
"comments.lessThanHalf": "早いもので今年も後半戦のはじまりです!はりきっていきましょう!",
"comments.moreThanHalf": "こんにちは!まだまだ今年は終わらないよ!"
"links.switchLanguage": "言語切替"
}

そして pages/index.tsxintl 以下を使って文字列を呼び出すようにします。


pages/index.tsx

import * as React from 'react'

import * as Moment from 'moment'
import Link from 'next/link'
import { RouterProps, withRouter } from 'next/router'

import IntelMessage, { locales } from './../intl'
import { titles, comments, links } from './../intl/messages'

const defaultLanguage = 'ja' // デフォルトの言語設定は日本語
const intl = new IntelMessage(defaultLanguage)

const getRemain = (): number =>
Moment()
.endOf('year')
.diff(Moment(), 'days')

const getComment = (remain: number): string => {
let message = comments.moreThanHalf
if (remain < 31) {
message = comments.lessThanOrEqualTo31days
} else if (remain < 92) {
message = comments.lessThanOrEqualTo92days
} else if (remain < 183) {
message = comments.lessThanHalf
}
return intl.format(message)
}

const getCurrentLanguage = (router?: RouterProps) => {
if (!router || !router.query) {
return defaultLanguage
}
const lang = router.query.lang as string
return Object.keys(locales).includes(lang) ? lang : defaultLanguage
}

const getNextLanguage = (lang: string) => (lang === 'ja' ? 'en' : 'ja')

export default withRouter(({ router }) => {
const lang = getCurrentLanguage(router)
intl.setLanguage(lang)

const remain = getRemain()
const comment = getComment(remain)
return (
<>
<h1>{intl.format(titles.index, { remain })}</h1>
<p>{comment}</p>
<Link href={{ pathname: '/', query: { lang: getNextLanguage(lang) } }}>
<a>{intl.format(links.switchLanguage)}</a>
</Link>
</>
)
})


intl.format を使用するようにした以外の目立つ修正は、以下2点でしょうか。


  • 言語切替ボタンを設置した


  • next/router の withRouter を使い、クエリパラメータを取得できるようにした

うまく実装できていると以下のような動きをするはずです。


やったね!これで君も i18n 対応マスターやで!!!

とはいかず、このままだと運用上しんどいことばかりです。

ざっと目につくめんどくさい部分は以下でしょう。


  • json を手で書かないといけない

  • 翻訳 id を手で書いてるのに同じものを json にも書かないといけない

  • 言語ごとに json を用意する必要があるので、特定の言語にしか加筆修正してなかった/特定の言語だけ加筆修正してなかった問題が起きやすい

  • git pull して手元で動かしてみるまで翻訳漏れがないのか確信が持てない

一つずつ潰していきましょう。

以下のようになるとだいぶ楽そうです。


  • json ではなく yaml

  • messages/*.ts を更新したら自動で locales/{ja|en}.yml が生成される


    • デフォルトメッセージな英語が最初から埋められた状態で生成




  • .git/hooks の下に pre-push ファイルを用意して、翻訳漏れをチェック、パスしないと push できない


yaml で Go

何はともあれ yaml のほうがベター、ローダーをいれて対応しちゃいます。

yarn add json-loader yaml-flat-loader

next.config.js を編集して yaml を require したら yaml-flat-loader が使われるようにします。


next.config.js

module.exports = require('@zeit/next-typescript')({

webpack(config, options) {
config.module.rules.push({
test: /\.(yml|yaml)$/,
use: [{ loader: 'json-loader' }, { loader: 'yaml-flat-loader' }],
})
return config
},
})

これで yaml の読み込みができるようになりました。 intl/index.tslocales も yaml を読むようにします。


itnl/index.ts

export const locales = {

en: require('./locales/en.yml'),
ja: require('./locales/ja.yml'),
}

.json を .yml にしただけですね。

このまま yml を用意してないですが、この際手で書くのではなく自動生成させてしまいましょう。

というわけでいつものパッケージの追加です。

yarn add extract-react-intl-messages

ものすごい万能な神パッケージでして、特にコードを書く必要もなく、 package.jsonscripts に書くだけで動きます。

以下のようなコマンドを追加してみましょう。


package.json

"intl:build": "extract-messages -l=ja,en -o intl/locales 'intl/messages/**/*.ts' -f yaml --flat",


intl/messages 以下の ts ファイルを読み込み、yaml 形式で intl/locales 以下に ja, en を用意する、という一行でございます。

実行してみましょう。

あっけなく完了しました。

なんということでしょう。言語ごとに yaml ファイルが生成されているようです!



  • intl/locales/en.yml




  • intl/locales/ja.yml



かんぺきですね。 extract-react-intl-messages を公開してくれている @akameco 氏に足を向けてねむれません。

ここまで来たら intl/messages を watch するのと、 intl:check 的なスクリプトを用意してあげれば運用面でもバッチリです。

おなじみのパッケージ追加からはじめます。

yarn add watch colors path 

intl/messages の更新検知はこれだけ。かんたん。


package.json

"intl:watch": "watch 'yarn intl:build' ./intl/messages"


最後に翻訳もれチェックスクリプトを用意しましょう。

ビルド処理の CPU がもったいないので生の JavsScript でやります。


scripts/intlChecker.js

require('colors')

const path = require('path')
const fs = require('fs')
const loader = require('yaml-flat-loader')

fs.readFile(path.resolve(__dirname, './../intl/locales/ja.yml'), (err, raw) => {
if (err) {
return console.error('Not found ja.yml'.white.bgRed)
}
const errors = []
const messages = JSON.parse(loader(raw))
Object.keys(messages).forEach(key => {
if (messages[key] && messages[key].length > 0) {
return
}
errors.push(key)
})
if (errors.length === 0) {
console.log('Translation has completed:)'.green)
process.exit(0)
}
console.log('\nOh, these ids has no messages for ja_JP;'.red)
console.log('\n======================================\n')
errors.forEach(e => {
console.log(`${e}`.yellow)
})
console.log('\n======================================\n')
process.exit(1)
})


難しいことはしてません。単純に ja.yml を読み込み、翻訳データが設定されてないキーを収集し、

1つでも翻訳漏れがあれば process.exit(1) で終了させます。


package.json

"intl:check": "node scripts/intlChecker.js",


ちゃんと怒ってもらえました。これで漏れ知らずですね!

仕上げに以下のようなスクリプトを .git/hooks/pre-push として登録しておけば、 git push するときに自動実行されます。

#!/bin/sh

# technical copypasta from https://github.com/angular/material2/wiki/Pre-commit-hook-for-linters

pass=true
RED='\033[1;31m'
GREEN='\033[0;32m'
NC='\033[0m'

echo "Running Linters:"

# Run tslint and get the output and return code
intlCheck=$(yarn intl:check)
ret_code=$?

# If it didn't pass, announce it failed and print the output
if [ $ret_code != 0 ]; then
printf "\n${RED}intl:check failed:${NC}"
echo "$intlCheck\n"
pass=false
else
printf "${GREEN}intl:check passed.${NC}\n"
fi

# If there were no failures, it is good to commit
if $pass; then
exit 0
fi

exit 1

これで治安を維持するために必要なコミュニケーションは「pre-push つかってくれ」だけでよくなるので楽になりましたね。


あとがき

LOB の実プロダクトでも extract-react-intl-messages が大活躍しているのですが、 TypeScript のまま渡しても動くことを知りませんでした。

intl:watch コマンドでファイル更新ごとに JavaScript へ変換、その変換後のファイルを extract-react-intl-messages に食べさせていたのですが、記事作成中、なにも考えず *.ts を指定したら普通に飲み込んでくれて驚愕しました。

今回のサンプルアプリケーションについては、 Github においておきました。興味がある方はぜひこちらも御覧ください。

https://github.com/mojibakeo/ts-react-intl-sample-app

明日は再び @hatchinee による業務で書いたコードはチャンスさえあればパッケージに切り出して公開すべきである理由です。引き続き LOB のアドベントカレンダーをおたのしみください。

ではでは。