vue-i18nとは、Vueアプリの文言・日時・数値を言語ごとに出し分けるためのVue向け国際化ライブラリで、createI18nで作ったインスタンスをapp.useで登録し、コンポーネントではuseI18nが返すt(文言)・d(日時)・n(数値)を呼ぶだけで多言語対応ができます。 言語の切り替えは、useI18nが返すlocale(ref)に 'en' などを代入するだけで、画面全体が自動で描き直されます。

この記事では、vue-i18n 11.4.13 と Vue 3.5.43 で実際に動かした出力をもとに、最小構成、メッセージの書き方と出力の一覧表、言語ファイルの遅延ロード、初期言語の決め方、キーが無いときの警告文、TypeScriptでキーを検査する方法までを順に説明します。コードはそのまま貼って動く形にしてあります。

確認環境:Ubuntu 24.04 / Node.js 22.22.2 / vue 3.5.43 / vue-i18n 11.4.13 / Vite 8.3.2 / TypeScript 7.0.2 / jsdom 30.1.1 / Chromium 141(Playwright 1.56.0)。コンポーネントの出力は @vue/test-utils 2.5.1 と jsdom で、遅延ロードは Vite でビルドした成果物を Chromium で開いて確かめています。

vue-i18nの早見表

やりたいこと 書き方 出力例(ja)
インスタンスを作る createI18n({ legacy: false, locale: 'ja', fallbackLocale: 'en', messages }) ―
アプリに登録する createApp(App).use(i18n).mount('#app') ―
コンポーネントで使う const { t, d, n, locale } = useI18n() ―
文言を出す t('title') タスク一覧
値を埋め込む(名前付き補間) t('hello', { name: 'Sato' }) こんにちは、Satoさん
件数で文言を変える(複数形) t('item', 5) 品物5件
日時を書式化する d(date, 'short') 2026年10月3日
数値・通貨を書式化する n(1280, 'currency') ¥1,280
言語を切り替える locale.value = 'en' 画面全体が英語に変わる
setupの外から切り替える i18n.global.locale.value = 'en' 同上
言語ファイルを後から足す i18n.global.setLocaleMessage('en', messages) ―
キーがあるか調べる te('title') true
読み込み済みの言語を調べる i18n.global.availableLocales ['ja']

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「vue-i18nの使い方|useI18nで多言語対応と言語切り替え」

vue-i18n 11系で押さえておく3つの前提

最初に、現在のvue-i18nで知っておくと迷わない点を3つにまとめます。

前提 内容 この記事での扱い
2つのモードがある Composition APIモード(useI18nを使う)と、Legacy APIモード(this.$i18nを使う従来の書き方) Composition APIモードだけを使う
Legacy APIモードは11系で非推奨 公式の移行ガイド(Breaking Changes in v11)に、Legacy APIモードとv-tディレクティブは11系で非推奨、12系で取り除くと書かれている 新しく書くコードは legacy: false にする
既定値はまだlegacy 11.4.13では、legacy を書かないとLegacy APIモードで起動し、警告が1つ出る legacy: false を必ず書く

3つ目は実際に確かめた結果です。legacy を省いて createI18n を呼ぶと、次の警告が出て、モードは legacy になりました。

[intlify] Legacy API mode has been deprecated in v11. Use Composition API mode instead.
About how to use the Composition API mode, see https://vue-i18n.intlify.dev/guide/advanced/composition.html
mode(default)= legacy
mode(legacy:false)= composition

「useI18n を使うなら legacy: false を書く」と覚えておけば十分です。npmのタグを見ると、次の系列(12.0.0-alpha)がすでに公開されていて、Composition APIモードへ一本化していく流れが続いています。今から書くコードをuseI18nでそろえておくと、次の版へもそのまま進めます。

インストールと最小構成(createI18n → useI18n → t・d・n)

インストール

npm install vue-i18n

この記事の時点では vue-i18n 11.4.13 が入りました。入った版は npm ls vue-i18n で確かめられます。

3ファイルの最小構成

Viteで作ったVueプロジェクトを前提に、i18n.js(設定)・main.js(登録)・App.vue(利用) の3つに分けます。

// src/i18n.js
import { createI18n } from 'vue-i18n'

export const i18n = createI18n({
  legacy: false,          // Composition API モード
  locale: 'ja',           // 最初に表示する言語
  fallbackLocale: 'en',   // キーが無いときに探す言語
  messages: {
    ja: { title: 'タスク一覧', hello: 'こんにちは、{name}さん' },
    en: { title: 'Task list', hello: 'Hello, {name}!' },
  },
  datetimeFormats: {
    ja: { short: { year: 'numeric', month: 'short', day: 'numeric' } },
    en: { short: { year: 'numeric', month: 'short', day: 'numeric' } },
  },
  numberFormats: {
    ja: { currency: { style: 'currency', currency: 'JPY' } },
    en: { currency: { style: 'currency', currency: 'USD' } },
  },
})
// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import { i18n } from './i18n'

createApp(App).use(i18n).mount('#app')
<!-- src/App.vue -->
<script setup>
import { useI18n } from 'vue-i18n'

const { t, d, n, locale } = useI18n()
const date = new Date(2026, 9, 3)
</script>

<template>
  <h1>{{ t('title') }}</h1>
  <p>{{ t('hello', { name: 'Sato' }) }}</p>
  <p>{{ d(date, 'short') }}</p>
  <p>{{ n(1280, 'currency') }}</p>

  <select v-model="locale">
    <option value="ja">日本語</option>
    <option value="en">English</option>
  </select>
</template>

実行結果

同じ内容のコンポーネントを @vue/test-utils でマウントし、見出しと段落の文字を1行に並べて出力しました。1行目が初期表示、2行目がセレクトボックスで en を選んだあと、3行目が i18n.global.locale.value = 'ja' で戻したあとです。

タスク一覧 | こんにちは、Satoさん | 2026年10月3日 | ¥1,280
Task list | Hello, Sato! | Oct 3, 2026 | $1,280.00
タスク一覧 | こんにちは、Satoさん | 2026年10月3日 | ¥1,280

localeはrefなので、v-modelにそのままつなげます。 値を変えるだけで、t・d・n を使っている箇所がすべて描き直されます。再描画を自分で指示する必要はありません。

createI18nの主なオプション

オプション 役割 書かない場合
legacy false でComposition APIモード Legacy APIモード(警告が出る)
locale 現在の言語 'en-US'
fallbackLocale キーが無いときに探す言語 locale と同じ値(別の言語は探さない)
messages 言語ごとの文言 空
datetimeFormats d で使う日時書式の名前と定義 名前付き書式は使えない
numberFormats n で使う数値書式の名前と定義 名前付き書式は使えない
missingWarn / fallbackWarn 開発時の警告を出すかどうか 出す
missing キーが無いときに呼ばれる関数 なし
globalInjection テンプレートで $t などを使えるようにする 使える

useI18nが返す主なもの

名前 種類 用途
t 関数 文言を取り出す(補間・複数形もこれ)
d 関数 日時を書式化する
n 関数 数値を書式化する
locale ref 現在の言語。代入で切り替え
te 関数 キーがあるかどうかを true / false で返す
tm 関数 メッセージの入れ子ごと(配列やオブジェクト)取り出す
availableLocales 配列 読み込み済みの言語
setLocaleMessage 関数 言語のメッセージを登録する

何も渡さずに useI18n() を呼ぶと、アプリ全体で共有するグローバルスコープが返ります。 そのため、どのコンポーネントで locale を変えても、アプリ全体の言語が変わります。

メッセージ定義→出力の一覧表(補間・複数形・日時・数値)

ここがこの記事の中心です。メッセージの書き方ごとに、呼び出し方と実際の出力を並べました。 すべて vue-i18n 11.4.13 で実行した結果です。

文言(t)の一覧

種類 メッセージ定義 呼び出し 出力
名前付き補間 hello: 'こんにちは、{name}さん' t('hello', { name: 'Sato' }) こんにちは、Satoさん
リスト補間 range: '{0}から{1}まで' t('range', [1, 5]) 1から5まで
入れ子のキー nested: { save: '保存' } t('nested.save') 保存
別のメッセージを参照 app: 'タスク帳'、welcome: '@:app へようこそ' t('welcome') タスク帳 へようこそ
参照+大文字化 app: 'task book'、shout: '@.upper:app' t('shout') TASK BOOK
記号をそのまま出す "info{'@'}example.com" t('mail') info@example.com
波かっこをそのまま出す "{'{'}name{'}'}" t('mail') {name}
既定の文言を渡す (キーなし) t('nothing', '(未設定)') (未設定)

@ と波かっこはメッセージ構文で特別な意味を持つので、文字として出したいときは {'@'} のようにリテラル補間で囲みます。メールアドレスを文言に入れるときによく出会う箇所です。

複数形の一覧

複数形は、縦棒(パイプ)で区切って書き、t の2番目の引数に件数を渡します。

メッセージ定義 呼び出し 出力
item: '品物なし | 品物1件 | 品物{count}件' t('item', 0) 品物なし
同上 t('item', 1) 品物1件
同上 t('item', 5) 品物5件
同上 t('item', { count: 12 }) 品物12件
item: 'no items | one item | {count} items' t('item', 5) 5 items
apple: '{n} apple | {n} apples' t('apple', 1) 1 apple
同上 t('apple', 3) 3 apples

区切りの数で選ばれ方が変わります。

区切った数 選ばれ方
2つ 1のとき1番目、それ以外は2番目
3つ 0のとき1番目、1のとき2番目、2以上は3番目

件数は、メッセージの中で {count} と {n} のどちらでも参照できます。日本語は単数・複数で形が変わりませんが、「0件のときだけ別の文言にしたい」場面で3つ区切りが役に立ちます。

日時(d)の一覧

日時の書式は、datetimeFormats に名前を付けて定義します。中身は JavaScript 標準の Intl.DateTimeFormat に渡すオプションと同じです。

datetimeFormats: {
  ja: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
    long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short', hour: 'numeric', minute: 'numeric' },
  },
  en: {
    short: { year: 'numeric', month: 'short', day: 'numeric' },
    long: { year: 'numeric', month: 'long', day: 'numeric', weekday: 'short', hour: 'numeric', minute: 'numeric' },
  },
}

const date = new Date(2026, 9, 3, 14, 5) を渡したときの出力です。

呼び出し ja の出力 en の出力
d(date, 'short') 2026年10月3日 Oct 3, 2026
d(date, 'long') 2026年10月3日(土) 14:05 Sat, October 3, 2026 at 2:05 PM
d(date, 'short', 'ja')(言語を指定) 2026年10月3日 2026年10月3日

数値(n)の一覧

数値の書式は numberFormats に定義します。こちらは Intl.NumberFormat のオプションと同じです。

numberFormats: {
  ja: {
    currency: { style: 'currency', currency: 'JPY' },
    decimal: { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 },
    percent: { style: 'percent' },
  },
  en: {
    currency: { style: 'currency', currency: 'USD' },
    decimal: { style: 'decimal', minimumFractionDigits: 2, maximumFractionDigits: 2 },
    percent: { style: 'percent' },
  },
}
呼び出し ja の出力 en の出力
n(1234567.891) 1,234,567.891 1,234,567.891
n(1280, 'currency') ¥1,280 $1,280.00
n(3.14159, 'decimal') 3.14 3.14
n(0.256, 'percent') 26% 26%
n(1280, { style: 'currency', currency: 'EUR' }) ― €1,280.00
n(1280, 'currency', 'ja')(言語を指定) ¥1,280 ¥1,280

n は表示の形を変えるだけで、金額の換算はしません。 1280 を currency で出すと、ja では ¥1,280、en では $1,280.00 になります。言語によって通貨単位を変えたい場合は、数値そのものもアプリ側で用意します。

また、d と n の書式名も言語ごとに定義が必要です。ja に short を定義して en に定義しなかった場合、en では書式が見つからず、後述の警告が出て空文字になります。

テンプレートでの書き方3通り

テンプレートから文言を出す方法は3つあります。

書き方 例 向いている場面
useI18nの t {{ t('title') }} 基本。script setup内でも同じ関数を使える
グローバルの $t {{ $t('title') }} useI18nを呼ばずにテンプレートだけで済ませたいとき
i18n-t コンポーネント 下のコード 文の途中にリンクなどの要素を挟みたいとき

$t・$d・$n

legacy: false でも、テンプレートでは $t・$d・$n がそのまま使えます(globalInjection が既定で有効なため)。

<template>
  <h1>{{ $t('title') }}</h1>
  <p>{{ $n(1234.5) }}</p>
</template>
<h1>タスク一覧</h1><p>1,234.5</p>

文の途中に要素を入れる(i18n-t)

「利用規約に同意します」の「利用規約」だけをリンクにしたい、という場面です。言語によって語順が変わるので、文を分割せず、メッセージ側に差し込み位置 {link} を書きます。

messages: {
  ja: { agree: '{link}に同意します', terms: '利用規約' },
  en: { agree: 'I agree to the {link}', terms: 'Terms' },
}
<script setup>
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
</script>

<template>
  <i18n-t keypath="agree" tag="p">
    <template #link>
      <a href="/terms">{{ t('terms') }}</a>
    </template>
  </i18n-t>
</template>

出力(上が ja、下が en に切り替えたあと):

<p><a href="/terms">利用規約</a>に同意します</p>
<p>I agree to the <a href="/terms">Terms</a></p>

日本語ではリンクが文頭に、英語では文末に入りました。語順の違いをメッセージ側で吸収できるのがこの書き方の良いところです。

そのコンポーネントだけの文言(ローカルスコープ)

共通の文言とは別に、その部品だけで使う文言を持たせることもできます。

<script setup>
import { useI18n } from 'vue-i18n'

const { t } = useI18n({
  useScope: 'local',
  messages: {
    ja: { caption: 'この部品だけの文言' },
    en: { caption: 'Local text' },
  },
})
</script>

<template>
  <p>{{ t('caption') }}</p>
</template>
<p>この部品だけの文言</p>
スコープ 指定 メッセージの置き場所 使いどころ
グローバル useI18n() createI18n の messages アプリ全体で使う文言。まずはこちら
ローカル useI18n({ useScope: 'local', messages }) そのコンポーネント 配布する部品など、文言を部品と一緒に持ち運びたいとき

言語切り替えの作り方

セレクトボックスで切り替える

最小構成で見たとおり、locale を v-model につなぐだけで切り替えられます。

<script setup>
import { useI18n } from 'vue-i18n'
const { locale } = useI18n()
</script>

<template>
  <select v-model="locale">
    <option value="ja">日本語</option>
    <option value="en">English</option>
  </select>
</template>

setupの外(ルーターやストア)から切り替える

useI18n は setup の中でしか呼べません。ルーターのガードや通常の関数から切り替えるときは、createI18n で作ったインスタンスの global を使います。

import { i18n } from './i18n'

i18n.global.locale.value = 'en'

legacy: false のとき、i18n.global.locale は ref です。.value を付けて代入します。

切り替えと一緒にやっておくと良いこと

すること コード 理由
html の lang 属性を合わせる document.documentElement.setAttribute('lang', locale) 読み上げソフトや検索エンジンが言語を判別しやすくなる
選んだ言語を保存する localStorage.setItem('locale', locale) 次に開いたときも同じ言語で表示できる
その言語のメッセージを読み込む 次の章の setLocale 使う言語だけを配信できる

この3つを1つの関数にまとめたのが、次の章の setLocale です。

言語ファイルを遅延ロードする(動的import)

言語が増えると、全言語のメッセージを最初から配るのは通信量の面でもったいなくなります。最初の言語だけを含めておき、ほかの言語は選ばれたときに動的importで取りに行くようにします。考え方は公式ドキュメントの Lazy loading のページに沿っています。

ファイル構成

src/
├── i18n.js
├── main.js
├── App.vue
└── locales/
    ├── ja.json
    ├── en.json
    └── fr.json
{ "title": "タスク一覧", "add": "追加" }

(en.json は { "title": "Task list", "add": "Add" }、fr.json は { "title": "Liste des tâches", "add": "Ajouter" } です。)

i18n.js(setLocaleつき)

// src/i18n.js
import { nextTick } from 'vue'
import { createI18n } from 'vue-i18n'
import ja from './locales/ja.json'          // 最初の言語だけ静的に読み込む

export const SUPPORTED = ['ja', 'en', 'fr']

export const i18n = createI18n({
  legacy: false,
  locale: 'ja',
  fallbackLocale: 'ja',
  messages: { ja },
})

export async function setLocale(locale) {
  if (!SUPPORTED.includes(locale)) return false
  // まだ読み込んでいない言語だけ、動的 import で取りに行く
  if (!i18n.global.availableLocales.includes(locale)) {
    const messages = await import(`./locales/${locale}.json`)
    i18n.global.setLocaleMessage(locale, messages.default)
  }
  i18n.global.locale.value = locale
  document.documentElement.setAttribute('lang', locale)
  localStorage.setItem('locale', locale)
  await nextTick()
  return true
}
<!-- src/App.vue -->
<script setup>
import { useI18n } from 'vue-i18n'
import { setLocale, SUPPORTED } from './i18n'

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

<template>
  <h1>{{ t('title') }}</h1>
  <button>{{ t('add') }}</button>
  <select :value="locale" @change="setLocale($event.target.value)">
    <option v-for="l in SUPPORTED" :key="l" :value="l">{{ l }}</option>
  </select>
</template>

ここでは v-model ではなく、:value と @change に分けています。 v-model で locale を直接書き換えると、メッセージが届く前に言語だけが切り替わってしまうためです。setLocale の中で「読み込み→切り替え」の順に進めます。

ビルド結果:言語ごとにファイルが分かれる

npx vite build(Vite 8.3.2)の出力です。

dist/index.html                  0.21 kB │ gzip:  0.17 kB
dist/assets/en-CX98zutB.js       0.08 kB │ gzip:  0.09 kB
dist/assets/fr-PPcmznbm.js       0.09 kB │ gzip:  0.10 kB
dist/assets/index-CRSsrpfu.js  121.73 kB │ gzip: 44.05 kB

en と fr が別ファイル(チャンク)になりました。 ja は静的importしているので index 側に含まれます。このときViteは次のメッセージも出します。ja.json が「動的importの対象でもあり、静的importもされている」ことを知らせる案内で、動作には影響しません。

[INEFFECTIVE_DYNAMIC_IMPORT] src/locales/ja.json is dynamically imported by src/i18n.js but also statically imported by src/i18n.js, dynamic import will not move module into another chunk.

ブラウザで確かめる:選んだときだけ取得される

ビルドした成果物を vite preview で配信し、Chromium 141 で開いて、取得されたJSファイルを記録しました。

初期表示 | h1 = タスク一覧 | button = 追加 | lang = ja | 取得したJS: ['index-CRSsrpfu.js']
en を選択 | h1 = Task list | button = Add | lang = en | 取得したJS: ['index-CRSsrpfu.js', 'en-CX98zutB.js']
fr を選択 | h1 = Liste des tâches | button = Ajouter | lang = fr | 取得したJS: ['index-CRSsrpfu.js', 'en-CX98zutB.js', 'fr-PPcmznbm.js']
en に戻す | h1 = Task list | button = Add | lang = en | 取得したJS: ['index-CRSsrpfu.js', 'en-CX98zutB.js', 'fr-PPcmznbm.js']
localStorage.locale = en

読み取れることは3つです。

  • 初期表示では index だけが取得され、en・fr のファイルは取りに行かない
  • en を選んだ時点で en のファイルが1回だけ取得される
  • en に戻したときは再取得しない(availableLocales に入っているため)

遅延ロードの流れ

flowchart TD
  A[言語を選ぶ] --> B{対応している言語か}
  B -- いいえ --> X[何もしない false を返す]
  B -- はい --> C{読み込み済みか}
  C -- いいえ --> D[動的 import で取得]
  D --> E[setLocaleMessage で登録]
  C -- はい --> F[locale.value を変更]
  E --> F
  F --> G[html の lang と localStorage を更新]

静的に全部含める方法との使い分け

方法 向いている場合 気を付ける点
全言語を静的import 言語が2〜3個で文言も少ない 言語が増えるほど初期のJSが大きくなる
動的importで遅延ロード 言語が多い、文言が多い 切り替えが非同期になるので、読み込み→切り替えの順を守る
画面(ルート)単位でも分ける 画面ごとの文言が多い キーの置き場所の決まりをチームでそろえる

初期言語を決める(localStorage と navigator.language)

初めて開いたときの言語は、「前回選んだ言語 → ブラウザの言語設定 → 既定の言語」の順で決めると自然です。

// src/detect.js
export const SUPPORTED = ['ja', 'en', 'fr']
export const DEFAULT_LOCALE = 'ja'

export function detectLocale() {
  // 1. 前回選んだ言語(localStorage)
  const saved = localStorage.getItem('locale')
  if (saved && SUPPORTED.includes(saved)) return saved
  // 2. ブラウザの言語設定(優先順の配列)。'en-US' は 'en' に丸めて探す
  const candidates = navigator.languages?.length ? navigator.languages : [navigator.language]
  for (const lang of candidates) {
    const base = lang.toLowerCase().split('-')[0]
    if (SUPPORTED.includes(base)) return base
  }
  // 3. どれにも当てはまらなければ既定の言語
  return DEFAULT_LOCALE
}

localStorage と navigator.languages の値を入れ替えながら実行した結果です。

localStorage=null navigator.languages=["en-US","en"]     => en
localStorage=null navigator.languages=["fr-CA","en"]     => fr
localStorage=null navigator.languages=["de-DE","en-GB"]  => en
localStorage=null navigator.languages=["zh-TW","ko"]     => ja
localStorage=fr   navigator.languages=["en-US"]          => fr
localStorage=xx   navigator.languages=["en-US"]          => en
行 読み取れること
1〜2行目 'en-US' や 'fr-CA' のような地域つきの値は、ハイフンの前だけを見て対応言語に当てはめる
3行目 第1希望(de)に対応していなくても、第2希望(en-GB)で見つかる
4行目 どれにも対応していなければ既定の ja
5行目 保存した言語があれば、ブラウザの設定より優先
6行目 保存値が対応言語に無い値(xx)なら読み飛ばして次へ進む

navigator.language は第1希望の1つだけ、navigator.languages は優先順の配列です。配列のほうを見ると、3行目のように第2希望まで拾えます。

起動時に組み合わせる

遅延ロードと合わせる場合は、マウントの前に setLocale を待つと、最初の描画から目的の言語で表示できます。

// src/main.js
import { createApp } from 'vue'
import App from './App.vue'
import { i18n, setLocale } from './i18n'
import { detectLocale } from './detect'

await setLocale(detectLocale())
createApp(App).use(i18n).mount('#app')

この main.js でビルドし、Chromium の言語設定を変えて開いた結果です。

fr-CA => Liste des tâches | lang = fr | languages = ['fr-CA']
en-US => Task list | lang = en | languages = ['en-US']
  fr を選んで再読み込み => Liste des tâches
de-DE => タスク一覧 | lang = ja | languages = ['de-DE']

ブラウザがフランス語なら最初からフランス語で表示され、選び直した言語は再読み込みのあとも保たれました。 対応していないドイツ語の設定では、既定の日本語になります。

キーが無いときの警告文と対処

実際に出る警告

ja に無く en にだけあるキー(onlyEn)と、どちらにも無いキー(nothing)を呼んだ結果です。[warn] の行が console.warn に出た内容です。

(1) 既定の設定
  [warn] [intlify] Not found 'onlyEn' key in 'ja' locale messages.
  [warn] [intlify] Fall back to translate 'onlyEn' key with 'en' locale.
  t('onlyEn')   => English only
  [warn] [intlify] Not found 'nothing' key in 'ja' locale messages.
  [warn] [intlify] Fall back to translate 'nothing' key with 'en' locale.
  [warn] [intlify] Not found 'nothing' key in 'en' locale messages.
  t('nothing')  => nothing
警告文 意味 対処
Not found 'xxx' key in 'ja' locale messages. ja のメッセージにそのキーが無い ja にキーを足す。つづりを見直す
Fall back to translate 'xxx' key with 'en' locale. fallbackLocale の en を探しに行った ja 側の訳を足せば出なくなる
Fall back to datetime format 'short' key with 'en' locale. d の書式名がその言語に無い datetimeFormats に同じ名前を定義する

どの言語にも無いキーは、キーの文字列そのもの(nothing)が画面に出ます。 画面に英字のキーがそのまま見えたら、まずつづりと言語ファイルを見直します。

警告を止める・記録する

// (2) 警告を出さない
createI18n({ legacy: false, locale: 'ja', fallbackLocale: 'en', messages,
  missingWarn: false, fallbackWarn: false })

// (3) 欠けているキーを自分で記録する
const missed = []
createI18n({ legacy: false, locale: 'ja', fallbackLocale: 'en', messages,
  missing: (locale, key) => { missed.push(`${locale}:${key}`) } })
(2) missingWarn: false, fallbackWarn: false
  t('onlyEn')   => English only
  t('nothing')  => nothing
(3) missing ハンドラで記録する
  [warn] [intlify] Fall back to translate 'nothing' key with 'en' locale.
  t('nothing')  => nothing
  [warn] [intlify] Fall back to translate 'onlyEn' key with 'en' locale.
  t('onlyEn')   => English only
  記録: ["ja:nothing","en:nothing","ja:onlyEn"]

missing を渡すと「Not found」の警告は出なくなり、代わりに自分の関数が呼ばれます。開発中に欠けているキーの一覧を集めたいときに便利です。

なお、NODE_ENV=production で同じスクリプトを実行すると、警告は1行も出ませんでした。出力(フォールバックした文言やキー文字列)と missing の記録は同じです。警告は開発時の手がかりで、公開用のビルドでは静かになります。

よく出会うエラー文

エラー文 原因 直し方
SyntaxError: Must be called at the top of a setup function useI18n を setup の外(通常の関数やモジュール直下)で呼んだ setup 内で呼ぶ。外では i18n.global を使う
SyntaxError: Need to install with app.use function app.use(i18n) をしていない main.js で createApp(App).use(i18n) とする
[intlify] Legacy API mode has been deprecated in v11. legacy: false を書いていない createI18n に legacy: false を足す
[intlify] Not found parent scope. use the global scope. i18n-t などが親のスコープを探したが見つからず、グローバルを使った 案内のメッセージ。グローバルの文言で良ければそのままで動く

(1行目のエラー文では、setup の部分が実際にはバッククォートで囲まれて表示されます。)

型でキーを検査する(TypeScript)

TypeScriptでは、「言語ファイルどうしでキーがそろっているか」と「t に渡すキーが存在するか」の2つを検査できます。TypeScript 7.0.2 の tsc --noEmit で確かめました。

言語ファイルのキーのそろい具合を検査する

日本語の定義を型の正本にして、createI18n の型引数に渡します。書き方は公式ドキュメントの TypeScript Support のページに沿っています。

// src/locales/ja.ts
export default {
  title: 'タスク一覧',
  hello: 'こんにちは、{name}さん',
  nested: { save: '保存' },
}
// src/i18n.ts
import { createI18n } from 'vue-i18n'
import ja from './locales/ja'

// 日本語の定義を「型の正本」にする
export type MessageSchema = typeof ja

export const i18n = createI18n<[MessageSchema], 'ja' | 'en'>({
  legacy: false,
  locale: 'ja',
  fallbackLocale: 'en',
  messages: {
    ja,
    en: {
      title: 'Task list',
      hello: 'Hello, {name}!',
      // nested.save を書き忘れている
    },
  },
})
ts/i18n.ts(13,5): error TS2741: Property 'nested' is missing in type '{ title: string; hello: string; }' but required in type '{ title: string; hello: string; nested: { save: string; }; }'.

en に nested が無いことを、ビルド前に教えてくれます。 en に nested: { save: 'Save' } を足すとエラーは消えました。

t に渡すキーを検査する

ここは実際に試してわかった点です。上の設定をしたうえで i18n.global.t('titel')(つづりの誤り)や、型引数つきの useI18n が返す t に t('titel') を書いても、tsc はエラーを出しませんでした。 t の引数は任意の文字列も受け取れる型になっていて、型の情報は主にエディタでのキー補完に使われます。

呼び出し側でも検査したい場合は、キーの文字列の型を自分で作り、その型だけを受け取る薄い関数を用意します。

// src/keys.ts
import { i18n, type MessageSchema } from './i18n'

// 'title' | 'hello' | 'nested.save' のような、キーの文字列の型を作る
type Leaves<T, P extends string = ''> = {
  [K in keyof T & string]: T[K] extends string ? `${P}${K}` : Leaves<T[K], `${P}${K}.`>
}[keyof T & string]

export type MessageKey = Leaves<MessageSchema>

// キーを MessageKey に絞った t
export function tk(key: MessageKey, named: Record<string, unknown> = {}): string {
  return i18n.global.t(key, named)
}

tk('title')
tk('nested.save')
tk('hello', { name: 'Sato' })
tk('titel')         // つづりの誤り
tk('nested.sav')    // 入れ子のキーの誤り
ts/keys.ts(18,4): error TS2345: Argument of type '"titel"' is not assignable to parameter of type 'MessageKey'.
ts/keys.ts(19,4): error TS2345: Argument of type '"nested.sav"' is not assignable to parameter of type 'MessageKey'.

正しいキーの3行は通り、誤った2行だけがエラーになりました。 tk の中身は i18n.global.t を呼んでいるだけなので、言語を切り替えたときの再描画もそのまま働きます。

エディタの補完を効かせる(グローバルの型定義)

useI18n を呼ぶたびに型引数を書かなくて済むよう、vue-i18n の DefineLocaleMessage を拡張しておく方法もあります。

// src/i18n.d.ts
import type { MessageSchema } from './i18n'

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

日時と数値の書式名には、同じ要領で DefineDateTimeFormat と DefineNumberFormat が用意されています。

型による検査のまとめ

検査したいこと 方法 tsc の結果
言語どうしでキーがそろっているか createI18n の型引数に [MessageSchema] と言語名を渡す 不足があれば TS2741
t に渡すキーのつづり 素の t('titel') エラーにならない(補完向け)
t に渡すキーのつづり キーの型(MessageKey)を受け取る関数 tk を通す 誤りがあれば TS2345
毎回の型引数を省く DefineLocaleMessage を拡張する ―

従来の書き方からの置き換え表

以前のバージョンの記事やコードを読むときに役立つ対応表です。

従来の書き方 現在の書き方(Composition APIモード)
new VueI18n({ ... })(Vue 2向けのv8) createI18n({ legacy: false, ... })
this.$i18n.locale = 'en' const { locale } = useI18n() → locale.value = 'en'
this.$t('key') const { t } = useI18n() → t('key')(テンプレートでは $t も可)
$tc('item', 5) t('item', 5)(公式の移行ガイドで tc と $tc は11系で取り除かれたと案内されている)
v-t ディレクティブ {{ t('key') }}(v-t は11系で非推奨)
切り替え後に $forceUpdate() 不要。locale は ref なので自動で描き直される

切り替えたのに画面が変わらないときは、再描画を指示するより先に、locale が同じインスタンスのものかを見直すと解決が早いです。たとえばローカルスコープの locale を変えても、グローバルスコープの文言は変わりません。アプリ全体の言語を変えるなら、引数なしの useI18n() か i18n.global を使います。

つまずきやすい点チェック表

症状 見直す箇所
キーの文字列がそのまま画面に出る キーのつづり、言語ファイルの読み込み、fallbackLocale の設定
切り替えても文言が変わらない legacy: false か、.value に代入しているか、グローバルスコープか
日付が空文字になる その言語の datetimeFormats に書式名があるか
@ を含む文言でエラーになる {'@'} のリテラル補間にしているか
遅延ロードで一瞬キーが見える 読み込み完了を待ってから locale を変えているか
再読み込みで言語が戻る localStorage への保存と、起動時の detectLocale
起動時に Legacy の警告が出る createI18n に legacy: false があるか

よくある質問

Q. $t と useI18n の t は、どちらを使えばよいですか? A. どちらも同じグローバルの文言を返します。script setup の中でも文言を使う(タイトルの設定や通知など)なら useI18n の t に統一すると見通しが良くなります。テンプレートだけで済む小さな部品なら $t でも十分です。

Q. 言語を切り替えたあと、ページを再読み込みすると元に戻ります。 A. locale はメモリ上の値なので、再読み込みで初期値に戻ります。切り替え時に localStorage へ保存し、起動時に detectLocale のような関数で読み出して初期言語に使うと、同じ言語で表示が続きます。

Q. 遅延ロードの言語ファイルはJSONでないといけませんか? A. JSONでもJavaScript(export default のオブジェクト)でも動きます。この記事では、Node.js上の確認では .mjs、Viteのビルドでは .json を使い、どちらも setLocaleMessage で登録できました。翻訳の担当者とファイルをやり取りするなら、JSONのほうが扱いやすいことが多いです。

Q. 日時や数値の書式は、自分でライブラリを足す必要がありますか? A. 追加は不要です。d と n は JavaScript 標準の Intl.DateTimeFormat と Intl.NumberFormat を使うので、datetimeFormats と numberFormats にオプションを書くだけで言語に合った表記になります。

まとめ

  • createI18n に legacy: false を書き、app.use で登録し、useI18n の t・d・n を使うのが現在の基本形。11.4.13 では legacy を省くと Legacy APIモードで起動し、非推奨の警告が出る
  • 言語の切り替えは locale.value = 'en'。setup の外からは i18n.global.locale.value。再描画の指示は不要
  • 補間は {name}、複数形は縦棒区切り、@ や波かっこはリテラル補間。日時と数値は datetimeFormats と numberFormats に名前付きで定義する
  • 遅延ロードは「動的import → setLocaleMessage → locale 変更」の順。Viteのビルドで言語ごとのファイルに分かれ、選んだときに1回だけ取得されることを確かめた
  • 初期言語は localStorage → navigator.languages → 既定の言語の順で決めると、前回の選択とブラウザの設定の両方を生かせる
  • キーが無いと Not found と Fall back の警告が出て、キー文字列がそのまま表示される。missing ハンドラで一覧を集められる
  • 型の検査は2段構え。言語どうしのキーのそろい具合は createI18n の型引数で、t に渡すキーのつづりはキーの型を受け取る関数で検査できる

参考

  • Vue I18n 公式ドキュメント「Lazy loading」 https://vue-i18n.intlify.dev/guide/advanced/lazy.html
  • Vue I18n 公式ドキュメント「TypeScript Support」 https://vue-i18n.intlify.dev/guide/advanced/typescript.html
  • Vue I18n 公式ドキュメント「Breaking Changes in v11」 https://vue-i18n.intlify.dev/guide/migration/breaking11.html

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「vue-i18nの使い方|useI18nで多言語対応と言語切り替え」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?