🌐

Lingui で実現するモダンな多言語対応

2025/02/06に公開
1

はじめに 🚩

よくある i18n ライブラリでは翻訳辞書を用意し、翻訳キーを元に翻訳対応を行いますが、Lingui では、コードベース内で実際の文言を直接記述し、それを翻訳対象として抽出できます。
つまり、先に翻訳辞書を用意するか、先にコードベース内で文言を記述するかといった違いがあります。

後者のこの特徴により、翻訳キーの管理が不要になるといったメリットがあります。
さらに翻訳作業を効率化、自動化できる機能も提供されており、翻訳作業のコストを削減できると感じます。

この記事では、このような特徴を持つ JavaScript 向けの国際化(i18n)ライブラリ Lingui を使用して、効率的な多言語対応を実装する方法を紹介します。

翻訳までの流れ 🔄

以下に、基本的なワークフローを説明します:

  1. メッセージの定義

    • コードベース内で Lingui のコンポーネントを使用してメッセージを直接記述
    <Trans>こんにちは、世界</Trans>
    
  2. メッセージの抽出

    • Lingui CLI を使用して翻訳辞書を生成
    lingui extract
    

    また CLI を実行すると、翻訳状況についてターミナル上で確認できます。

    0

  3. 翻訳作業

    • CLI で生成された翻訳ファイルで翻訳を行う
    en.json
    {
      "LECoio": { // ← 自動生成されたランダムな文字列
        "message": "こんにちは、世界",
        "placeholders": {},
        "origin": [["app/[lang]/page.tsx", 12]],
        "translation": "" // ←ここで翻訳を行う
      }
    }
    
  4. コンパイル

    • Lingui CLI でメッセージカタログをアプリケーションで使用可能な形式にコンパイル
    • 翻訳バンドルのサイズを最適化
    lingui compile
    
  5. デプロイ

    • コンパイルされたメッセージカタログを本番ビルドに含める
    • ユーザーの言語設定に基づいてローカライズされたコンテンツを提供

このように(基本的に)既存のテキストを <Trans> コンポーネントでラップするだけで翻訳対象として抽出できるため、特に開発が進んだプロジェクトで新たに多言語対応を導入する場合に便利に感じます。

このワークフローにより、開発者は翻訳管理を効率的に行うことができ、かつエンドユーザーには最適化された形で多言語コンテンツを提供することが可能です。

実装サンプル 📝

簡単に日本語と英語の 2 言語対応を実現したサンプルを以下に示します。

1-1
ja 表示

1-2
en 表示

主なサンプルコードは以下です。

app/page.tsx
app/page.tsx
import { ClientSample } from '@/components/client-sample';
import { initLingui } from '@/components/initLingui';
import { Trans } from '@lingui/react/macro';

export default async function Page({
  params,
}: {
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  initLingui(lang);

  return (
    <div className='p-4 space-y-2'>
      <p>
        <Trans>サーバーサイド</Trans>
      </p>
      <ClientSample />
    </div>
  );
}
components/client-sample.tsx
components/client-sample.tsx
'use client';

import { Trans } from '@lingui/react/macro';
import { useLingui } from '@lingui/react';
import { Label, LabelStatus } from './label';
import Link from 'next/link';

export function ClientSample() {
  const { i18n } = useLingui();

  const currentDate = new Date();
  const amount = 1234.56;
  const name = 'John';

  return (
    <div className='flex flex-col gap-1 border border-gray-300 p-4'>
      <p>
        <Trans>クライアントサイド</Trans>
      </p>
      <p>
        <Trans>こんにちわ {name}</Trans>
      </p>
      <p>
        <Trans>
          詳しくは
          <Link className='text-blue-500 underline' href='/help'>
            ヘルプページ
          </Link>
          をご覧ください
        </Trans>
      </p>
      <input type='text' placeholder={i18n._('入力してください')} />
      <p>
        <Trans>今日の日付: {i18n.date(currentDate)}</Trans>
      </p>
      <p>
        <Trans>
          金額: {i18n.number(amount, { style: 'currency', currency: 'JPY' })}
        </Trans>
      </p>
      <Label status={LabelStatus.InProgress} />
      <Label status={LabelStatus.NotStarted} />
      <Label status={LabelStatus.Completed} />
    </div>
  );
}

前準備

基本的には公式から以下の Example が提供されているのでそちらを参考にしてください。
https://github.com/lingui/js-lingui/tree/main/examples/nextjs-swc

一点注意点として、上記サンプルに加えて翻訳辞書をどのファイルで管理するかの設定が必要です。
ただ、こちらもドキュメントが用意されているので詰まることはないと思いますが、
https://lingui.dev/ref/catalog-formats

簡単に翻訳辞書を JSON ファイルで管理する場合について説明します。

@lingui/format-json をインストールして、lingui.config.ts で以下のように設定追加します。

lingui.config.ts
import { defineConfig } from '@lingui/conf';
+ import { formatter } from '@lingui/format-json';
import { locales, defaultLocale } from './config/locales';

export default defineConfig({
  locales: [...locales],
  pseudoLocale: 'pseudo',
  sourceLocale: defaultLocale,
  fallbackLocales: {
    default: defaultLocale,
  },
  catalogs: [
    {
      path: 'locales/{locale}',
      include: ['app/', 'components/'],
    },
  ],
+  format: formatter({ style: 'lingui' }),
});

さらにexamples/nextjs-swc/src/appRouterI18n.tsの loadCatalog の処理を PO ファイルではなく JSON ファイルで管理するようにします。

appRouterI18n.ts
async function loadCatalog(locale: SupportedLocales): Promise<{
  [k: string]: Messages
}> {
-  const { messages } = await import(`./locales/${locale}.po`)
+  const { messages } = await import(`./locales/${locale}.json`)
  return {
    [locale]: messages
  }
}

これで CLI で翻訳ファイルを生成すると、locales/{locale}.json に翻訳ファイルが生成されるようになります。

前準備は以上で、Lingui がサポートしているコンポーネントや機能別に実装方法を紹介します。

Trans コンポーネント

Trans コンポーネントは、静的なメッセージ、変数を含むメッセージ、インラインマークアップを含むメッセージの翻訳に使用される最も基本的なコンポーネントです。

基本的な使い方

import { Trans } from "@lingui/react/macro";

const name = "John";

return (
  <>
    <p>
      <Trans>クライアントサイド</Trans>
    </p>
    <p>
      <Trans>こんにちわ {name}</Trans>
    </p>
    <p>
      <Trans>
        詳しくは
        <Link className="text-blue-500 underline" href="/help">
          ヘルプページ
        </Link>
        をご覧ください
      </Trans>
    </p>
  </>
);

また、Trans コンポーネントによってなにか HTML タグとして出力されるわけではありません。
(他提供されているコンポーネントも同様です)

2

主なプロパティ

プロパティ名 説明
id string カスタムメッセージ ID
comment string 翻訳者向けのコメント
context string 同じメッセージを異なる ID で抽出するために使用
render func カスタムレンダリング用のコールバック関数

プロパティの使用例

  1. カスタム ID の指定
<Trans id="message.save_success">保存が完了しました</Trans>
  1. 翻訳者へのコメント付与
<Trans comment="ホームページのメインメッセージ">ようこそ、{appName}</Trans>
  1. コンテキストを使用した同一メッセージの区別
// 「記事」を投稿しました
<Trans context="article">投稿しました</Trans>

// 「コメント」を投稿しました
<Trans context="comment">投稿しました</Trans>

useLingui フック(日付や数値のフォーマット)

useLingui フックは、React コンポーネント内で i18n インスタンスと翻訳機能にアクセスするための便利なフックです。

基本的な使い方

import { useLingui } from "@lingui/react/macro";

const { i18n } = useLingui();

const currentDate = new Date();
const amount = 1234.56;

return (
  <>
    <input type="text" placeholder={i18n._("入力してください")} />
    <p>
      <Trans>今日の日付: {i18n.date(currentDate)}</Trans>
    </p>
    <p>
      <Trans>
        金額: {i18n.number(amount, { style: "currency", currency: "JPY" })}
      </Trans>
    </p>
  </>
);

i18n.date()i18n.number()は内部でIntlオブジェクトを使用しており、各言語に適した形式で自動的にフォーマットされます。

詳しくは公式ドキュメントを参照してください。
https://lingui.dev/tutorials/react#dates-and-numbers

Select コンポーネント

Select コンポーネントは、値に基づいて異なるメッセージを表示するために使用されます。variant による切り替えなど、複数の選択肢から適切な文言を選択する場合に便利です。

基本的な使い方

import { Select } from "@lingui/react/macro";

type Props = {
  status: LabelStatus;
};

export const Label = ({ status }: Props) => {
  return (
    <span
      className={clsx(
        "inline-flex w-fit items-center px-2.5 py-0.5 rounded-full text-xs font-medium",
        {
          [LabelStatus.NotStarted]: "bg-gray-100 text-gray-700",
          [LabelStatus.InProgress]: "bg-blue-100 text-blue-700",
          [LabelStatus.Completed]: "bg-green-100 text-green-700",
        }[status]
      )}
    >
      <Select
        value={status}
        _not_started="未着手"
        _in_progress="進行中"
        _completed="完了"
        other="未定"
      />
    </span>
  );
};

主なプロパティ

プロパティ名 説明
value string (必須) 選択の基準となる値
other string (必須) デフォルトの catch-all オプション
_<case> string 特定のケースに対応するメッセージで、スネークケースを使用する必要があります

その他のプロパティ(idcommentcontextrender)は Trans コンポーネントと同様です。

他にも以下のようなコンポーネントが提供されています。

  • Plural: 複数形を扱うためのマクロで、言語によって異なる複数形のルールに対応するためのコンポーネント
  • SelectOrdinal: 序数(1st, 2nd, 3rd など)を扱うためのコンポーネント

詳細は公式ドキュメントを参照してください。
https://lingui.dev/ref/macro

コンポーネント外での翻訳

コンポーネント外(例:Zod のバリデーションスキーマ)で翻訳が必要な場合は、@lingui/react/macroではなく@lingui/core/macroを使用します。

import { z } from "zod";
import { t } from "@lingui/core/macro";

const userSchema = z.object({
  username: z.string().min(3, t`ユーザー名は3文字以上で入力してください`),
  email: z.string().email(t`メールアドレスの形式が正しくありません`),
});

このように、React コンポーネント外でtマクロを使用することで、翻訳に対応したエラーメッセージを定義できます。

Server Component での翻訳

Lingui v4.10.0 以降では、React Server Components(RSC)のサポートが追加されました。

Server Component でも基本的には上記のコンポーネントを使用すれば問題ないですが、一点だけ注意点があります。
それは Locale の初期化が必要なことです。

具体的なコードは公式のサンプルにあります。

https://github.com/lingui/js-lingui/blob/9c50b4877ca8b134d0d96c09a8055221ca70b095/examples/nextjs-swc/src/initLingui.tsx#L1-L12

説明をいれると

initLingui は主に以下の 2 つの役割を果たしています:

  1. i18n インスタンスの取得

    • getI18nInstance(lang)を使用して、指定された言語に対応する i18n インスタンスを取得
    • このインスタンスには、その言語用の翻訳メッセージが既にロードされている

https://github.com/lingui/js-lingui/blob/9c50b4877ca8b134d0d96c09a8055221ca70b095/examples/nextjs-swc/src/appRouterI18n.ts#L39-L44

  1. Server Component 用のセットアップ
    • setI18n(i18n)を使用して、取得した i18n インスタンスを RSC で使用できるようにする
    • これにより、サーバーサイドでの翻訳機能が有効になる

page.tsx (Server Component) のコードは以下です。

パラメータから locale を取得して、initLingui に渡し呼び出すことで、翻訳機能が有効になります。

page.tsx
import { ClientSample } from '@/components/client-sample';
import { initLingui } from '@/components/initLingui';
import { Trans } from '@lingui/react/macro';

export default async function Page({
  params,
}: {
  params: Promise<{ lang: string }>;
}) {
  const { lang } = await params;
  initLingui(lang);

  return (
    <>
      <p>
        <Trans>サーバーサイド</Trans>
      </p>
      <ClientSample />
    </>
  );
}

ここまで紹介したコンポーネントやフックは@lingui/react/macroなど、macroを使用しています。
macro を使用するメリットなどは記述の違いなどがありますが、詳細は以下に示します。

macro と macro なし の違いについて
// マクロ版 (@lingui/react/macro)
<Trans>Hello {name}</Trans>

// 通常版 (@lingui/react)
<Trans
  id="msg.hello"
  message="Hello {name}"
  values={{ name }}
/>


// マクロ版 (@lingui/core/macro)
t`Hello ${name}`

// 通常版 (@lingui/core)
i18n._({
  id: "msg.hello",
  message: "Hello {name}",
  values: { name }
})

macro を使用すると:

  • コンパイル時に ICU MessageFormat 形式に変換される
  • メッセージ ID は自動生成される
  • TypeScript による型チェックが可能
  • 不要なデータ(コメントやデフォルトメッセージ)は本番ビルドから削除される
  • より直感的な API

一方、通常版では:

  • ICU MessageFormat 構文を直接書く必要がある
  • メッセージ ID を手動で管理する必要がある
  • より低レベルな API を提供

CLI について

package.json に以下のようなスクリプトを追加すると良いと思います。

{
  "scripts": {
    "extract": "lingui extract --clean",
    "extract:watch": "lingui extract --watch",
    "compile": "lingui compile --typescript"
  }
}

各コマンドの説明:

  • extract: ソースファイルからメッセージを抽出し、言語ごとのメッセージカタログを生成します

    • --clean: 使用されなくなった翻訳を削除します
    • --watch: 開発時に便利な監視モードを有効にします
    • 現時点(2025/02)では、上記この2つのオプションを併用することはできないようです
      • そのため、開発中は--watchオプションを使用して、ファイルの変更を監視して
      • コミット前に--cleanオプションを使用して整理しておくのが良いと思います
  • compile: 翻訳済みのカタログをアプリケーションで使用可能な形式にコンパイルします

    • --typescript: TypeScript 型定義付きでコンパイルします

また Lingui のドキュメント(https://lingui.dev/ref/cli)にはコンパイルされたカタログファイルを Git で管理する必要がなく、ビルドプロセス内で生成するのが望ましいと記載されています。

ソースとなる翻訳ファイルやメッセージだけをバージョン管理し、コンパイルされた成果物はビルド時に生成することで、管理がシンプルになり、トラブルのリスクも減らすことができます。

locales/**/*.ts

テストについて

簡単にテストについても触れておきます。

テストの効率やメンテナンス性を向上させるために、test/utils ディレクトリなどにカスタムレンダー関数を用意しておくことをおすすめします。

これにより、テスト実行時にアプリケーション全体で利用している多言語対応の設定(i18n)の初期化処理を共通化でき、各テストケースでの記述量を減らすことができます。

import { render, RenderOptions } from "@testing-library/react";
import { I18nProvider } from "@lingui/react";
import { setupI18n } from "@lingui/core";
import { messages } from "@/locales/ja";
import { messages as enMessages } from "@/locales/en";
import { ReactElement } from "react";

const AllTheProviders = ({ children }: { children: React.ReactNode }) => {
  const i18n = setupI18n({
    locale: "ja",
    messages: {
      ja: messages,
      en: enMessages,
    },
  });

  return <I18nProvider i18n={i18n}>{children}</I18nProvider>;
};

const customRender = (
  ui: ReactElement,
  options?: Omit<RenderOptions, "wrapper">
) => render(ui, { wrapper: AllTheProviders, ...options });

export * from "@testing-library/react";
export { customRender as render };

実際のテストコードでは、react-testing-library の render ではなく、カスタムレンダーを使用します。

import { render } from "@/test/utils";

describe("言語切り替え", () => {
  it("日本語から英語に切り替わると、文言が英語に切り替わる", async () => {
    const { i18n, getByTestId } = render(<Home />);

    expect(i18n.locale).toBe("ja");
    expect(getByTestId("all-orders")).toHaveTextContent("すべて見る");

    act(() => {
      i18n.activate("en");
    });

    expect(i18n.locale).toBe("en");
    expect(getByTestId("all-orders")).toHaveTextContent("View All");
  });
});

このように共通のカスタムレンダーを用意しておくと、テストの時に実際のアプリと同じ i18n 環境を再現できるので、国際化対応のテストが簡単にできます。

所感

また個人的にあったらいいなと思う機能についても簡単に書き残しておきます。

ダイナミックルートでのナビゲーション対応について

現在、Next.js のダイナミックルートを使用する際、Link(a)タグの href 属性に毎回 locale を指定する必要があり、開発の手間となっています。

理想としては、遷移時に locale を自動的に取得して適用できる仕組みがあると良いと考えています。現状では、これを実現するために独自の Link コンポーネントを作成し、ESLint ルールで制御するなど考慮が必要で、できれば標準でサポートされたらいいなと思っています。
(既に対応されているなど、ご存知の方がいらしたら教えていただけると幸いです 🙏)

参考として、next-intl ではこのようなナビゲーション周りのサポートが標準で提供されています。
https://next-intl.dev/docs/routing/navigation

注意点 🚨

SWC プラグインのバージョン制約について

Lingui の最新バージョンは v5 ですが、Next.js のバージョンに応じて Lingui のバージョンを変更する必要があります。

Next.js v14.2 系を使用したプロジェクトでハマりましたが、調査の結果、以下の理由から v4 を使用することにしました。

前提として Next.js v12 以降のデフォルトコンパイラーである SWC には、swc_core という中核ライブラリが使われています。

Next.js 14.2 系では swc_core のバージョンを @0.90.0 - 0.90.37 の範囲に揃える必要があり、この swc_core のバージョンに対応するためには @lingui/swc-plugin のバージョンを 4.0.7 ~ 4.0.8 にする必要があります。

3
SWC Plugins: https://plugins.swc.rs/versions/range/12

そして @lingui/swc-plugin のバージョンと 他 lingui パッケージのメインバージョンは揃える必要があります。

Version 0.1.0 ~ 0._ compatible with @lingui/core@3._
Version 4._ compatible with @lingui/core@4._
Version 5._ compatible with @lingui/core@5._

参考: https://github.com/lingui/swc-plugin?tab=readme-ov-file#compatibility

なお、v4 と v5 ではインポートするパッケージや記述内容が若干異なるため、注意が必要です。

移行ガイドも用意されているので、アップグレードの際はそちらを参照すると良いです。
https://lingui.dev/releases/migration-5

まとめ 📌

この記事では、Lingui を活用した多言語対応の実装方法を紹介しました。

Lingui の最大の特徴は、翻訳キーを考えたり管理したりする必要がなく、コードベース内で実際の文言を直接記述できる点です。これにより、開発者は実装に集中でき、翻訳管理の煩わしさから解放されます。

また、React Server Components のサポートや日付・数値のフォーマット機能など、モダンな開発に必要な機能も備えています。
さらに、macro を活用することで ICU MessageFormat を意識せずに開発でき、コンパイル時の最適化によってバンドルサイズを抑えることができます。

特に既存のプロジェクトに多言語対応を導入する場合、(一部、Select などのコンポーネントを除きますが)既存の文言をそのまま翻訳対象として抽出できるため、スムーズな導入が可能です。

以上です!

chot Inc. tech blog

Discussion

温州の葉温州の葉

開発体験という点では、linguiはi18nようなライブライより開発者にとって心地が良いです