🌎

小規模環境のi18nでt("Hello","こんにちは")のように日本語をfallbackに入れる運用したら良かった話

commits8 min read

Reactアプリケーションをi18nをするにあたって、t("Hello", "こんにちは")のように日本語をfallback値として設定して、ソースから翻訳ファイルを生成するようにしたらわりと良かったのでまとめる

前提

あくまで今回の話は下記のような前提としている。

  • 小規模で、複雑な多言語化処理を要する部分が少ない
  • 日本語から他言語への変換である
  • 多言語化の種類は少なく、多言語化後も日本語が中心。
  • 組織のコミュニケーションパスが少なく、実装者がUIを変更する事が出来る環境である

準備

本題に入る前の前提として、ライブラリと外部サービス選びについて。

ライブラリ選び

i18n自体はマッピングさえできれば十分なので自前してもよかったが、省力化のためにライブラリに頼った。
様々なコンポーネントを一通り触り、機能性や型などを考慮してi18nextを利用することにした。
翻訳データの型まで考慮されている点は良いと感じる(とはいえ、結局今回の手法では型はそれほど重要にならなかった)
i18nextは機能面として良いが、OSSとしては企業の宣伝色の強い部分は人によっては悩ましいところかもしれない。

優良な候補としてreact-intl(Format.js)も堅牢に行うには良さそうであったが、babelでの変換が必要な点など、簡易に済ませるには少々腰の重そうだったため、今回は候補から外した。

その他の候補としては@shopify/react-i18nなどもシンプルで良さそうだった。

翻訳サービス選び(機械翻訳系サービスを選ぶ)

翻訳系サービスもたくさんあるが、ひとまずチーム管理して自動翻訳をするだけ・日本語がソースになる、価格感、CLIからアップロードできることなど考えると、LocalazyかCrowdinが良かった。

Crowdinは機械翻訳を使うためには自分でAPIキーを設定しなければならない点やLocalazyは翻訳サービスのデフォルトがあまりイケてない点などそれぞれ弱点はありそうだった
ちなみにLocalazyは利用当初日本語からの翻訳が弱かったが、問い合わせたところ少し対応が強化されたので、比較的印象が良かった。

i18nextの開発元であるサービスのlocizeも試してみたが、CLIからの同期機能などが無く、UIが複雑でちょっと使いこなすのは難しそうだった

本題

1. 文言ベースでなくKeyベースにして、デフォルト値に日本語を埋め込む

今後の運用保守を考えると、ソースコード中に日本語を残すのはしたいと考えた。
この場合、下記のように日本語キーにするのがよく使われるパターンがある。

t("こんにちは")

一方で、成熟してないアプリの場合各種文言修正あるとその度にキーが変わることや、文言だけでは意味が適切に訳しづらいデメリットを考えるとうまく行かない予感を感じた。

そこで「キー名を設定しつつ、デフォルト値に日本語を入れる」という手段を取ってみることにしてみた。

// t(キー名, デフォルト値)
t("HomeScreen.MainComponent.Greeting","こんにちは")

今回利用したi18nextでは第二引数がデフォルト値(fallback値)となる挙動だった。

キー名についてはCSSのクラス名のような感覚で、コンポーネントや画面名からざっくり重複しないようにだけ注意しながらつけるようにした。

こうすることで、下記のようなメリットが得られた

  • コード検索に引っかかる
  • 翻訳漏れが起きてしまった場合でも、最低限として日本語がほぼ確実に表示されてくれる(キー名が出ることがほぼ無くなる)
  • 文言修正があってもコード中で変更すればひとまずは変更される
  • キー名をHogePage.FugaComponent.BarLabelみたいにするので、コンポーネントの構造と一致し、「うっかりキーがかぶってしまった」がちょっとだけ起こりづらい

弱点として「同じ文言でも複数回翻訳する必要がある」というのがあるが、「むしろ日本語として同一でも他言語では違うケースに対応しやすい」ことや、「本当に同じであればReactのコンポーネントが共通化されるはず」と考え、これらは問題ないこととした。

FYI: defaultValueはi18nextにおいてはどういう扱いなのか

このdefaultValueとして与えたワードはi18nextでは実装上一体どういう扱いになっているかというのが少々気になる人もいるだろう。
第二引数は、下記defaultValueとしてドキュメントされている。

i18nextにおいては、この第二引数に書いたデフォルト値は通常language=devという状態として扱われるらしい。

defaultValueを利用した場合における型の役割

i18nでのTypeScriptの役割は、「存在しないキーを検出できる」というものがある。
ただし今回の手法を使った場合、default値を設定しているため、「キー名を間違った」などが起きない。
そのため実はt(key:string)ぐらいでも運用は回ってしまう。

一方で、全く無意味かというそうでもなく、後述する抜き出しの漏れや抜き出しツールの漏れやミスの検出が出来るのは嬉しい部分だった。

i18next以外では?

理屈自体は「キーが存在しなければfallbackする」というだけの単純な話なので、仮に自前のi18nをしているならこんな感じでも十分可能だろう。

const useCustomTranslate = () => {
  const translator = getSomeTranslator()
  const t = (key, defaultValue) => {
    return translator(key) ?? defaultValue
  }
  return { t }
}

キー名の型が作れているなら(key: Key, defaultValue: string)のように型をつけるとより便利だろう。

2. i18next-parserで抜き出して翻訳サービスに投げる

キーを組み込んだだけでは対訳ファイルが無いので、i18next-parserでコード中から抜き出して、翻訳サービスへ投げる運用を考えた

抜き出し

設定ファイルはこんな感じにする

// 18next-parser.config.js

module.exports = {
  locales: ['ja'],
  createOldCatalogs: false,
  defaultNamespace:"translation",
  output: 'locale/extracts/$LOCALE/$NAMESPACE.json',
  lexers: {
    tsx: ['JsxLexer'],
  },
  input: "src/**/*.tsx",
}

outputを少し特殊にしていて、あくまでもソース言語(日本語)は「抽出した言語である(extract)」という扱いにして、通常の翻訳対象ファイルとは別で扱っている。

あとはコマンドを叩く

$ yarn i18next-parser

gulpやbroccoliはわざわざツールを増やしたくないので使わず、そのままコマンドで実行している

ちなみに、i18next-parserはかなり柔軟なようでt("xxx")のような形式であれば他のi18nライブラリや自前でも抜き出してくれるようだ。

3. 外部サービスに投げる

翻訳管理をするのに、外部サービスに投げる。
crowdinもlocalazyもあんまりコマンド体系などは変わらない

crowdinに投げる場合

crowdinのCLIに従ってすすめる。

$ crowdin init

とするとボイラープレートができるので、あとはこんな具合で修正する

files: [
    {
      # ソースは抜き出したファイル
      "source": "locale/extracts/ja/*.json",
      # 翻訳後ファイルは別途のディレクトリに格納
      "translation": "locale/translations/%two_letters_code%/%original_file_name%",
    },
  ]

あとは下記のように実行コマンドを叩けば良い

# アップロード
$ crowdin upload 
# ダウンロード(同期)
$ crowdin download

localazyに投げる場合

Localazy CLIのドキュメントに従ってインストールする

localazy.jsonは下記のように設定した。

{
  "writeKey": "****",
  "readKey": "***",
  "upload": {
    "type": "json",
    "files": "locale/extracts/ja/translation.json"
  },
  "download": {
    "files": "locale/translations/${lang}/translation.json"
  }
}
# アップロード
$ localazy upload
# ダウンロード(同期)
$ localazy download

その他Tips

本題とは少しずれるが、その他小規模な案件で検討しうる項目があるので最後に追記する

Tips 1. namespaceは使わない

i18nextにはnamespaceという機能が備わっているが、少なくとも自分の手に馴染まず、便利さより複雑性の方が高くなってしまう感じがあった。
また、自動化を考えるとimport周りがえらい面倒なので、これは一切利用しないことにした。

ただしnamespaceを使わない場合、型周りを整合性とるのに、翻訳データを下記のように工夫する必要があった

import ja from "../../locales/extracts/ja/translation.json"

const resources = {
  ja: {
    // tranlationというキー名を一段作る
    translation: ja
  }
  // 下記だと動くが型エラーになってしまう
  // ja: ja
}

TypeScript対応

namespaceを利用しないと、TypeScript対応も少し煩雑さが減る。
この場合のTypeScript対応としてはこんな具合になる。

// react-i18next.d.ts

import 'react-i18next'
// import all namespaces (for the default language, only)
import resouces from './resources'
import ja from "../../../locale/extracts/ja/translation.json"

declare module 'react-i18next' {
  // translationに含まれているように型定義を拡張する
  type DefaultResources = { translation: typeof ja }
  interface Resources extends DefaultResources { }
}

i18nextでのTypeScript対応は、少々変わっていてreact-i18next自体を拡張することで行うように案内されている。
色々試したが確かにこのやり方が一番合理的な感じになっていたので、あわせておくのが良いだろう。

単にjsonから型を拾っているだけではあるので、もし上記が合わないか、別なライブラリを使っている場合であればuseCustomI18nのようなhooksを作成してラッパーを自前するでも良いだろう。

Tips 2. 開発環境の場合に抜き出し対応したPostProcessorをかませると便利

画面を見ながら作業していると、翻訳した箇所かどうかわからなくなることがある。
そういう場合は、下記記事のように開発環境から見た際に見分けがつくようにしておくと便利だった

https://zenn.dev/terrierscript/articles/2021-06-01-i18next-post-processor-marking

Tips 3. 面倒な箇所はUIから変える。

あんまり複雑なi18nをしたくない場合、ある程度割り切りをしたほうが良いケースがあった。

例えば

<Box>
  お困りの場合は<ContactLink>お問い合わせ</ContactLink>ください
</Box>

のようにリンクがテキスト中に入り込んでいると、jsonとしての抽出も面倒で、結構面倒なことがある。
もちろん各i18nライブラリはこれらのケースにも対応はしているが、小規模な環境であまりこういう箇所にコストをかけたくないことがある。

このような場合、真正面から対応せず、リンクを外に出してしまうなどを検討したほうがトータルコストが安くてよかった

<Box>
  <Box>{t("Contacts.Description.Label", "お困りの場合はお問い合わせください")}</Box>
  <Box><ContactLink>{t("Contacts.Link.Label", "お問い合わせ")}</ContactLink></Box>
</Box>

もちろん画面変更の余力や、組織体制によってはデザイナー・PMとの調整が必要な場合もあるだろう

もっと面倒ならコンポーネントごと分けたり消したりする

更に面倒なケースな場合は、最終手段的でもあるが日本語かどうかで判別して出し分けるという手もある。
例えばi18nextの場合はこんな感じで日本語判定が出来る。

export const useIsJp = () => {
  const { i18 } =  useTranslation()
  return i18n.language === "jp"
}

あとはこれを使ってコンポーネントを分離するなり表示しないなりという処理が出来る。

export const SomeComplexItem = () => {
  const isJp = useIsJp()
  return <>{isJp ? <SomeComplextItemJp> : <SomeComplextItemEn>}
}

日本でしか通用しない・必要のない箇所の出し分けなどに便利だ

i18next以外の環境では?

i18next以外など、もっとバニラJSな環境でやりたければ、ブラウザなら下記のようなやり方もあるだろう


// browser
export const isJp = () => navigator.language?.split("-")[0] === "ja"

更にExpoであればexpo-localizationが使える

// expo 
import * as Localization from 'expo-localization';

export const isJp = () => Localization?.locale.split("-")[0] === "ja"

この記事に贈られたバッジ