仕組み
@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だけが描く文が入っていないことを確かめられます。
ロケールを読む場所
文言はロケールを受け取らず、呼ばれたときに自分で読みます。読む場所は、サーバーとブラウザで違います。
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のロケールで読まれます。