本記事は試験的に生成AIで出したものを人間の手で修正しています
個人開発のアプリで多言語対応をやる機会がここ最近続いていて、数えてみたら7本くらいになっていました。量子コンピュータの測定結果だけで運勢が決まるおみくじアプリは9言語、AI同士を討論させるアプリは8言語、マイクラのAIボットのダッシュボードも8言語。日英2言語だけのものも数本あります。
これだけ数をこなしていると、さすがに next-intl や react-i18next みたいなちゃんとしたi18nライブラリを1回くらい使ってそうなものですが、実は1回も使っていません。
毎回同じ40行くらいの自前パターンを書いて済ませています。
Record<言語コード, 辞書> みたいな形のオブジェクトと、それを読むだけのフックだけです。
なんでこれで済ませているのか、あと今の形に落ち着くまでに何回かやらかしているので、そのあたりを書いておきます。
だいたいこの形になる
量子おみくじというアプリの辞書がこれです。量子ビットの重ね合わせを1回測定して、その結果だけで運勢が決まる、乱数を一切使わないおみくじアプリです。
export const LANGS = [
{ code: "ja", label: "日本語", dir: "ltr" },
{ code: "en", label: "English", dir: "ltr" },
{ code: "ar", label: "العربية", dir: "rtl" },
// ...
] as const;
export type LangCode = (typeof LANGS)[number]["code"];
export type Messages = {
tagline: string;
observe: string;
histTitle: string; // {n}
// ...
};
export const MESSAGES: Record<LangCode, Messages> = {
ja: { tagline: "運勢は、観測するまで決まっていない。", observe: "観測する" /* ... */ },
en: { tagline: "Your fortune is not decided until you observe it.", observe: "Observe" /* ... */ },
// ...
};
これで全部です。ファイル1個、型1個、言語ごとにオブジェクトリテラルが1個並んでいるだけです。{n} みたいなプレースホルダーは、ちゃんとしたICU MessageFormatパーサーとかではなく、自前の fmt() 関数で置換しているだけです。ルーティングも増えないし、ビルド時の文字列抽出も、翻訳キーの漏れをチェックするLintも要りません。
それでも漏れには気づくことができます。
全言語のエントリが同じ Messages 型を満たさないといけないので、新しい言語を足したときに1個訳し忘れると、その場でTypeScriptがエラーを出してくれます。
9言語×数十個の文言くらいの規模だと、これが地味に効きます。
翻訳作業自体も、思ったほど重くありませんでした。「日本語と英語だけ自分で書いて、その2言語分と Messages 型をClaudeに渡して、残りの言語を埋めてもらう」というのをずっとやっています。9言語分を1人で手打ちすることを考えると、これだけでもだいぶ気が楽になります。出来上がった訳文も、個人開発のアプリとして出す分には十分な品質だと思っています。
1回目のやらかし:リロードすると言語を忘れる
最初に作った2本、AI討論バトルとAI文章判定アプリでは、言語の状態をただの useState で持っていました。
気づいたのは公開してしばらく経ってから、自分で普通に使っていたときです。英語に切り替えて討論を眺めたあと、次の対戦を見ようとリロードしたら、いつの間にか日本語に戻っていました。実装したときは「言語切り替えボタンが動けばOK」くらいの気持ちだったので、そのボタンを押した状態がページをまたいで残る前提がすっぽり抜けていました。
2回目でようやく気づいた:useStateじゃなくてuseSyncExternalStore
素直な直し方は localStorage に保存することだと思って、最初はそのまま useState の初期値を localStorage.getItem にするだけの直し方をしていました。ただ、ここにもう一段落とし穴がありました。レンダリング中に localStorage を直接読むと、サーバーサイドレンダリングとかみ合わなくなります。サーバー側には window がないので、サーバーは1つの見た目でレンダリングして、クライアントは別の見た目でハイドレーションしようとして、Reactがハイドレーションの不一致を警告してきます。
これを最終的に落ち着けた形が、AI驚き屋発見器というアプリのこのコードです。
function getLocaleSnapshot(): Locale {
if (memoryLocale) return memoryLocale;
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
if (stored && stored in DICTIONARIES) return stored as Locale;
} catch { /* ignore */ }
const nav = (typeof navigator !== "undefined" ? navigator.language : "")
.slice(0, 2).toLowerCase();
if (nav && nav in DICTIONARIES) return nav as Locale;
return DEFAULT_LOCALE;
}
const locale = useSyncExternalStore(subscribe, getLocaleSnapshot, () => DEFAULT_LOCALE);
useSyncExternalStore の3つ目の引数がサーバー側のスナップショットで、常にデフォルト言語を返すようにしてあります。
これでサーバーと最初のクライアントレンダリングの見た目が一致します。
ハイドレーションが終わった直後に、Reactが getLocaleSnapshot() の実際の値で再レンダリングしてくれる、という流れです。
保存済みの言語があればそれ、なければ navigator.language から推測した言語です。
今のところ変な言語がチラつくこともないし、コンソールに警告も出ていません。
新しいプロジェクトを始めるときは、だいたいこのコードをコピーしてくるところから始めるようになりました。
アラビア語だけ勝手が違った
2本のアプリでアラビア語対応をしているんですが、最初にアラビア語を足したときは、文字列を訳してそのまま出しただけでした。その状態で実際にブラウザでアラビア語表示に切り替えて見てみたら、ボタンの並びが変でした。アイコンとラベルが横に並んでいるだけのボタンなんですが、アラビア語だとアイコンがラベルの右側に来ないと、文字自体は合っているのにレイアウト全体が逆さまに見えてしまいます。文章のブロックはそれっぽく見えていたので油断していましたが、ボタン1個で一気に「ああ、これは文字を訳しただけじゃダメなやつだ」と気づかされました。直し方自体は2行で済みます。
export const RTL_LANGS: Lang[] = ["ar"];
// コンポーネント側
const isRtl = RTL_LANGS.includes(lang);
これに dir={isRtl ? "rtl" : "ltr"} をルート要素に渡してあげると、flexboxの並び順が自動でひっくり返ってくれます。
直すこと自体は2行で済むくらい安いんですが、自分でアラビア語表示を見なかったら最後まで気づかずに出していたと思います。
アラビア語が読めるわけではないので、文字が正しいかどうかまでは判断できないんですが、レイアウトの左右がおかしいことくらいは見ればわかりました。
じゃあライブラリはいらないのか
ここまで書いておいてなんですが、next-intl や react-i18next が要らないと言いたいわけではありません。
言語によって複数形のルールが違う話(英語の単数・複数の感覚がそのまま通じない言語がある)とか、サーバーサイドでの言語ルーティング、文字列抽出のツールチェーン、翻訳チームとのやり取りのワークフローとか、あのへんのライブラリが強いところに、今回の自前パターンはそもそも手を出していません。
個人開発で、翻訳チームもいなければ /en/ と /ja/ でURLを分けるSEO要件もない状況だと、そのどれも出番がありません。
自分が欲しいのは「文言を差し替える、選んだ言語を覚えておく、アラビア語で崩れない」の3つくらいで、型付きのオブジェクト1個のほうが、たいていのi18nライブラリの設定ファイルより少ないコード量で済んでいます。
もちろんタダではなくて、複数形の処理は histTitle: (n) => ... みたいな文言ごとの場当たり的な関数で済ませているので、CLDRの複数形ルールにきちんと準拠しているわけではありません。もしどれかのプロジェクトで、大勢が同時に翻訳を進める体制が急に必要になったら、自前のオブジェクトリテラルと格闘するより素直にライブラリを入れると思います。今のところ、1人で開発してClaudeに訳文を埋めてもらう規模を超えたことがないので、その日が来るかどうかは分かりません。
おわりに
書いていて改めて思ったのは、Record<言語コード, 辞書> というだけのオブジェクトとフック1個で、案外ここまで持つんだな、ということです。凝ったi18n基盤を組む余裕が個人開発には無いので、いつも最短距離で済ませているだけなんですが、それでも9言語・RTL込みでちゃんと動いているのを見ると、意外と侮れないなと思います。
localStorage を絡めるなら useState じゃなくて useSyncExternalStore にしておくこと、RTL対応は文字列を訳すだけじゃなくレイアウトの向きまで見ること、この2つは次にどこかで多言語対応をやるときも、たぶんまた同じところでつまずかないように覚えておこうと思います。
最後まで読んでいただきありがとうございました。