@k8ordo/i18n

仕組み

@k8ordo/i18nがどう動いているかを説明します。使うだけなら知らなくてもかまいませんが、なぜそう書くのかが分かると、迷ったときに判断しやすくなります。

このページの内容

文言は副作用の無い関数

messageは、受け取った文を閉じ込めた関数を返すだけです。宣言した時点では、どこにも登録せず、何も確かめず、エラーも投げません。

save-button.tsx
'use client';

import * as m from '../messages';

export function SaveButton() {
  return <button type="submit">{m.form.save()}</button>;
ブラウザに届くのはこの文言だけ
}

そのため、どこからも呼ばれない文言は、使われないコードとしてバンドラが取り除けます。ブラウザのバンドルに入るのは、'use client'のモジュールと、そこからimportされたモジュールが名前で参照した文言だけです。入る文言は、すべてのロケールの文を持っています。

Server Componentが描いた文は、どのモジュールで宣言されていても、ブラウザのバンドルを増やしません。文言を1つの辞書オブジェクトにしないのは、このためです。辞書は、丸ごと残るか丸ごと消えるかのどちらかしかありません。

ロケールの欠けを宣言の時点で確かめないのも、同じ理由です。宣言の中にエラーを投げるコードがあると、バンドラはその宣言を副作用とみなし、使われない文言を取り除けなくなります。そのため、型をすり抜けた欠けは、文言を読んだ時点でTypeErrorになります。

messageがロケールの集合を引数に取らないのも、宣言を解析できる形に保つためです。仮にlocales.message(…)のような集合のメソッドにすると、バンドラはほかの呼び出しが返した関数の中を解析しないので、使われない文言が残ってしまいます。

情報

メモ

ビルドのあとにdist/client/assets/のJavaScriptを検索すれば、Server Componentだけが描く文が入っていないことを確かめられます。

ロケールを読む場所

文言はロケールを受け取らず、呼ばれたときに自分で読みます。読む場所は、サーバーとブラウザで違います。

text
server   paramsSchema accepts 'en'    → AsyncLocalStorage
         nav.home()                   → reads the storage → 'Home'

browser  location.pathname '/en/ui'   → first segment 'en'
         nav.home()                   → reads the URL     → 'Home'

サーバーでは

サーバーでは、paramsSchemaが受け付けたロケールをAsyncLocalStorageに置きます。描いている途中のリクエストが同時にいくつあっても、それぞれのロケールは混ざりません。

AsyncLocalStorageは、node:async_hooksをimportせずにprocess.getBuiltinModuleで取り出します。同じビルドを、ブラウザでもそのまま読み込めるようにするためです。

RSCの環境と、Client ComponentをHTMLにするSSRの環境は、同じプロセスの中の別のモジュールグラフです。両方が同じロケールを読めるように、ストレージはglobalThisに置いています。

ロケールを受け付けるのは、どのパターンが答えるかを決める途中です。フレームワークはパターンごとに別の流れでスキーマを走らせ、答えたパターンの流れで描画を始めます。後に続くスキーマが拒んだパターンでは、受け付けたロケールもパターンと一緒に捨てられます。

ブラウザでは

ブラウザでは、location.pathnameのうちViteのbaseより下の最初の区間を、文言を呼ぶたびに読みます。これがサーバーのHTMLと一致するのは、そのHTMLが同じURLのために描かれたからです。別のURLのために描いた404.htmlは、ハイドレーションせずに描き直します。

どちらの読み方をするかは、モジュールを読み込んだ時点でdocumentが定義されているかどうかで、1度だけ決まります。

集合を定義するモジュールがブラウザでまだ評価されていない間は、URLの区間がロケールかどうか分かりません。その間は、文言が文を持たない区間をロケールではないとみなし、最初に書いた文を返します。/fr/…の404ページがエラーを投げないのは、このためです。

最後に定義した集合が使われる

defineLocalesは値を返すだけでなく、既定のロケールと、一覧に含まれるかを確かめる関数をglobalThisに登録します。messageは集合を受け取らないので、ここから既定のロケールを読みます。

登録は、後から定義したほうが勝ちます。開発サーバーがロケールを足したi18n.tsを評価し直したとき、次に呼ばれる文言が新しい集合を読めるようにするためです。

警告

落とし穴

その代わり、テストや補助関数の中で別の集合を定義すると、それ以降のすべての文言がその集合を読みます。集合はアプリに1つだけにして、1つのモジュールで定義してexportし、ほかの場所ではimportしてください。

プロバイダもフックも無い理由

ロケールはURLにあり、文言はそれを呼ばれた場所で読みます。そのため、ロケールをコンポーネントの木に流すプロバイダも、それを読み出すフックも要りません。

  • プロバイダの値はClient Componentでしか読めないので、Server Componentはプロバイダからロケールを受け取れません。文言が自分でロケールを読む形なら、Server ComponentでもClient Componentでも、同じ1行で呼べます。
  • フックではないので、文言はイベントハンドラの中でも、ほかの文言の関数の中でも、bindParamsに渡す関数の中でも呼べます。
  • ロケールを変えるのは、ページの移動です。ブラウザで同期させる状態は無く、移動すればページが描き直されて、文言は新しいURLのロケールで読まれます。
k8ordo

Baselineに入った機能を、制限なく使うReactのライブラリ群。

© 2026 k8o — MIT License

組版:Noto Sans JP / M PLUS 2