Rails/Laravelのi18nをReact/Vueで使いたくてライブラリを勉強がてら作ってみた
はじめに
Rails や Laravel でバックエンドを作って、フロントは React や Vue で SPA にする構成はよくあると思います。
そういうときに翻訳ファイルをどうするか問題がありまして、バックエンドの config/locales/*.yml とフロントの翻訳ファイル、両方管理するのはしんどい。できればバックエンドのやつをそのまま使いたい。
そして、個人的にはプロジェクトのマイグレーション等で使えるように、できればそれぞれの組み合わせに互換性のあるものがほしかったのですが、既存のライブラリを調べたもののよさそうなものが無かったので、勉強がてら自分で作ってみました。
作成したものはこちら:
既存ライブラリを調べてみた
Rails + JavaScript 系
i18n-js (fnando)
Rails の YAML をエクスポートして JavaScript で使うやつ。gem と npm パッケージ両方あります。
これ自体は良さそうなんですが、React や Vue の hooks / composables がなくて、自分でラップする必要がありそうでした。
@shopify/react-i18n(deprecated)
Shopify が作った React 用の i18n ライブラリ。Rails の i18n と一貫性のある API を提供しているとのこと。
ただ Shopify 向けに最適化されていて、Laravel には対応していません。さらに、現在は deprecated になっています。
Laravel + React
laravel-react-i18n
Laravel + React 専用のライブラリ。Vite プラグインもあって、useLaravelReactI18n という hooks も用意されています。
これはかなり良さそうでした。ただ Laravel 専用なので、Rails では使えません。
Laravel + Vue
laravel-vue-i18n
Laravel + Vue3 専用のライブラリ。こちらも Vite プラグインあり、composables あり。
これも良さそうなんですが、Laravel + Vue の組み合わせ限定です。
まとめ
調べてみると、個別の組み合わせに対応したライブラリはそれぞれあります。
| 組み合わせ | ライブラリ |
|---|---|
| Rails + React | i18n-js + 自前ラップ、@shopify/react-i18n(deprecated) |
| Laravel + React | laravel-react-i18n |
| Laravel + Vue | laravel-vue-i18n |
| Rails + Vue | 見つけられませんでした(もしあったらすみません) |
ただ、Rails/Laravel 両方に対応していて、かつ React/Vue 両方で同じ API で使えるものは見つかりませんでした。
作成したライブラリの概要
bf-i18n という名前で npm に公開しました。bf は backend-friendly の略です。
特徴としては:
- Rails 形式でも Laravel 形式でも、バックエンドの翻訳ファイルをそのままフロントで使える
- React と Vue で同じ API なので、フレームワーク移行時も学習コストが低い
- hooks / composables が最初から用意されているので、自前でラップする必要がない
- モノレポ構成なので、必要なパッケージだけインストールできる
パッケージ構成
モノレポで4つに分けました。
| パッケージ | 説明 |
|---|---|
| @bf-i18n/core | コア機能 |
| @bf-i18n/react | React 用 |
| @bf-i18n/vue | Vue 用 |
| @bf-i18n/cli | CLI |
使い方
対応モード
Rails 形式でも Laravel 形式でも、どちらをベースにしていてもフロントエンドで使えるようにしました。mode で切り替えます。
Rails モード
ja:
greeting: "こんにちは、%{name}さん!"
items:
zero: "アイテムなし"
one: "1個のアイテム"
other: "%{count}個のアイテム"
Laravel モード
return [
'greeting' => 'こんにちは、:nameさん!',
'items' => '{0} アイテムなし|{1} 1個のアイテム|[2,*] :count個のアイテム',
];
基本
import { createI18n } from '@bf-i18n/core';
const i18n = createI18n({
defaultLocale: 'ja',
mode: 'rails',
translations: {
ja: {
greeting: 'こんにちは、%{name}さん!',
},
en: {
greeting: 'Hello, %{name}!',
},
},
});
i18n.t('greeting', { name: '世界' }); // "こんにちは、世界さん!"
React
import { I18nProvider, useTranslation } from '@bf-i18n/react';
function MyComponent() {
const { t, setLocale } = useTranslation();
return (
<div>
<p>{t('greeting', { name: 'React' })}</p>
<button onClick={() => setLocale('en')}>English</button>
</div>
);
}
Vue
<script setup>
import { useTranslation } from '@bf-i18n/vue';
const { t, setLocale } = useTranslation();
</script>
<template>
<p>{{ t('greeting', { name: 'Vue' }) }}</p>
<button @click="setLocale('en')">English</button>
</template>
React と Vue で API を揃えています。
ブラウザロケール検出
navigator.languages から言語を自動で取得します。
const i18n = createI18n({
defaultLocale: 'en',
detectBrowserLocale: true,
translations: { /* ... */ },
});
Missing Key トラッキング
存在しないキーを呼んだときに記録しておく機能。開発中に翻訳の追加忘れに気づけます。
i18n.t('missing.key'); // キーがないと "missing.key" がそのまま返る
if (i18n.hasMissingKeys()) {
console.warn(i18n.getMissingKeys());
}
CLI の extract
ソースコードから t('key') を抽出する機能。正規表現でやっているので、動的にキーを組み立てているケースは拾えません。
bf-i18n extract ./src --output keys.json
内部処理の流れ
core パッケージは4つのクラスで構成されています。
I18n(メイン)
├── Translator(キーの解決、フォールバック)
├── Interpolator(変数の補間)
└── Pluralizer(複数形の処理)
補間処理
Rails 形式と Laravel 形式で変数のプレースホルダーが違うので、正規表現を動的に組み立てています。
// interpolator.ts
private buildPattern(): RegExp {
const { prefix, suffix } = this.options;
if (suffix) {
// Rails形式: %{variable}
const escapedPrefix = this.escapeRegExp(prefix);
const escapedSuffix = this.escapeRegExp(suffix);
return new RegExp(`${escapedPrefix}([a-zA-Z_][a-zA-Z0-9_]*)${escapedSuffix}`, 'g');
} else {
// Laravel形式: :variable
const escapedPrefix = this.escapeRegExp(prefix);
return new RegExp(`${escapedPrefix}([a-zA-Z_][a-zA-Z0-9_]*)`, 'g');
}
}
複数形の処理
Rails は zero / one / other のキーで分ける形式、Laravel は {0} None|{1} One|[2,*] Many のパイプ区切り形式。どちらも Intl.PluralRules を使ってロケールに応じた複数形カテゴリを取得しています。
// pluralizer.ts
resolveKeyBased(
translations: Record<string, TranslationValue>,
count: number
): TranslationValue | undefined {
// count=0 の場合は zero キーを優先(Rails互換)
if (count === 0 && 'zero' in translations) {
return translations['zero'];
}
const category = this.getPluralCategory(count) as PluralCategory;
if (category in translations) {
return translations[category];
}
// フォールバック: other
if ('other' in translations) {
return translations['other'];
}
return undefined;
}
React の再レンダリング
ロケールが変わったら再レンダリングしてほしいので、useSyncExternalStore を使っています。I18n クラスの onChange で購読して、ロケールが変わったらコンポーネントが更新されます。
// hooks.ts
export function useTranslation(options: UseTranslationOptions = {}): UseTranslationReturn {
const i18n = useI18n();
const locale = useSyncExternalStore(
(callback) => i18n.onChange(callback),
() => i18n.locale,
() => i18n.locale
);
// ...
}
今後
Spring や Django の形式にも対応できたらとは思っています。ただ Rails/Laravel とだいぶ違うので、ちゃんとできるかはわかりません。
Discussion