✍️

【Next.js】Static Export(SSG)でのi18nによる文言管理の一元化

2024/07/06に公開

Next.jsでStatic Export(SSG)を使用している場合の、i18を活用した文言管理の一元化について紹介させていただきます。

記事執筆時のNext.jsのバージョンとホスティング環境は以下になります。

  • Next.js: 13.4.7
    • ルーティング方法はPages Router
  • ホスティング環境: CloudFront + S3

文言管理の一元化の必要性

文言管理の課題

ウェブサイトやアプリケーションでは、多くのテキストが散在しているため、それらを管理するのは困難です。
その結果、表記ゆれが生じることがあり、場合によってはユーザーの混乱を招いたり、一貫性の欠如からメンテナンス効率が下がる恐れがあります。

表記ゆれの例

例として、表記ゆれが起こりそうな文言を種類ごとに示していますが、表記ゆれに気が付きやすい文言と気が付きにくい文言など様々です。

表記ゆれの種類
類義語 キャンセル ⇔ 取り消し
略語 アプリケーション ⇔ アプリ
助詞の有無 入力は必須です ⇔ 入力必須です
送り仮名 お問い合わせ ⇔ お問合わせ
ひらがなと漢字 ください ⇔ 下さい

表記ゆれ防止とメンテナンスのしやすさの向上

i18nを活用して文言を一元管理することで、表記揺れを減らし、メンテナンスのしやすさを向上させます。
すべてのテキストを一元管理することで、同じ文言が異なる場所で異なる表記になることを防ぎ、メンテナンスの際に一箇所を修正するだけで済むようになります。

補足
表記ゆれを完全に防止するには文言管理方法に加えて、スタイルガイドを作成するなど、用語や表記のルールを明確にする必要があります。

i18nライブラリの設定

Next.jsには、標準でi18n設定ができる機能がついています。
そのため、next.configで設定するだけで、簡単に利用することができます。
https://nextjs.org/docs/pages/building-your-application/routing/internationalization

ですが、この機能はサーバーサイドレンダリング(SSR)を前提としているため、Static Export(SSG)では使用できません。
Static Export(SSG)では、すべてのページがビルド時に静的なHTMLとして生成されるため、サーバーサイドでの動的な処理が行えません。

そのため、SSG環境でのi18n対応には、クライアントサイドで動作するライブラリを使用する必要があり、今回は以下のライブラリを使用することにしました。

  • react-i18next
  • i18next

https://react.i18next.com/
https://www.i18next.com/

インストールと設定

  • react-i18nexti18nextをインストールし、設定ファイルを作成します。
npm install react-i18next i18next
  • プロジェクトのルートにi18next.tsを作成します。(別の章で変更を加えます)
import i18n from 'i18next';
import { initReactI18next } from 'react-i18next';

i18n.use(initReactI18next).init();

export default i18n;

i18nプロバイダ設定

  • pages/_app.tsxでi18nプロバイダを設定します。
import '../styles/globals.css';
import '../i18next'; // i18n設定ファイルをインポート

function MyApp({ Component, pageProps }) {
  return <Component {...pageProps} />;
}

export default MyApp;

VS Codeの拡張機能の設定

VS Codeで効率的にi18nを管理するために、拡張機能i18n-allyの設定をします。

// .vscode/settings.json
{
  // ロケールファイルが保存されているパス
  "i18n-ally.localesPaths": ["locales"],
  // ソース言語の指定
  "i18n-ally.sourceLanguage": "ja",
  // VS Code内で表示する言語
  "i18n-ally.displayLanguage": "ja",
  // キーをアルファベット順にソート
  "i18n-ally.sortKeys": true,
  // すべての翻訳キーが揃っていることを確認する
  "i18n-ally.keepFulfilled": true,
  // 使用しているフレームワークを指定
  "i18n-ally.enabledFrameworks": ["vscode", "react"],
  // キーのスタイルを指定
  "i18n-ally.keystyle": "nested"
}

プロジェクトに関わる全員が同じ拡張機能を使って開発できるように、recommendationsに追加して、VS Codeを開いたときに開発者に推奨されるようにします。

// .vscode/extensions.json
{
  "recommendations": ["lokalise.i18n-ally"]
}

文言管理の実装例

文言管理ファイル作成

  • locales/jaに文言管理用のJSONファイルを作成します。
// `locales/ja/common.json`
{
  "cancel": "キャンセル"
  "submit": "送信"
}

文言管理ファイルを作成するときのポイント

意図しないkeyの上書きを防ぐ
分割された文言管理ファイルを一つにまとめる際に、意図せずkeyが上書きされてしまう恐れがありますが、react-i18nextではnamespaceの設定を行えば、ファイルが分離された状態でもkeyの衝突を回避できます。(別の章で詳しく解説します)

https://react.i18next.com/guides/multiple-translation-files#separating-translation-files

keyの分け方
例えば、labelというkeyの場合、formのlabelのみで使う想定なのか、ボタンやリンクでも使う想定の共通文言としての意味を持つのかが曖昧になります。
そのため、keyの分け方についてもチームで認識を合わせておく必要がありそうです。

// labelが共通文言としての意味を持つ場合
{
 "label":{
   "email": "メールアドレス",
   "name": "ユーザー名",
   "login": "ログイン" // ボタンで使う想定
 }
}
// formのlabelとしての意味を持つ場合
{
  "label":{
    "email": "メールアドレス",
    "name": "ユーザー名",
  },
  "button": {
    "login": "ログイン"
  }
}

ファイルの分け方
例では共通文言を設定するcommon.jsonを作成しましたが、その他にもページやコンポーネントごとのファイルを作るなど、チームによってファイル分割のルールを設定しておくと管理がしやすくなります。

分割ファイルを統合する

分割された文言管理ファイルをlocales/ja/index.tsにまとめます。

import common from './common.json';

const LOCALE_JA = {
    common,
};

export default LOCALE_JA;

統合された文言管理ファイルをi18nの初期化時に渡します。
このとき、ns: ["common"]のようにnamespaceを追加できるため、ファイルが分離された状態でもkeyの衝突を防ぐことができます。

  import i18n from 'i18next';
  import { initReactI18next } from 'react-i18next';

+ import ja from './locales/ja';

+ i18n.use(initReactI18next).init({
+   resources: {
+     ja
+   },
+   lng: 'ja',
+   ns: ['common'],
+   defaultNS: 'common',
+   fallbackLng: 'ja',
+ });

  export default i18n;

具体的な使用例

通常の使用例

useTranslationから受け取ったt関数の引数にメッセージのkeyを渡すことで、jsonファイルに定義されたメッセージが返されます。

{
  "cancel": "キャンセル"  
}
import { useTranslation } from 'react-i18next';

const CancelButton = () => {
  const { t } = useTranslation();
  // <button>キャンセル</button>; 
  return <button>{t('cancel')}</button>;
};

動的な値を文言に埋め込む例

t関数の引数に値を渡すことで、動的な値をメッセージに埋め込むことができます。

{
  "greeting": "こんにちは、{{name}}さん!"  
}
import { useTranslation } from 'react-i18next';

const GreetingMessage = ({ name }) => {
  const { t } = useTranslation();
  // <p>こんにちは、〇〇さん!</p>;
  return <p>{t('greeting', { name })}</p>;
};

任意のタグを文言に埋め込む例

Trans コンポーネントを使うことで、任意のタグを埋め込むことができます。

{
  "link": {
    "detail": "詳しくは<a>xxxについて</a>をご覧ください。"
  }
}
import Link from 'next/link';
import { useTranslation, Trans } from 'react-i18next';

const DetailLink = () => {
  const { t } = useTranslation();

  return (
    // 詳しくは<a href="/detail">xxxについて</a>をご覧ください。 
    <Trans t={t} i18nKey="link.detail" components={{ a: <Link href="/detail" /> }} />
  );
};

文言管理ファイルのkeyの衝突回避

以下のような文言管理ファイルがあり、titleというkeyが重複しています。
このような場合はnamespaceを設定することにより衝突を回避できます。

文言管理ファイル

// home.json
{
  title: "ホーム"
}
// about.json
{
  title: "アバウト"
}

namespaceの設定

下記の設定ではresourcesオブジェクトのjaキーの中にcommon, home, aboutの3つのnamespaceが定義されています。
そして、nsプロパティでもこれらのnamespaceを指定しています。

import common from './locales/ja/common.json'; 
import home from './locales/ja/home.json';
import about from './locales/ja/about.json'

i18n.use(initReactI18next).init({
  resources: {
    ja: {
      common,
      home,
      about,
    }
  },
  lng: 'ja',
  ns: ['common', "home", 'about'], // namespaceを設定
  defaultNS: 'common',
  fallbackLng: 'ja',
});

export default i18n;

文言の取得方法

  1. t関数の引数でnamespaceを指定する方法
const { t } = useTranslation();
const homeTitle = t('title', { ns: "home" }) // ホーム
const aboutTitle = t('title', { ns: "about" }) // アバウト
  1. useTranslationの引数でnamespaceを指定する方法
const { t } = useTranslation('home');
const homeTitle = t('title') // ホーム

ファイル内の文言を参照する方法

$tの引数にメッセージのkeyを渡すことで、ファイル内の文言を参照することができます。

{
  "label": {
    "password": "パスワード"
  },
  "password": {
    // パスワードを忘れた場合
    "forgot": "$t(label.password)を忘れた場合"
  }
}

まとめ

文言管理を一元化することで、メンテナンス効率を高めることができ、表記ゆれについても一定以上は防止することができます。
また、文言管理はすべてJSONファイルで行われるため、フロントエンドの専門知識がなくても簡単に管理できます。
例えば、コンポーネント側の修正を必要とせず、文言のみを変更する場合は、該当するJSONファイルを直接編集するだけで済むため、フロントエンドのスキルに関係なく誰でも容易に対応可能になります。

また、パフォーマンス面に関しても、一元化前と変わらず、ビルド時に文言リソースが静的HTMLに埋め込まれているため、文言リソースのロードによるパフォーマンスへの影響も心配ないはずです。
少なくとも、大規模プロジェクトでjsonファイルが肥大化し過ぎていたり、頻繁な言語切替がない限り、パフォーマンス面は気にしなくていいのではないかと個人的には思ってます。(もし認識が間違ってたら教えて下さい)

Discussion