チュートリアル//10 読了時間

Vue i18n をスケールさせる:多言語アプリのための Composition API パターン

Eray Gündoğmuş
共有

Vue i18n は Composition API の登場により大きく成熟しました。2020年に書かれたチュートリアルの Options API パターンをまだ踏襲しているなら、多くのメリットを活かせていません——特に型安全性、遅延読み込み、CDN 配信メッセージの面で。

これは、スケールする多言語 Vue 3 アプリケーションを構築するための実践的なガイドです。コンポーネント単位の翻訳からルートベースのロケール切り替え、Nuxt 3 との統合まで網羅します。デモだけで通用するパターンではなく、エンタープライズ規模でも通用するパターンを取り上げます。

Composition API で vue-i18n をセットアップする

Composition API モードから始めましょう。Options API モードは後方互換性のために残っていますが、大規模なコードベースを管理しやすくするコンポーザブルパターンは得られません。

npm install vue-i18n@9
// src/i18n/index.ts
import { createI18n } from 'vue-i18n'

export type MessageSchema = typeof import('./locales/en.json')

export const i18n = createI18n<[MessageSchema], 'en' | 'fr' | 'de'>({
  legacy: false, // Composition API mode
  locale: 'en',
  fallbackLocale: 'en',
  messages: {
    en: {} // loaded lazily
  }
})

legacy: false の設定が重要なステップです。これにより useI18n() と Composition API の全機能が使えるようになります。

useI18n():コアコンポーザブル

翻訳が必要なすべてのコンポーネントは useI18n() を使います。基本的なパターンは次のとおりです。

// src/components/WelcomeHeader.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t, locale } = useI18n()
</script>

<template>
  <h1>{{ t('welcome.heading') }}</h1>
  <p>{{ t('welcome.subheading', { plan: 'Pro' }) }}</p>
</template>

t() 関数はリアクティブです。locale が変わると、すべての t() 呼び出しが自動的に再評価されます。つまり、ロケール切り替えは手動の再レンダリングなしに機能します。

グローバルスコープとローカルスコープ

これはスケール時にほとんどのチームが間違えるポイントです。vue-i18n は 2 つのスコープをサポートしており、不用意に混在させると保守上の問題が生じます。

グローバルスコープ — i18n インスタンスレベルで定義したメッセージ。どこからでもアクセス可能:

// src/i18n/locales/en.json
{
  "nav.home": "Home",
  "nav.pricing": "Pricing",
  "common.save": "Save",
  "common.cancel": "Cancel"
}

ローカルスコープ — 特定のコンポーネントにスコープされたメッセージ。コンポーネント内で定義:

// src/components/BillingForm.vue
<script setup lang="ts">
import { useI18n } from 'vue-i18n'

const { t } = useI18n({
  messages: {
    en: {
      title: 'Billing Information',
      cardNumber: 'Card number',
      expiryDate: 'Expiry date',
      errors: {
        invalidCard: 'Please check your card number'
      }
    },
    fr: {
      title: 'Informations de facturation',
      cardNumber: 'Numéro de carte',
      expiryDate: "Date d'expiration",
      errors: {
        invalidCard: 'Veuillez vérifier votre numéro de carte'
      }
    }
  }
})
</script>

経験則として、ナビゲーション・共通アクション・アプリ全体に表示されるエラーメッセージはグローバルスコープに置きます。機能固有の UI テキストはローカルスコープに置き、コンポーネントと同じ場所に配置します。

ローカルスコープのメッセージはグローバル名前空間を汚染しません。また、どの翻訳がどの機能に属するかが明確になります。コンポーネントを削除すれば、その翻訳も一緒に削除されます。

TypeScript による型安全性

型安全性がなければ、i18n はランタイムエラーの問題になります。翻訳キーが欠けていても、ユーザーが本番環境で空文字列を見るまで気づけません。

vue-i18n は完全な TypeScript 連携をサポートしています。セットアップには型宣言が必要です。

// src/i18n/index.ts
import { createI18n } from 'vue-i18n'
import enMessages from './locales/en.json'

type MessageSchema = typeof enMessages

declare module 'vue-i18n' {
  export interface DefineLocaleMessage extends MessageSchema {}
}

export const i18n = createI18n<false, MessageSchema>({
  legacy: false,
  locale: 'en',
  messages: {
    en: enMessages
  }
})

これで t() は完全に型付けされます。存在しないキーを渡すと、TypeScript がコンパイル時に検出します。

const { t } = useI18n()

t('nav.home')        // OK
t('nav.homee')       // TypeScript error: Argument of type '"nav.homee"' is not assignable
t('welcome.heading') // OK — if it exists in the schema

スケール時において、これは i18n が保守の負担になるか透明な存在になるかの分かれ目です。IDE がキーをオートコンプリートし、タイポはビルド時に失敗します——本番ではなく。

ロケールの遅延読み込み

すべてのロケールを最初から読み込むのは小規模なアプリなら問題ありません。しかし、20 以上の言語と数千のキーを持つエンタープライズアプリでは現実的ではありません。解決策は遅延読み込みです。アクティブなロケールをオンデマンドで読み込みます。

// src/i18n/loader.ts
import { i18n } from './index'

const loadedLocales = new Set<string>(['en']) // en loaded at init

export async function loadLocale(locale: string): Promise<void> {
  if (loadedLocales.has(locale)) return

  const messages = await import(`./locales/${locale}.json`)
  i18n.global.setLocaleMessage(locale, messages.default)
  loadedLocales.add(locale)
}

export async function setLocale(locale: string): Promise<void> {
  await loadLocale(locale)
  i18n.global.locale.value = locale as any
  document.documentElement.setAttribute('lang', locale)
}

これをロケール切り替えコンポーネントで使います:

// src/components/LocaleSwitcher.vue
<script setup lang="ts">
import { setLocale } from '@/i18n/loader'

const locales = [
  { code: 'en', label: 'English' },
  { code: 'fr', label: 'Français' },
  { code: 'de', label: 'Deutsch' },
]

async function handleChange(code: string) {
  await setLocale(code)
}
</script>

<template>
  <select @change="e => handleChange((e.target as HTMLSelectElement).value)">
    <option v-for="l in locales" :key="l.code" :value="l.code">
      {{ l.label }}
    </option>
  </select>
</template>

各ロケールバンドルは独立したチャンクになります。ユーザーは使用する言語のみをダウンロードします。

ルートベースのロケール切り替え

SEO が重要なアプリでは、ロケールを URL に含めるべきです——/en/pricing/fr/tarifs のように。このアプローチにより、各ロケールに独自のインデックス可能な URL が与えられ、検索エンジンがサイトの言語構造を把握できます。

// src/router/index.ts
import { createRouter, createWebHistory } from 'vue-router'
import { setLocale } from '@/i18n/loader'

const SUPPORTED_LOCALES = ['en', 'fr', 'de']

const router = createRouter({
  history: createWebHistory(),
  routes: [
    {
      path: '/:locale(en|fr|de)',
      component: () => import('@/layouts/LocaleLayout.vue'),
      children: [
        { path: '', component: () => import('@/pages/Home.vue') },
        { path: 'pricing', component: () => import('@/pages/Pricing.vue') },
        { path: 'features', component: () => import('@/pages/Features.vue') },
      ]
    },
    {
      path: '/:pathMatch(.*)*',
      redirect: to => `/en/${to.params.pathMatch}`
    }
  ]
})

router.beforeEach(async (to) => {
  const locale = to.params.locale as string
  if (SUPPORTED_LOCALES.includes(locale)) {
    await setLocale(locale)
  }
})

export default router

ロケールレイアウトコンポーネントは SEO のために <link rel="alternate" hreflang> タグを処理します:

// src/layouts/LocaleLayout.vue
<script setup lang="ts">
import { useRoute } from 'vue-router'
import { useHead } from '@vueuse/head'

const route = useRoute()
const locale = route.params.locale as string
const LOCALES = ['en', 'fr', 'de']

useHead({
  htmlAttrs: { lang: locale },
  link: LOCALES.map(l => ({
    rel: 'alternate',
    hreflang: l,
    href: `https://example.com/${l}${route.path.replace(`/${locale}`, '')}`
  }))
})
</script>

<template>
  <RouterView />
</template>

複数形と書式設定

複数形は、単純な i18n 実装が破綻するポイントです。英語には 2 つの複数形があります。アラビア語には 6 つあります。ロシア語は数字の末尾によって異なる形を使います。vue-i18n は正しく使えば、これを正確に処理します。

複数形:

// en.json
{
  "items": "no items | one item | {count} items"
}

// ru.json — Russian plural rules
{
  "items": "нет элементов | {count} элемент | {count} элемента | {count} элементов"
}
const { t, n, d } = useI18n()

// Pluralization
t('items', 0)   // "no items"
t('items', 1)   // "one item"
t('items', 42)  // "42 items"

// Number formatting with named formats
n(1234567.89, 'currency') // "$1,234,567.89" in en, "1 234 567,89 €" in fr

名前付きフォーマットを i18n インスタンスレベルで定義します:

export const i18n = createI18n({
  legacy: false,
  locale: 'en',
  numberFormats: {
    en: {
      currency: { style: 'currency', currency: 'USD', notation: 'standard' },
      decimal: { style: 'decimal', minimumFractionDigits: 2 }
    },
    fr: {
      currency: { style: 'currency', currency: 'EUR', notation: 'standard' },
      decimal: { style: 'decimal', minimumFractionDigits: 2 }
    }
  },
  datetimeFormats: {
    en: {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
    },
    fr: {
      short: { year: 'numeric', month: 'short', day: 'numeric' },
      long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'long' }
    }
  }
})

これにより、n()d() はコンポーネントごとのロジックなしに各ロケールで正しく書式設定されます。

CDN 配信メッセージ vs. バンドル JSON

JSON ロケールファイルをアプリにバンドルすることには現実的なトレードオフがあります。翻訳を更新するたびに再デプロイが必要になります。翻訳を頻繁に更新するチーム——コピーの A/B テスト、誤りの修正、新市場へのローカライズ——にとって、これは無視できない制約です。

代替手段は CDN 配信メッセージです。翻訳ファイルを CDN に置き、アプリが実行時にそれを取得します。これは Nuxt 3、Vite SPA、カスタムサーバーレンダリングを含む、あらゆる Vue セットアップで機能します。

// src/i18n/cdn-loader.ts
const CDN_BASE = 'https://cdn.example.com/i18n'

export async function loadLocaleFromCDN(locale: string): Promise<Record<string, string>> {
  const res = await fetch(`${CDN_BASE}/${locale}.json`, {
    headers: { 'Cache-Control': 'max-age=3600' }
  })
  if (!res.ok) throw new Error(`Failed to load locale: ${locale}`)
  return res.json()
}

トレードオフはロケール読み込み時のネットワークリクエストです。適切なキャッシュが設定された CDN であれば、最初の読み込み後は通常キャッシュヒットになります。メリットは、翻訳チームがコードのデプロイパイプラインに触れることなく、コピーの更新を即座に反映できることです。

Better i18n では、CDN 配信がプラットフォームに組み込まれています。翻訳はレビューワークフローを経て公開され、CDN を通じてグローバルに即座に利用可能になります——再デプロイも、タイポ修正のための PR も不要です。配信パイプラインの仕組みは機能ページで説明しており、Vue プロジェクトでこれを検討している場合は、開発者向けページに SDK 統合例があります。

Nuxt 3 との統合

Nuxt 3 は @nuxtjs/i18n を通じて i18n をファーストクラスでサポートしています。これは vue-i18n をラップし、サーバーサイドレンダリング、ルート生成、SEO ユーティリティを追加します。

npm install @nuxtjs/i18n
// nuxt.config.ts
export default defineNuxtConfig({
  modules: ['@nuxtjs/i18n'],
  i18n: {
    locales: [
      { code: 'en', iso: 'en-US', file: 'en.json' },
      { code: 'fr', iso: 'fr-FR', file: 'fr.json' },
      { code: 'de', iso: 'de-DE', file: 'de.json' }
    ],
    defaultLocale: 'en',
    langDir: 'i18n/locales/',
    lazy: true,
    strategy: 'prefix_except_default',
    detectBrowserLanguage: {
      useCookie: true,
      cookieKey: 'i18n_redirected',
      redirectOn: 'root'
    }
  }
})

strategy: 'prefix_except_default' では、英語のルートにはプレフィックスがなく(/pricing)、他のロケールにはプレフィックスが付きます(/fr/tarifs)。Nuxt がルート生成を自動的に処理します。

コンポーネントでは、useI18n() は Nuxt を使わないアプリと同様に動作します:

// pages/pricing.vue
<script setup lang="ts">
const { t } = useI18n()

useSeoMeta({
  title: t('pricing.meta.title'),
  description: t('pricing.meta.description')
})
</script>

SSR 統合により、翻訳されたコンテンツが初期 HTML に含まれます——未翻訳コンテンツのフラッシュがなく、検索エンジンはローカライズされたバージョンを確認できます。

開発者ワークフローのスケーリング

翻訳対象が増えると、ボトルネックは技術的なセットアップからワークフローへと移ります。スケール時に効果的なパターンをいくつか紹介します。

翻訳キーは位置ではなく意味で命名する。 nav.pricinglink3 よりも長持ちします。ナビゲーションが再設計されても、link3 は意味をなさなくなります。nav.pricing は依然として正確です。

翻訳の欠落をビルドエラーとして扱う。 デフォルトロケールにキーが存在するが対象ロケールに欠けている場合、警告または失敗するようビルドを設定します。ユーザーより先に問題を捕捉します。

翻訳の抽出を自動化する。 CI の一部として vue-i18n-extract などのツールを実行することで、すべての新しい文字列がリリース前に翻訳のフラグを立てられるようにします。

翻訳レビューとコードレビューを分離する。 翻訳の品質はコードの品質とは異なるスキルです。翻訳者がタイポを修正するために PR を開く必要があるなら、不必要な摩擦を生んでいます。言語のレビューが Git ではなく言語の中で行われる翻訳プラットフォームを使用しましょう。

この記事で説明した技術的な基盤はエンジニアリング面を担います。ワークフロー面——翻訳のレビュー、翻訳者アクセスの管理、言語間での用語集の一貫性維持——は、専用ツールが不釣り合いなほどの効果を発揮する領域です。

Comments

Loading comments...