こんにちは! 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 を追加しましょう。
// 略
"scripts": {
"dev": "next",
"build": "next build",
"start": "next start"
}
お次は 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
を使うようにします。
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 派です。
このへんも個人やら会社によってポリシーあると思うので、適宜加筆修正削除なりして設定してください。
{
"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
}
}
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
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
日本語でカウンターが動くようにちょろっと書き換えてみます。どうせなら残りの日数に応じたコメントも表示させてみましょう。
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
の実装から開始します。
こいつに期待するのは、現在の言語設定を渡すと、それに応じた翻訳済み文字列を返却してくれること、と於いてみましょう。
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
経由で言語設定を渡すと、インスタンス内に intlProvider
の InjectedItl
型を this.intl
にセットする実装です。
format
には id
(上の実装でいうところの messageDescriptor
) と values
を渡すと、 InjectedIntl
を使って formatMessage を実行、文字列を返却するようにしてみました。
突貫工事なのでコンストラクタに引数渡さず、setLanguage
も呼ばないまま format
しようとしたときのエラーハンドリングが用意されておりませんが、今回はご愛嬌ということで…。
実際に翻訳を試してみましょう
では、実際に翻訳されるように書き換えていきましょう。
タイトルに該当する tiltles
, 残日数に応じたテキストの comments
, 言語切替ボタン用の links
にオブジェクトを分割してみます。
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
で設置します。
{
"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"
}
{
"titles.index": "年越しまであと {remain} 日!",
"comments.lessThanOrEqualTo31days": "もう師走、よい一年でしたか?",
"comments.lessThanOrEqualTo92days": "残り3ヶ月を切りましたね!冬がはじまるよ!",
"comments.lessThanHalf": "早いもので今年も後半戦のはじまりです!はりきっていきましょう!",
"comments.moreThanHalf": "こんにちは!まだまだ今年は終わらないよ!"
"links.switchLanguage": "言語切替"
}
そして pages/index.tsx
も intl
以下を使って文字列を呼び出すようにします。
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
が使われるようにします。
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.ts
の locales
も yaml を読むようにします。
export const locales = {
en: require('./locales/en.yml'),
ja: require('./locales/ja.yml'),
}
.json を .yml にしただけですね。
このまま yml を用意してないですが、この際手で書くのではなく自動生成させてしまいましょう。
というわけでいつものパッケージの追加です。
yarn add extract-react-intl-messages
ものすごい万能な神パッケージでして、特にコードを書く必要もなく、 package.json
の scripts
に書くだけで動きます。
以下のようなコマンドを追加してみましょう。
"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
の更新検知はこれだけ。かんたん。
"intl:watch": "watch 'yarn intl:build' ./intl/messages"
最後に翻訳もれチェックスクリプトを用意しましょう。
ビルド処理の CPU がもったいないので生の JavsScript でやります。
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)
で終了させます。
"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 においておきました。興味がある方はぜひこちらも御覧ください。
明日は再び @hatchinee による業務で書いたコードはチャンスさえあればパッケージに切り出して公開すべきである理由です。引き続き LOB のアドベントカレンダーをおたのしみください。
ではでは。