From 37803f83b313b8e5064cd338ab53bf01e7cfb33d Mon Sep 17 00:00:00 2001 From: guppy0356 Date: Wed, 2 Sep 2026 06:19:56 +0900 Subject: [PATCH] docs(ja): translate advanced/bundler-agnostic-rsc-serialization into Japanese Co-Authored-By: Claude Fable 5 --- .../bundler-agnostic-rsc-serialization.mdx | 859 ++++++++++++++++++ 1 file changed, 859 insertions(+) create mode 100644 docs/src/pages/ja/(pages)/advanced/bundler-agnostic-rsc-serialization.mdx diff --git a/docs/src/pages/ja/(pages)/advanced/bundler-agnostic-rsc-serialization.mdx b/docs/src/pages/ja/(pages)/advanced/bundler-agnostic-rsc-serialization.mdx new file mode 100644 index 00000000..579198b2 --- /dev/null +++ b/docs/src/pages/ja/(pages)/advanced/bundler-agnostic-rsc-serialization.mdx @@ -0,0 +1,859 @@ +--- +title: バンドラー非依存のRSCシリアライズ +date: 2026-04-13 +author: Viktor Lázár +github: lazarv +category: Advanced +order: 1 +--- + +import Link from "../../../../components/Link.jsx"; +import Subtitle from "../../../../components/Subtitle.jsx"; + +# バンドラー非依存のRSCシリアライズ + +バンドラーへの結合、実行環境の制約、Reactのインポートを排除したスタンドアロンなFlightプロトコル実装 + +*`@lazarv/rsc`の技術的な詳細解説です。`@lazarv/rsc`は、ReactのFlightプロトコルをゼロから実装したもので、React公式のシリアライザが前提としている3つの結合、すなわち特定のバンドラーへの依存、特定の実行環境への依存、`react-server` Node.jsエクスポート条件への依存を取り除いています。その結果、Node.js、Deno、Bun、Cloudflare Workers、ブラウザなど、Web Platform APIを提供するあらゆる環境でまったく同じように動作するRSCシリアライザが実現しています。* + + +## 概要 + + +React Server Components(RSC)は、Flightプロトコルに依存しています。これは、Reactの要素ツリー、データ構造、クライアント/サーバー参照のメタデータを実行環境の境界を越えてシリアライズする、行区切りのストリーミング形式です。公式実装である`react-server-dom-webpack`は、3つのインフラ上の前提と密結合しています。 + +1. **バンドラーへの結合。** シリアライザは、クライアント参照とサーバー参照の解決をWebpackのマニフェストに依存しています。他のバンドラー(Vite、Rollup、esbuild、Rspack)では、アダプターパッケージや互換シムが必要になります。 +2. **実行環境への結合。** サーバー用エントリーポイントはNode.js固有のAPI(`stream.Readable`、`Buffer`)を使用し、クライアント用エントリーポイントはブラウザコンテキストを前提としています。同じコードをDeno、Bun、Cloudflare Workersで実行するには、別ビルドやポリフィルが必要です。 +3. **エクスポート条件への結合。** ReactのFlightサーバー内部コードは、`react-server` Node.jsエクスポート条件の背後に隠されています。この条件をサポートしていない、または設定できない環境(ワーカースレッド、カスタムランタイム、組み込みエンジン)では、条件を適用したバージョンのシリアライザとReactをバンドルしない限り、シリアライザをロードできません。 + +本稿では、これら3つの結合をすべて取り除いたFlightプロトコルのスタンドアロン実装である`@lazarv/rsc`を紹介します。`react-server-dom-webpack`とワイヤーフォーマットの完全な互換性を保ちながら、いくつかのアーキテクチャ上の革新を導入しています。抽象的なモジュールリゾルバー/ローダーのインターフェース、Web Platform APIのみを使ったI/O、Reactを直接インポートしない`Symbol.for()`ベースのReact連携、データオブジェクトのインライン/アウトライン判定のための参照カウント、マイクロタスクで統合されるチャンクのフラッシュ、同期シリアライズモード、そしてデシリアライズ経路におけるゼロコピーの要素タプルスキャナーです。 + +13のベンチマークシナリオ全体で、`@lazarv/rsc`は`react-server-dom-webpack`に対してシリアライズで1.1倍〜6.5倍、デシリアライズで1.0倍〜11.2倍の性能を発揮し、ラウンドトリップでは1.0倍〜6.1倍の改善を示しています。 + + +## 問題: 3層の結合 + + +### バンドラーへの結合 + +`react-server-dom-webpack`は、クライアントとサーバーのマニフェスト(モジュール指定子からバンドル済み出力パスへのJSONマッピング)を生成するWebpackプラグインを必要とします。シリアライザは実行時にこのマニフェストを読み取り、`"use client"`と`"use server"`の参照を、クライアントとサーバーが正しいモジュールをロードするために使用するチャンクメタデータへと解決します。 + +```javascript +// react-server-dom-webpack/server — Webpackマニフェストが必要 +import { renderToReadableStream } from "react-server-dom-webpack/server"; + +// マニフェストはWebpackプラグインによって生成される +const manifest = require("./react-client-manifest.json"); +const stream = renderToReadableStream(, manifest); +``` + +Webpack以外のバンドラーに対しては、Reactチームが`react-server-dom-esm`(未完成)を提供しており、コミュニティもさまざまなアダプターシム(`react-server-dom-vite`など)を構築してきました。各アダプターは、マニフェスト形式をリバースエンジニアリングし、Webpackプラグインに相当するものを独自に提供しなければなりません。これにより、すべてのバンドラーが独自の統合コードを必要とする、断片化したエコシステムが生まれています。 + +### 実行環境への結合 + +公式パッケージは、環境ごとに分かれた4つのエントリーポイントを提供しています。 + +| エントリー | プラットフォーム | 使用API | +|---|---|---| +| `server.node` | Node.js | `stream.Readable`、`stream.Writable` | +| `server.edge` | エッジランタイム | `ReadableStream` | +| `client.browser` | ブラウザ | `ReadableStream`、`fetch` | +| `client.node` | Node.js SSR | `stream.Readable` | + +この4分割は、条件付きインポートの複雑さを生み出します。複数の環境(SSR + エッジ + ブラウザ)をターゲットとするフレームワークは、正しいエントリーポイントを動的に選択し、APIサーフェスの違いに対処し、各組み合わせを個別にテストしなければなりません。 + +### エクスポート条件への結合 + +ReactのFlightサーバーコードは、`react-server` Node.jsエクスポート条件の下に置かれています。 + +```json +{ + "exports": { + ".": { + "react-server": "./server.react-server.js", + "default": "./server.js" + } + } +} +``` + +この条件は、バンドラーまたはランタイムで設定する必要があります(Node.jsでは`--conditions=react-server`、Webpack/Viteでは`resolve.conditions`)。エクスポート条件をサポートしていない環境、あるいはこの特定の条件が設定されていない環境では、Flightサーバーをまったくインポートできません。これは最も厄介な結合です。なぜなら目に見えないからです。インポート自体は成功するものの誤ったエントリーポイントがロードされ、原因のわかりにくい実行時エラーを引き起こします。 + +`@lazarv/react-server`のように、Flightプロトコルを多様なコンテキスト(SSRのためのワーカースレッド、スナップショット保存のためのキャッシュプロバイダー、環境をまたぐ構造化ログのためのロガープロキシ)で使用するプロジェクトにとって、エクスポート条件の制約はアーキテクチャ上の根本的な障壁となります。 + + +## 設計目標 + + +`@lazarv/rsc`は、次の不変条件を掲げて設計されました。 + +1. **Flightプロトコルとの完全な互換性。** `react-server-dom-webpack`がサポートするすべての型 — 要素、フラグメント、Suspense、lazy、memo、forwardRef、コンテキスト、Activity、ViewTransition、Promise、Map、Set、Date、BigInt、RegExp、Symbol、URL、URLSearchParams、FormData、TypedArray、ArrayBuffer、DataView、Blob、ReadableStream、非同期イテラブル、クライアント/サーバー参照、バインド済みアクション、一時参照、エラーダイジェストの伝播 — が同一にシリアライズ・デシリアライズされること。 +2. **バンドラー非依存。** Webpackプラグインも、Viteプラグインも、バンドラーのマニフェストも不要。モジュール解決は、利用者側が提供する抽象インターフェースであること。 +3. **実行環境非依存。** すべての環境で単一のコードパス。`ReadableStream`、`TextEncoder`、`TextDecoder`、`FormData`、`Blob`、`URL`といったWeb Platform APIのみの上に構築すること。`stream.Readable`、`Buffer`、`AsyncLocalStorage`は使わないこと。 +4. **`react-server`条件が不要。** 特別なエクスポート条件の設定なしに、あらゆる環境でシリアライザが動作すること。 +5. **Reactを直接インポートしない。** パッケージはいかなるレベルでも`react`から`import`しないこと。React連携は`Symbol.for()`と、呼び出し時に任意で渡されるReactインスタンスを通じて行うこと。 +6. **同等以上のパフォーマンス。** 代表的なワークロード全体で、実装が公式パッケージと少なくとも同等の速度であること。 +7. **同期モード。** Promiseやストリーミングを伴わないユースケース(キャッシュスナップショット、ロガーのペイロード)向けに、完全に同期的なシリアライズ/デシリアライズの経路を提供すること。 + + +## アーキテクチャ + + +パッケージは、2つのエントリーポイントと4つのソースファイルで構成されています。 + +| エントリー | ソース | 役割 | +|---|---|---| +| `@lazarv/rsc/server` | `server/index.mjs` → `server/shared.mjs` | シリアライズ: `renderToReadableStream`、`syncToBuffer`、`prerender`、`decodeReply`、参照の登録 | +| `@lazarv/rsc/client` | `client/index.mjs` → `client/shared.mjs` | デシリアライズ: `createFromReadableStream`、`createFromFetch`、`syncFromBuffer`、`encodeReply`、サーバー参照プロキシ | + +`index.mjs`ファイルは再エクスポート用のバレルです。すべてのロジックは`shared.mjs`ファイルにあり、サーバー側が約3,300行、クライアント側が約3,500行です。プラットフォームごとの条件分岐も、動的な`require()`呼び出しも、開発モードフラグ以外の環境検出もありません。 + +### Reactを切り離す戦略 + +最も根本的な設計判断は、Reactをインポートせずに、どうやってReactとやり取りするかです。 + +ReactのFlightプロトコルは、Reactの要素型(`$$typeof`シンボル)、内部データ構造(lazyペイロード、コンテキストオブジェクト)、そしてクライアントコンポーネントのレンダリングにおいてはReact内部のフックディスパッチャーを扱います。公式の`react-server-dom-webpack`はこれらを`react`から直接インポートしており、それが`react-server`条件への依存を生み出しています。 + +`@lazarv/rsc`は、これを回避するために3つの戦略を採用しています。 + +**戦略1: 型検出に`Symbol.for()`を使う。** + +Reactの要素型は、`Symbol.for()`で登録されたグローバルシンボルです。どのReactのコピーがロードされているかにかかわらず、あらゆるコードがそれらを検出できます。 + +```javascript +const REACT_ELEMENT_TYPE = Symbol.for("react.element"); +const REACT_TRANSITIONAL_ELEMENT_TYPE = Symbol.for("react.transitional.element"); +const REACT_FRAGMENT_TYPE = Symbol.for("react.fragment"); +const REACT_SUSPENSE_TYPE = Symbol.for("react.suspense"); +const REACT_CLIENT_REFERENCE = Symbol.for("react.client.reference"); +const REACT_SERVER_REFERENCE = Symbol.for("react.server.reference"); +// ... 15以上の追加の型シンボル +``` + +`Symbol.for()`はすべてのレルム(ワーカースレッドやiframeを含む)で同じシンボルを返すため、このアプローチは`instanceof`チェックを悩ませるReactの複数コピー問題の影響を受けません。 + +**戦略2: 要素の構造的なダックタイピング。** + +`React.createElement()`を呼び出す代わりに、シリアライザはオブジェクトを構造的に検査します。 + +```javascript +function isReactElement(value) { + return ( + value !== null && + typeof value === "object" && + (value.$$typeof === REACT_ELEMENT_TYPE || + value.$$typeof === REACT_TRANSITIONAL_ELEMENT_TYPE) + ); +} +``` + +これは、あらゆるReactバージョン(18、19、experimental)と、あらゆるJSX変換(classic、automatic、手動の`createElement`呼び出し)で作成された要素に対して機能します。 + +**戦略3: フック用の任意のReactインスタンス。** + +フック(`use()`、`useId()`、`useMemo()`、`useCallback()`、`useEffect()`)を使用するクライアントコンポーネントには、React内部のディスパッチャーが必要です。`@lazarv/rsc`はReactをインポートする代わりに、オプションで任意の`react`インスタンスを受け取ります。 + +```javascript +const stream = renderToReadableStream(, { + react: React, // 任意 — コンポーネントがフックを使う場合のみ必要 +}); +``` + +インスタンスが渡された場合、シリアライザは`React.__SERVER_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE`(またはクライアント側の同等物)を通じてReact内部のディスパッチャーにアクセスします。渡されなかった場合、純粋なサーバーコンポーネント(フックなし)は通常どおり動作し、フックの使用は明確なエラーをスローします。 + +このオプトイン方式により、`@lazarv/rsc`はプレーンなデータ構造、Reactの要素、さらには既存のFlightペイロードの再シリアライズまでを、Reactへの依存を一切持たずに行えます。 + +### 抽象モジュールインターフェース + +`react-server-dom-webpack`がWebpackのマニフェストを使う場面で、`@lazarv/rsc`は2つの抽象インターフェースを使います。 + +```typescript +// サーバー側: 参照をメタデータへ解決する方法 +interface ModuleResolver { + resolveClientReference?(reference: unknown): ClientReferenceMetadata | null; + resolveServerReference?(reference: unknown): ServerReferenceMetadata | null; +} + +// クライアント側: メタデータからモジュールをロードする方法 +interface ModuleLoader { + preloadModule?(metadata: ClientReferenceMetadata): Promise | void; + requireModule(metadata: ClientReferenceMetadata): unknown; + loadServerAction?(id: string): Promise | Function; +} +``` + +フレームワーク(または任意の利用者)がこれらの実装を提供します。`@lazarv/react-server`は、Viteが生成するモジュールグラフに接続する形で実装しています。別のフレームワークであれば、Rspackのマニフェスト、インポートマップ、その他どんなモジュールシステムに対しても実装できます。Flightプロトコル自体は関知しません。 + + +## シリアライズエンジン + + +### 参照カウントと重複排除 + +シリアライズの前に、`@lazarv/rsc`はモデルツリー全体を事前スキャンし、各オブジェクトや配列が何回参照されているかをカウントします。 + +```javascript +function countReferences(model) { + const counts = new Map(); + const stack = [model]; + + while (stack.length > 0) { + const value = stack.pop(); + if (value === null || value === undefined) continue; + if (typeof value !== "object") continue; + + // 常に個別チャンクとして出力される型はスキップ + if (value instanceof Date || value instanceof RegExp || + ArrayBuffer.isView(value) || /* ... */) continue; + + const count = (counts.get(value) || 0) + 1; + counts.set(value, count); + if (count > 1) continue; // 子は走査済み + + // 型(配列、Map、Set、要素、オブジェクト)に応じて子を走査 + // ... + } + return counts; +} +``` + +この$O(n)$の事前スキャンが、重要な最適化を可能にします。それが**インライン/アウトラインの判定**です。ちょうど1回だけ参照されるオブジェクトは、親のJSON行に直接インライン化されます。複数回参照されるオブジェクトは、独自のIDを持つ個別チャンクとして出力され、以降の参照は`$`による後方参照を使います。これにより、チャンク数とペイロードサイズを最小化しながら、クライアント側でのオブジェクト同一性が保たれます。 + +なお、クライアント参照とサーバー参照の重複排除(同一クライアントコンポーネントに対する`I`行の繰り返しや、同一アクションに対するサーバー参照チャンクの繰り返しをまとめること)は、`react-server-dom-webpack`と共通の標準的な動作です。事前スキャンによる参照カウントはそれとは別の最適化で、*データ*層(プレーンなオブジェクトと配列)を対象に動作し、インライン化できるか、個別チャンクとしてアウトライン化すべきかを判定します。 + +### `serializeValue`ディスパッチ + +コアとなるシリアライズ関数は、Flightでシリアライズ可能なすべての型を扱う360行の型ディスパッチャーです。ディスパッチの順序はパフォーマンス上きわめて重要で、最も頻出する型が先にチェックされます。 + +``` +null → undefined → boolean → number (NaN/±Infinity/−0を含む) → +string (大きな文字列のTEXT行最適化を含む) → bigint → RegExp → symbol → +一時参照 → クライアント参照 → サーバー参照 → 関数 → +配列 (インライン vs. アウトライン) → React要素 → Promise → Date → Map → Set → +ReadableStream → Blob → 非同期イテラブル → TypedArray → ArrayBuffer → +FormData → URL → URLSearchParams → Error → プレーンオブジェクト (インライン vs. アウトライン) +``` + +各型は特定のワイヤーフォーマットのエンコーディングに対応します。エンコーディングには、公式プロトコルと一致する1文字のプレフィックス(Dateは`$D`、Mapは`$Q`、Setは`$W`、BigIntは`$n`、Symbolは`$S`など)が使われます。 + +### 大きな文字列の最適化 + +1KBを超える文字列は、JSONの代わりに長さプレフィックス付きのバイナリ行形式でシリアライズされます。 + +```javascript +if (value.length >= TEXT_CHUNK_SIZE) { + const id = request.getNextChunkId(); + const textBytes = encoder.encode(value); + const hexLength = textBytes.byteLength.toString(16); + const headerStr = `${id}:T${hexLength},`; + // ... バイナリチャンクとして出力 ... + return "$" + id; +} +``` + +これにより、大きな文字列に対する`JSON.stringify()`のオーバーヘッド(クォート処理、エスケープ文字の処理、余分なアロケーション)を回避できます。16進数の長さプレフィックスによってパーサーは読み取るべきバイト数を正確に把握でき、デシリアライズ側でのゼロコピー消費が可能になります。 + +### マイクロタスクで統合されるチャンクのフラッシュ + +同期的なシリアライズの間には、複数の行(ルートモデル、共有オブジェクトのチャンク、クライアント参照のチャンク、サーバー参照のチャンク)が生成されます。公式実装は各行を個別にフラッシュするため、1行につき1回の`ReadableStream.enqueue()`呼び出しが発生します。 + +`@lazarv/rsc`は、同期処理中のフラッシュを抑制し、すべての行を単一の`enqueue()`呼び出しにまとめます。 + +```javascript +function startWork(request) { + // writeChunkごとのフラッシュを抑制する + const wasFlowing = request.flowing; + request.flowing = false; + + try { + const serialized = serializeValue(request, request.model, null, null); + const row = request.serializeModelRow(0, serialized); + request.writeChunk(row); + + // flowingを復元し、すべての行を一括でフラッシュする + request.flowing = wasFlowing; + if (request.flowing && request.destination) { + request.flushChunks(); + } + } catch (error) { + // ... + } +} +``` + +フラッシュ自体は、バッファされたすべてのチャンクを単一の`Uint8Array`に統合します。 + +```javascript +flushChunks() { + if (this.completedChunks.length === 0) return; + const chunks = this.completedChunks; + this.completedChunks = []; // 再入安全のためスワップを先に行う + + // エンコードしてマージ + const encoded = Array.from({ length: chunks.length }); + let totalLength = 0; + for (let i = 0; i < chunks.length; i++) { + const chunk = chunks[i]; + encoded[i] = chunk instanceof Uint8Array ? chunk : encoder.encode(chunk); + totalLength += encoded[i].length; + } + + if (encoded.length === 1) { + this.destination.enqueue(encoded[0]); + } else { + const merged = new Uint8Array(totalLength); + let offset = 0; + for (const e of encoded) { merged.set(e, offset); offset += e.length; } + this.destination.enqueue(merged); + } +} +``` + +これにより、消費側での`ReadableStream`の読み取り回数が減り、(Flightデータをインライン化する場合の)SSR HTML内の`