ストーリー
Shotomatic Team
読了まで約13分

小さな Electron i18n を保守しやすくした 6 つの設計判断

ロケール設定、型付きカタログ、フォールバック、Intl 書式、renderer と main の同期を扱う、実用的な Electron i18n 設計を紹介します。

Electron アプリを開発しながらノートパソコンでコードを入力する開発者

既存の Electron アプリに日本語を追加すると、メッセージカタログだけでは解決できない問題が出てきました。OS の言語と保存済みの設定が異なるとき、どちらを優先するのか。renderer が main process に選択内容を伝える前はどうするのか。ネイティブダイアログと React の画面で言語がずれないようにするにはどうするのか。

最初の版は意図的に小さくしました。英語と日本語に対応し、英語をフォールバックに使います。大規模な国際化フレームワークは導入せず、TypeScript と短いテストスイートで支えています。初期に決めた 6 つの方針によって、この小さな層を理解しやすく保てました。

1. ロケールだけでなく、設定値を保存する

enja のようなロケールだけでは、「macOS の設定に従う」という選択を表せません。システム言語を初期値として使うだけでも同じ問題が起きます。一度具体的なロケールを保存すると、その後 macOS の言語を変えても反映できなくなるからです。

ユーザーの選択と、実際に使うロケールを別々にモデル化しました。

type AppLocale = "en" | "ja";
type AppLocalePreference = "system" | AppLocale;

const resolveAppLocale = (preference: AppLocalePreference, languages: readonly string[]): AppLocale =>
  preference === "system" ? detectSystemLocale(languages) : preference;

navigator.languages を参照するのは system の場合だけです。手動で選んだ言語は、macOS と異なっていても常に優先します。保存値は利用前に検証し、値がない、壊れている、未対応バージョンが残した値である場合は system に戻します。

navigator.languages の順序も重要です。一覧のどこかに日本語があれば選ぶのではなく、ユーザーの優先順で最初に見つかった対応言語を選びます。対応言語がなければ英語を使います。

2 つの値を分けると、それぞれの役割が明確になります。preference はユーザーが何を選んだか、locale は現在どの言語で画面を描画するかを表します。

2. 誤って切り替えても読める言語名にする

言語メニューでは、常に次のラベルを表示します。

  • English
  • 日本語

これは各言語が自らを呼ぶ名前、つまり自称表記です。両方を現在の画面言語に翻訳すると、復旧が難しくなります。読めない言語へ切り替えた人が、元に戻すためのラベルまで読めなくなるためです。

System default は言語名ではなくアプリの動作を説明する項目なので翻訳します。通常の翻訳変更で自称表記が書き換わらないよう、両方のカタログで 2 つのラベルをテストに固定しています。

3. 英語カタログを TypeScript の契約にする

英語カタログは、すでにアプリ内文言の一覧に最も近いものでした。そこで、英語のキーをすべての対応ロケールの契約にしました。

export const enMessages = {
  "settings.language.title": "Language",
  "settings.language.english": "English",
  "settings.language.japanese": "日本語",
} as const;

export type MessageKey = keyof typeof enMessages;

const catalogs = {
  en: enMessages,
  ja: jaMessages,
} as const satisfies Record<AppLocale, Record<MessageKey, string>>;

このカタログ契約は、開発中によくある 2 つの間違いを防ぎます。コンポーネントは、タイプミスしたキーや存在しないキーを要求できません。また、英語にあるキーを別のロケールで省略できません。日本語の対応文を追加せず英語メッセージだけを増やすと、カタログ変更の近くで型チェックが失敗します。

型付きキーだけで翻訳品質は保証できませんが、構造上の完全性をコンパイラで日常的に確認できます。2 ロケールと単純なメッセージ構造を持つアプリには十分でした。

4. 実行時フォールバックと完全性チェックを両方残す

コンパイル時に完全性を確認しても、実行時フォールバックは必要です。古いローカルデータ、想定外のビルド不一致、将来の動的カタログによって値が欠ける可能性があります。周囲の画面を壊すより、英語メッセージを返すほうが安全です。

ただし、フォールバックで未完成の作業を隠さないようにしました。型チェックですべてのカタログに同じキーを要求し、テストでは代表的なメッセージ、置換値、固定ラベルを比較します。実行時の耐障害性と開発中のフィードバックは別の問題なので、両方を残しています。

補間は {count} のような単純な名前付き値に限定しています。複数形規則、文法上の性、複雑なメッセージ選択が必要になれば、ICU MessageFormat や専用ライブラリを導入する理由になります。文言が必要とする前に仕組みを追加すると、現在の問題を解かないまま、システムを読みにくくします。

5. 日付と数値はメッセージカタログの外で書式設定する

ラベルを翻訳しても、日付や数値が別の地域の慣習のままでは、画面は自然に見えません。コンポーネントは有効なアプリロケールを受け取り、対応する BCP 47 タグをプラットフォームの formatter に渡します。

const getIntlLocale = (locale: AppLocale) => (locale === "ja" ? "ja-JP" : "en-US");

const label = new Intl.DateTimeFormat(getIntlLocale(locale), {
  year: "numeric",
  month: "2-digit",
  day: "2-digit",
}).format(date);

Intl.DateTimeFormatIntl.NumberFormat をメッセージ関数の外に置くことで、カタログが汎用の書式設定層になるのを防げます。日付、時刻、ファイルサイズ、件数を描画する場所で、ロケールへの依存関係も見えるようになります。

表示用の書式と、機械が扱う出力は分ける必要があります。ローカライズした日付には、ファイル名に適さないスラッシュなどが含まれることがあります。書き出すファイル名には予測可能で安全な形式を使い、ローカライズ形式は人が読むテキストだけに使います。

6. renderer の選択を main process と同期する

Electron アプリには複数の文言表示面があります。画面の大部分は React が描画しますが、ネイティブダイアログ、撮影関連の確認、その他 OS とのやり取りは main process が担当します。renderer のカタログを完成させても、アプリ全体のローカライズが終わるわけではありません。

renderer は型付き IPC endpoint を通じて、保存済みの preference と有効な locale の両方を送ります。

type AppLocaleSyncRequest = {
  preference: "system" | "en" | "ja";
  locale: "en" | "ja";
};

await window.electron.appLocale.syncPreference({
  preference,
  locale,
});

両方を送れば、それぞれの意味を保てます。main process は有効な locale をすぐ使いながら、それが macOS 由来か手動選択かも把握できます。最初の同期までは Electron の app.getLocale() から控えめな初期値を決め、取得できなければ英語へフォールバックします。

provider の mount 時と、window が再び focus されたときに同期します。開発中は main process の reload でメモリ上の context が失われても renderer が生き続ける場合があるため、2 回目の同期も役立ちます。IPC に失敗しても renderer は止めません。次回の同期まで、ネイティブ側の文言が安全なフォールバックを使うだけです。

renderer から main への同期によって、完了の定義も変わりました。現在は React の文言、main process の文言、macOS が所有する画面文言を別々に確認します。ローカライズ済みの設定画面だけでは、renderer の外にある英語の終了確認を隠せません。

境界を守るテスト

最初のテスト一式は、翻訳済み画面をすべてスナップショットにするのではなく、状態と process の境界を重点的に確認します。

  1. ja-JP が対応するシステム設定の先頭なら日本語になる。
  2. 手動で選んだ言語がシステム言語より優先される。
  3. 保存値がない、または未対応なら安全にシステム設定へ戻る。
  4. すべてのロケールが英語カタログのキー一式を満たす。
  5. English日本語 が両方の画面で変わらない。
  6. 日付と数値が有効なアプリロケールを使う。
  7. main process が preference と有効な locale の両方を受け取る。

重要な描画状態はコンポーネントテストでも確認しますが、これらの小さなテストは動作を定義する関数の近くで多くの不具合を捉えます。毎回の変更で実行できるほど軽量です。

小さな i18n にも明確な境界が必要

この設計で重要なのはコード量ではありません。preference と locale、復旧と完全性、メッセージと書式設定、renderer と main process を分けたことです。

境界が明確なら、後から個別の要素を置き換えられます。3 つ目、4 つ目の言語では生成カタログが必要になるかもしれません。文法が複雑になれば ICU メッセージが適するかもしれません。どちらの場合も、保存済み preference の意味や、ネイティブ文言をどの process が担当するかを定義し直す必要はありません。

この i18n 層は、私たちが開発する Mac アプリ、Shotomatic の Action Captureに日本語を追加する際に実装しました。上のコード例は製品版から簡略化していますが、状態モデルと process の境界は実際のコードと同じです。

クリック操作を分かりやすい手順書にまとめる

Action Captureは、クリックを順番どおりの手順として記録します。Macで内容を編集し、ガイドを書き出せます。

小さな Electron i18n を保守しやすくした 6 つの設計判断 | ブログ | Shotomatic