🍵

Reactを使ったアプリケーションの多言語対応の実装はこれに落ち着いた

2025/01/23に公開

以下のことを意識して実装しました。

  • 他のライブラリに差し替えられること
  • keyは主言語で書くこと
  • 翻訳情報がない場合コンパイルエラーになること

他のライブラリに差し替えられること

今の実装ではreact-i18nを使用していますが、今後他のライブラリに差し替えることも可能性としてはあるので、最初から設計に入れておくことで何かあったときに修正箇所が1箇所で済むようにします。

実装は以下になります。

初期化コード(ここは特に解説しません):

import i18n from "i18next"
import LanguageDetector from "i18next-browser-languagedetector"
import { initReactI18next } from "react-i18next"
import enJsonData from "~/infrastructure/i18n/locales/en.json"
import jaJsonData from "~/infrastructure/i18n/locales/ja.json"

const DEFAULT_LANGUAGE = navigator.language.startsWith("ja") ? "ja" : "en"

export function initI18n() {
  const detector = new LanguageDetector(null, {
    order: ["localStorage", "querystring", "cookie", "navigator", "htmlTag"],
    htmlTag: typeof document !== "undefined" ? document.documentElement : null
  })

  i18n
    .use(initReactI18next)
    .use(detector)
    .init({
      fallbackLng: DEFAULT_LANGUAGE,
      debug: process.env.NODE_ENV !== "production",
      interpolation: {
        escapeValue: false
      },
      resources: {
        en: {
          translation: enJsonData
        },
        ja: {
          translation: jaJsonData
        }
      },
      react: {
        transKeepBasicHtmlNodesFor: ["br", "strong", "i", "span"]
      }
    })
    .catch((error) => {
      console.error("i18n initialization failed:", error)
    })
}

hooksのコード:
useTranslateのような翻訳に関するhooksを一つ作っておき、そこに全て集約させることで仮に他のライブラリになってもtranslate関数のInterfaceさえ同じであれば変更箇所をuseTranslateの一箇所にすることができます。

import { useTranslation } from "react-i18next"
import type enJson from "~/infrastructure/i18n/locales/en.json"

export type Translate = (key: keyof typeof enJson, variables?: { [k: string]: any } | undefined) => string

export const useTranslate = () => {
  const { t } = useTranslation()

  const translate: Translate = (key, variables) => {
    return t(key, variables)
  }

  return { translate }
}

使用方法:

const { translate } = useTranslate()
const hoge = () => {
  console.log(translate("テスト"))
}

また、Translate型のように自分で型を定義しているので、i18n.d.tsなどに以下のようなコードを追加する必要もありません。型の補完をd.tsで書いてしまうと他のパッケージに差し替えた際にこのコードを削除する必要がありますが、Translate型を自分で用意しておけばその必要もありません。

// Translateの型を自分で定義しているので不要

import "i18next";
import jsJsonData from "locales/ja.json";

declare module "i18next" {
  interface CustomTypeOptions {
    defaultNS: "translation";
    resources: {
      translation: typeof jsJsonData
    };
  }
}

keyは主言語で書くこと

個人開発の当初は翻訳データのjson keyを日本語ではなく、格好つけてtranslate(”hoge.fuga”) とかにしていましたが、これはかなり認知負荷が高いです。小さいアプリケーションであっても正直きつかったです。また、

translate(”テスト”)の用に日本語で書くことの課題感としては、英語ではMembersのように複数形がありますが、日本語ではメンバーは複数でも単数でも同じ意味合いになる場合です。

この場合は、複数形のメンバーをメンバー一覧などすることになりますが、画面上メンバー一覧と表示するのは冗長だったりします。そのためうまくtranslate(”メンバー(s)”) のようにするしかありませんでした。多少のデメリットはありますが、開発時の認知負荷に比べると瑣末なことかなとしてこの決まりにしました。

翻訳情報がない場合コンパイルエラーになること

翻訳ファイルに追加していないkeyがある場合にコンパイルエラーになるのが理想でした。しかも可能な限りシンプルな方法で制約はきつめにしたかったです。

実装ポイントは上記でも書きましたがTranslateの部分です。

import type enJson from "~/infrastructure/i18n/locales/en.json"

export type Translate = (key: keyof typeof enJson, variables?: { [k: string]: any } | undefined) => string

export const useTranslate = () => {
	...
  const translate: Translate = (key, variables) => {
    return t(key, variables)
  }
  ...
}

翻訳データのkeyを型情報にして、その型をuseTranslateのtranslateのinterfaceとして使用しています。このようにするとtranslate使用時にjsonのkeyが型チェックされます。

また、コンパイルで落ちるようにするためにtsconfig.jsonに以下を設定してください

{
  "compilerOptions": {
    "strict": true, // 厳密な型チェックを有効化
    "noEmitOnError": true // 型エラーがある場合、出力を防止
  }
}

このようにすることでtranslate(”テスト”)のテストが翻訳データにない場合に、pnpm buildなどのタイミングでエラーになります。

おまけ:

可能な限りシンプルに保ちたかったので翻訳パッケージ以外のものは使いたくありませんでした。

react-i18nのお供として使われるようなi18next-extractはコンポーネントから多言語対応している箇所を自動で抽出してjson ファイルに入れてくれます。しかし、上記で書いたように抽象度を高めた設計では、*.tsxから翻訳コードを抜き出すようなことは難しいです。またコンパイルで落ちてくれれば実装は漏れることはないと思ったので入れていません。

Discussion