DOMレンダリング:テンプレート、制御フローブロック、ハイドレーション
前提知識
- ›第2回:コンパイラパイプライン(コンパイル出力の理解)
- ›第3回:リアクティビティエンジン(シグナル、エフェクト、バッチスケジューラー)
- ›DOM API(cloneNode、importNode、コメントノード、ツリーウォーキング)
- ›サーバーサイドレンダリングとハイドレーションの基本概念
DOMレンダリング:テンプレート、制御フローブロック、ハイドレーション
コンパイラがJavaScriptを生成し、リアクティビティエンジンが変更を追跡する — その最後のピースが、SvelteがどのようにDOMを実際に操作するかです。Svelteのアプローチは独自性があります。HTMLテンプレートをあらかじめ生成し、効率よくクローンし、そこへリアクティブなエフェクトを接続して特定のノードを更新していきます。この記事では、テンプレートのインスタンス化からハイドレーションまでの流れを順に追っていきます。
テンプレートのインスタンス化:from_html() と from_tree()
第2回で見たように、コンパイラは静的なHTMLからテンプレート文字列を生成します。実行時には、from_html() がその文字列をクローン可能なDOMノードに変換します。
export function from_html(content, flags) {
var is_fragment = (flags & TEMPLATE_FRAGMENT) !== 0;
var use_import_node = (flags & TEMPLATE_USE_IMPORT_NODE) !== 0;
var node;
return () => {
if (hydrating) {
assign_nodes(hydrate_node, null);
return hydrate_node;
}
if (node === undefined) {
node = create_fragment_from_html(content);
if (!is_fragment) node = get_first_child(node);
}
var clone = use_import_node || is_firefox
? document.importNode(node, true)
: node.cloneNode(true);
// ...
};
}
この関数はクロージャを返します。最初の呼び出しでは、一時的な要素の innerHTML を使ってHTML文字列をDOMツリーにパースします。2回目以降は、そのツリーを cloneNode(true) でクローンします。これはテンプレートクローニングの定番最適化です。HTMLのパースは一度だけ行い、クローンは何度でも繰り返します。
flowchart TD
First["First call"] --> Parse["create_fragment_from_html(content)<br/>innerHTML parsing"]
Parse --> Store["Store as 'node'"]
Subsequent["Subsequent calls"] --> Clone["node.cloneNode(true)"]
Clone --> DOM["Insert into DOM"]
Hydrating["During hydration"] --> Reuse["Return existing hydrate_node<br/>Skip cloning entirely"]
Firefoxへの特別な対応も入っています。ブラウザのバグを回避するため、cloneNode ではなく document.importNode を使います。TEMPLATE_USE_IMPORT_NODE フラグは、このワークアラウンドが必要な場合にコンパイラが立てる目印です。
template.js#L40-L45 の assign_nodes() 関数は、クローンされたDOMノードを現在のエフェクトに紐付け、ブロックの更新時にDOM操作の基点となる { start, end } アンカーを生成します。
Tips: テンプレート文字列の先頭にある
<!>は、コメントノードを生成するための記法です。これは動的コンテンツの開始位置をランタイムが追跡するためのマーカーとして機能します。コンパイル出力で<!>を見かけたら、それがその役割を担っています。
コンポーネントのマウントとハイドレーション
コンポーネントをDOMに表示するための公開APIは2つあります。新規レンダリング用の mount() と、サーバーレンダリング済みのHTMLを引き継ぐ hydrate() です。どちらも内部で _mount() に処理を委譲します。
hydrate() の実装は特に興味深い点があります。ターゲット要素の中からサーバーレンダリングコンテンツの開始を示す <!--[--> コメントマーカーを探します。見つかれば hydrating = true を設定し、既存のDOMのウォーキングを開始します。何か問題が起きた場合 — ノードの不一致や欠落など — は HYDRATION_ERROR をキャッチして警告を出し、ターゲットをクリアしてクライアントサイドの mount() にフォールバックします。
export function hydrate(component, options) {
try {
var anchor = get_first_child(target);
while (anchor && anchor.nodeType !== COMMENT_NODE || anchor.data !== HYDRATION_START) {
anchor = get_next_sibling(anchor);
}
set_hydrating(true);
set_hydrate_node(anchor);
const instance = _mount(component, { ...options, anchor });
set_hydrating(false);
return instance;
} catch (error) {
// Fallback to client-side rendering
clear_text_content(target);
set_hydrating(false);
return mount(component, options);
}
}
163行目 の _mount() は、boundary() を通じてコンポーネントを component_root エフェクトでラップし、コンパイル済みのコンポーネント関数をアンカーノードとpropsとともに呼び出します。このコンポーネント関数 — つまりコンパイラの出力 — がすべてのリアクティブステートとDOMエフェクトをセットアップします。
sequenceDiagram
participant App as Application
participant Mount as mount() / hydrate()
participant Root as component_root
participant Component as Compiled Component
participant Runtime as $.state, $.if, etc.
participant DOM as Browser DOM
App->>Mount: mount(Component, { target })
Mount->>Root: component_root(() => ...)
Root->>Component: Component(anchor, props)
Component->>Runtime: $.state(), $.from_html(), $.if()
Runtime->>DOM: cloneNode, appendChild, effects
制御フローブロック:{#if}、{#each}、{#key}
制御フローブロックは、リアクティビティエンジンとDOMが交差する場所です。各ブロックタイプは dom/blocks/ にランタイム実装を持ちます。
if_block() は BranchManager とブロックエフェクトを生成します。条件が変化すると、update_branch() がキー(ブランチのインデックス)とレンダー関数とともに呼び出されます。BranchManager がその遷移を管理します。古いブランチを一時停止し(outroトランジションを発火させ)、新しいブランチを生成して、DOMへの挿入位置を制御します。
export function if_block(node, fn, elseif = false) {
var branches = new BranchManager(node);
var flags = elseif ? EFFECT_TRANSPARENT : 0;
block(() => {
fn((child_fn, key) => {
update_branch(key, child_fn);
});
}, flags);
}
each ブロック はさらに複雑です。2種類の差分更新戦略を使い分けます。
- キーあり(Keyed) — 各アイテムに一意のキーがあります。リストが変化すると、ランタイムは新旧のアイテムをキーで照合し、DOMノードを並べ替え、変更があった部分だけを生成・破棄します。
- キーなし(Unkeyed) — アイテムをインデックスで照合します。シンプルですが、並べ替えが発生してもステートを保持できません。
{#each} ブロックの各アイテムは、アイテムの値とインデックスのリアクティブソースを持つ独自のブランチエフェクトを持ちます。EACH_ITEM_REACTIVE、EACH_INDEX_REACTIVE、EACH_IS_ANIMATED、EACH_IS_CONTROLLED は、テンプレートの使用状況に基づいてコンパイラがセットするビットフラグです。
flowchart TD
Each["$.each(node, flags, items_fn, render_fn)"]
Each --> Block["block effect<br/>(re-runs when items change)"]
Block --> Reconcile{"Keyed?"}
Reconcile -->|yes| Keyed["Match by key,<br/>reorder DOM nodes,<br/>create/destroy as needed"]
Reconcile -->|no| Unkeyed["Match by index,<br/>update in place,<br/>add/remove at end"]
Keyed --> Items["Each item:<br/>branch effect + reactive sources"]
Unkeyed --> Items
バウンダリー:エラーハンドリングと非同期サスペンス
boundary ブロック は Svelte 5 の比較的新しい機能のひとつです。コンポーネントツリーの特定のセクションをラップし、レンダリング中にスローされたエラーをキャッチしてフォールバックUIを提供します。
// Props shape:
{
onerror?: (error: unknown, reset: () => void) => void;
failed?: (anchor: Node, error: () => unknown, reset: () => () => void) => void;
pending?: (anchor: Node) => void;
}
バウンダリーは3つの状態を持ちます。
- Normal — 子コンポーネントが正常にレンダリングされている状態
- Pending — 非同期処理の完了を待機している状態(サスペンス)
- Failed — エラーがキャッチされた状態。
failedスニペットを表示します
バウンダリーエフェクトは BOUNDARY_EFFECT | EFFECT_TRANSPARENT | EFFECT_PRESERVED フラグを使います。EFFECT_PRESERVED フラグは、エフェクトツリーの最適化パスでバウンダリー自体が削除されないよう保護します。エラーが発生すると、バウンダリーは子コンポーネントを破棄し、エラーと reset コールバックを渡して failed スニペットをレンダリングし、エラーの伝播をそこで止めます。
stateDiagram-v2
[*] --> Normal: Initial render
Normal --> Failed: Error caught
Normal --> Pending: Async work started
Pending --> Normal: Async work completed
Pending --> Failed: Async work failed
Failed --> Normal: reset() called
ハイドレーションプロトコル
ハイドレーションとは、サーバーレンダリング済みのHTMLにクライアントサイドのリアクティビティを接続するプロセスです。Svelteのプロトコルは、SSR時に埋め込まれるコメントマーカーを使います。
ハイドレーションの状態は hydration.js で管理されます。進行状況を追跡する変数は2つあります。
hydrating— ハイドレーションモード中であることを示すboolean型フラグhydrate_node— 現在ハイドレート中のDOMノードを指すカーソル
hydrating が true の場合、from_html() などのテンプレート関数はクローン処理を完全にスキップし、既存の hydrate_node をそのまま返します。hydrate_next() 関数はカーソルを次の兄弟ノードへ進め、reset() 関数はブロックの処理後に予期しない兄弟ノードが残っていないかを検証します。
制御フローブロックでは、サーバーが命令マーカーを埋め込みます。[0 は「ブランチ0がレンダリングされた」、[-1 は「elseブランチ」を意味します。クライアントはこれを読み取り、サーバーがどのブランチを選択したかを判断します。不一致が発生した場合(例:{#if browser} がサーバーとクライアントで異なるマークアップを生成するケース)、ランタイムはそのブロックのサーバーコンテンツを取り除き、クライアントレンダリングにフォールバックします。
sequenceDiagram
participant SSR as Server HTML
participant Hydrate as hydrate()
participant Cursor as hydrate_node
participant Block as if_block
SSR->>Hydrate: <!--[--> content <!--]-->
Hydrate->>Cursor: set to first child after <!--[-->
Cursor->>Block: hydrate_node = <!--[0-->
Block->>Block: read_hydration_instruction() → "[0"
Block->>Block: Key matches? Create branch, walk children
Block->>Cursor: advance past <!--]-->
Tips: スタックトレースに
HYDRATION_ERRORが表示された場合、クライアントがサーバーレンダリング済みHTMLとの構造的な不一致を検出したことを意味します。typeof window !== 'undefined'のようなブラウザ固有のチェックが、サーバーとクライアントで異なるマークアップを生成していないか確認してみましょう。
SSR:Renderer クラス
サーバーサイドでは、Renderer クラス がHTMLを文字列のツリーとして蓄積していきます。クライアントのランタイムとは根本的に異なります。シグナルも、エフェクトも、DOMも存在しません。ただの文字列結合です。
export class Renderer {
#out = []; // string | Renderer items
type; // 'head' | 'body'
promise; // for async rendering
push(content) { /* append string to #out */ }
child(fn) { /* create nested Renderer, call fn(renderer) */ }
head(fn) { /* type: 'head' のサブ Renderer を作成 */ }
// ...
}
Renderer がツリー構造を持つのは、コンテンツがページの異なる部分をターゲットにできるためです。<svelte:head> のコンテンツは type: 'head' の子Rendererに入り、通常のコンテンツは type: 'body' にとどまります。レンダリングが完了すると、このツリーは「収集」されます。再帰的に結合され、最終的な { head: string, body: string } の結果として出力されます。
サーバーランタイムの render() 関数 は Renderer インスタンスを生成し、コンパイル済みのサーバーコンポーネントを呼び出します。コンポーネントはHTML文字列をそこへプッシュしていきます。
export function render(component, options = {}) {
return Renderer.render(component, options);
}
index.js#L40-L61 のサーバー版 element() は、開閉タグを文字列として直接プッシュし、動的な要素には <!----> のハイドレーションマーカーを埋め込みます。
クライアント vs サーバー:2つのパス、1つのコンポーネント
同一の .svelte コンポーネントでも、ターゲットによってコンパイル出力は異なります。第1回で触れたように、コンパイラはtransformフェーズで処理を分岐させます。シンプルなコンポーネントで具体的に比べてみましょう。
クライアント出力(簡略版):
import * as $ from 'svelte/internal/client';
var root = $.from_html(`<h1><!></h1>`);
export default function Component($$anchor) {
let count = $.state(0);
var h1 = root();
var text = $.child(h1);
$.template_effect(() => $.set_text(text, $.get(count)));
$.append($$anchor, h1);
}
サーバー出力(簡略版):
import * as $ from 'svelte/internal/server';
export default function Component($$renderer, $$props) {
let count = 0; // no reactivity needed
$$renderer.push(`<h1>${$.escape(count)}</h1>`);
}
サーバー版は格段にシンプルです。シグナルも、エフェクトも、DOM操作もありません。値はただのJavaScriptです。Renderer がHTML文字列を蓄積していきます。onMount のようなライフサイクルフックはno-opになります(第1回で見たように、index-server.js でスタブ化されています)。
この二重性は、package.json の条件付きexportsシステムによって維持されています。svelte/internal/client と svelte/internal/server は完全に独立したモジュールツリーです。属性レンダリングやHTMLエスケープのような処理に限り、svelte/internal/shared のユーティリティを共有しています。
flowchart TD
Component[".svelte Component"]
Component -->|"generate: 'client'"| Client["Client JS<br/>$.state(), $.from_html(),<br/>$.template_effect()"]
Component -->|"generate: 'server'"| Server["Server JS<br/>renderer.push(),<br/>$.escape()"]
Client --> ClientRuntime["svelte/internal/client<br/>Signals, Effects, DOM"]
Server --> ServerRuntime["svelte/internal/server<br/>Renderer, string concat"]
次回の内容
これで .svelte ソースから実際に動作するDOMまでのパイプライン全体を追い終えました。最終回では、開発体験を支える周辺システムを探っていきます。公開されているリアクティブユーティリティクラス、Svelte 4の移行ツール、オーナーシップトラッキングやHMRといった開発モードの機能、トランジション・アニメーションのランタイム、そしてテストインフラについて解説します。