ローカルファーストな家計管理アプリケーションであるActual Budgetは、世界中のユーザーが利用できるよう、堅牢な国際化(i18n)およびローカル化(l10n)の仕組みを備えています。本記事では、その技術スタックの中心となるi18nextの統合方法から、動的なリソース管理、自動化された翻訳ワークフローまでを詳しく解説します。
技術スタック:i18nextによる基盤構築
Actual Budgetでは、JavaScriptエコシステムで標準的なi18nextを採用し、Reactとの連携にはreact-i18nextを使用しています。これにより、コンポーネントレベルでの柔軟な翻訳管理が可能になっています。
主な依存ライブラリ
{
"dependencies": {
"i18next": "^23.x.x",
"react-i18next": "^14.x.x",
"i18next-resources-to-backend": "^2.x.x"
},
"devDependencies": {
"i18next-parser": "^9.x.x"
}
}
初期化プロセスと設定
アプリケーションの起動時に、言語リソースを効率的に読み込むための設定が行われます。以下のコードは、初期化処理の構成例です。
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';
import resourcesToBackend from 'i18next-resources-to-backend';
// リソースの動的読み込み関数
const fetchLocaleResource = (language: string, namespace: string) =>
import(`./locales/${language}/${namespace}.json`);
i18n
.use(initReactI18next)
.use(resourcesToBackend(fetchLocaleResource))
.init({
lng: 'en',
fallbackLng: 'en',
nsSeparator: false,
keySeparator: false,
interpolation: {
escapeValue: false
},
react: {
useSuspense: true
}
});
export default i18n;
言語リソースの動的な走査
Viteなどのビルドツールを使用している場合、import.meta.globを利用してプロジェクト内の言語ファイルを自動的に特定できます。
const localeModules = import.meta.glob('/locales/*.json');
export const supportedLocales = Object.keys(localeModules).map(filePath => {
const fileName = filePath.split('/').pop() || '';
return fileName.replace('.json', '');
});
Reactコンポーネントでの実装パターン
useTranslationフックの利用
最も一般的なテキスト置換の方法です。フックから取得したt関数を使用して、キーに対応する翻訳文を呼び出します。
import { useTranslation } from 'react-i18next';
const TransactionHeader = () => {
const { t } = useTranslation();
return (
<header>
<h1>{t('Transaction History')}</h1>
<button>{t('Add Entry')}</button>
</header>
);
};
Transコンポーネントによる複雑な構造の処理
HTML要素を含む翻訳や、変数の埋め込みが必要な場合にはTransコンポーネントが有効です。
import { Trans } from 'react-i18next';
const StatusMessage = ({ count }) => (
<p>
<Trans i18nKey="sync_status" count={count}>
現在 <strong>{{count}}</strong> 件のデータが同期待ちです。
</Trans>
</p>
);
翻訳資産の自動抽出
手動でJSONファイルを更新するのは効率が悪いため、i18next-parserを使用してソースコードから直接キーを抽出します。
// i18next-parser.config.js
module.exports = {
input: ['src/**/*.{ts,tsx}'],
output: 'src/locales/$LOCALE.json',
locales: ['en', 'ja', 'fr'],
defaultValue: (locale, _, key) => {
return locale === 'en' ? key : '';
},
keySeparator: false,
namespaceSeparator: false
};
堅牢性を高めるテスト戦略
言語切り替えが正しく動作するか、フォールバックが機能するかを検証するためのユニットテストを記述します。
import i18n from './i18n';
describe('Internationalization Logic', () => {
it('should fallback to English if the requested language is missing', async () => {
await i18n.changeLanguage('xyz'); // 存在しない言語
expect(i18n.language).toBe('en');
});
it('correctly handles regional codes', async () => {
// ブラウザの言語設定を模倣
const spy = vi.spyOn(navigator, 'language', 'get').mockReturnValue('ja-JP');
await i18n.changeLanguage(navigator.language);
expect(i18n.language).toMatch(/^ja/);
spy.mockRestore();
});
});
多言語対応のベストプラクティス
- 自然言語のキー採用:
"BUTTON_SAVE"のような抽象的なキーよりも、"Save Changes"のように原文をそのままキーにすることで、コンポーネントの可読性が向上します。 - コンテキストの保持: 文の一部を分割して翻訳するのではなく、文章全体を一つのキーとして扱うことで、言語特有の語順変更に対応しやすくなります。
- 複数形と補完:
{{count}}などのプレースホルダーを活用し、i18nextの複数形(Plurals)機能を活用します。
新規言語追加のフロー
i18next-parser.config.jsのlocales配列に新しい言語コードを追加します。- 抽出コマンドを実行し、新しいJSONファイルを生成します。
- 生成されたJSON内の空の値に対して翻訳を適用します。
- UI上のオーバーフローやフォントのレンダリングに問題がないか確認します。