JavaScriptを使わずにページを更新する
template forと処理命令を使い、JavaScriptなしでHTMLを更新する方法を解説。Nodeの例、ブラウザー対応、フォールバックと制限も紹介します。
<template for="name"> 要素は、同じドキュメント内でそれより前に同名の処理命令(processing instruction)でマークされたプレースホルダーを埋めます。サーバーは時間のかかるセクションを同じHTMLレスポンスの末尾で送信し、ブラウザーのパーサーがそれらを所定の位置へ移動させます。スクリプトは一切実行されません。
サーバーレンダリングされるページの多くには、注文件数やステータスチェックなど、表示に時間のかかる領域が1つあります。一般的な対処法は、ローディング用のdivを置き、後からフラグメントを取得するスクリプトを組み合わせることです。多くのチームが削除したいと考えているのは、まさにこのスクリプトの部分です。
本記事では、マークアップ、更新をストリーミングする実行可能なNodeサーバー、htmxと比較したこのパターンの位置付け、そして未対応ブラウザーでの表示内容について解説します。
重要なポイント
<template for>は、HTMLパーサーがその要素に到達した時点で、<?start name="x">…<?end>の範囲を置き換えるか、<?marker name="x">の挿入位置を埋めます。- サーバーはまずページのシェルをフラッシュし、同じレスポンス内で後からテンプレートを書き込む必要があります。レスポンス全体をバッファリングする層があると、プレースホルダーとその置き換えが同時に届いてしまいます。
- Chrome 150では、アウトオブオーダー・ストリーミングがデフォルトで有効になっています。
streamHTMLなどの関連するJavaScriptメソッドはChrome 155で導入されます。それ以前のバージョンではフラグが必要です。 - 未対応のブラウザーは処理命令をコメントとして解析するため、フォールバックコンテンツが画面に表示されたままになります。
課題:取得、検索、差し替え
これまで、サーバーレンダリングされたページの一部を更新するには、クライアント側で3つのステップが必要でした。フラグメントを取得し、対象ノードを見つけ、その内容を差し替えるという手順です。次のような最小限の例でも、スクリプト、2回目のリクエスト、DOMの検索が必要になります。
<div id="status">Loading…</div>
<script type="module">
const res = await fetch('/status.html');
document.querySelector('#status').innerHTML = await res.text();
</script>
サーバーは最初のレスポンスをレンダリングしている時点で、すでにステータスを把握していました。余分なラウンドトリップが発生するのは、HTMLが常にドキュメントの順序どおりに解析されてきたからに過ぎません。
マークアップにおける宣言的部分更新の仕組み
宣言的部分更新(Declarative Partial Updates)では、処理命令を名前付きのアンカーとして、<template for> をパッチとして使用します。Chromeの宣言的部分更新に関する発表では、3種類のアンカーが定義されています。<?marker name="x"> は挿入位置です。<?start name="x"> と <?end> は、置き換え対象となる範囲を囲みます。
プレースホルダーにフォールバックコンテンツを表示する場合は、<?start>…<?end> のペアを使用します。対応するテンプレートが到着すると、2つの処理命令の間にあるものがすべて置き換えられます。単独の <?marker> は位置を示すだけなので、その隣に置いたフォールバックテキストが削除されることはありません。
<div id="status"><?start name="status">Loading…<?end></div>
<!-- later in the same response -->
<template for="status">All systems <strong>normal</strong></template>
テンプレートが解析されると、DOMは次のようになります。
<div id="status">All systems <strong>normal</strong></div>
Chromeのドキュメントでは、要素ではなく処理命令が採用された理由が説明されています。処理命令を介したパッチは余分なノードを残さないため、新しい要素を追加すると問題が生じる場所、たとえば <head>(<title> を変更する場合など)や <table> の内部でも機能します。
サーバーは何を送信するのか
サーバーはシェル全体を即座に書き込み、データを待つ間はレスポンスを開いたままにし、ドキュメントを閉じる前に各 <template for> を書き込みます。以下の例では node:http とESモジュールのみを使用しています。server.mjs として保存し、node server.mjs を実行してください。
// server.mjs — run with: node server.mjs, then open http://localhost:3000
import { createServer } from 'node:http';
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
async function getStatus() { await sleep(1000); return 'All systems normal'; }
async function* getOrders() {
for (const o of ['Order #1001', 'Order #1002', 'Order #1003']) {
await sleep(700);
yield o;
}
}
createServer(async (req, res) => {
if (req.url !== '/') { res.writeHead(404).end(); return; }
res.writeHead(200, {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store',
});
// 1. Flush the shell immediately.
res.write(`<!doctype html>
<html><head><meta charset="utf-8"><title>Orders</title></head>
<body>
<h1>Your orders</h1>
<p id="status"><?start name="status">Checking status…<?end></p>
<ul id="orders"><?start name="orders"><li>Loading orders…</li><?end></ul>
`);
// 2. Templates are direct children of <body>, so they can reach the placeholders above.
const status = await getStatus();
res.write(`<template for="status">${esc(status)}</template>\n`);
// 3. Each template appends one item and leaves a fresh marker for the next.
for await (const order of getOrders()) {
res.write(`<template for="orders"><li>${esc(order)}</li><?marker name="orders"></template>\n`);
}
// 4. Close the document only after the last template.
res.end('</body></html>');
}).listen(3000);
response.write() を呼び出すたびに、end() を待たずにボディのチャンクが送信されます。チャンク化された出力を受け取る側の背景知識については、Web Streamsの解説記事を参照してください。sleep の呼び出しは時間のかかるクエリの代わりであり、ベンチマークではありません。
esc() ヘルパーを用意しているのは、テンプレートの内容が通常のHTMLだからです。埋め込むデータはエスケープしない限り、マークアップを注入される可能性があります。
ストリームが完了すると、#orders には3つの <li> 要素と、その後ろに続く <?marker name="orders"> が含まれます。Chrome 150以降でページを開くと、リストが順次埋まっていく様子を確認できます。
バッファリングはアウトオブオーダー・ストリーミングを無効化する
アウトオブオーダー・ストリーミングが効果を発揮するのは、バイトが時間をかけて順次ブラウザーに届く場合だけです。レスポンス全体を留め置いてしまう一般的な層は3つあります。バッファが満杯になるまで待つ圧縮ミドルウェア、レスポンスバッファリングが有効なリバースプロキシやCDN、そして何かを送信する前にページ全体をレンダリングするフレームワークです。各層を経由して curl -N http://localhost:3000 を実行し、チャンクが個別に届くことを確認してください。-N フラグはcurlの出力バッファリングを無効にします。セッションリプレイを使えば、ユーザー側から見た結果を確認できます。たとえば、プロキシがストリームを保持していたために、ページ全体が一度に届くまで画面が真っ白なままになっている、といった状況です。
ユースケースとその限界
宣言的パッチはサーバーがドキュメントを書き込んでいる間しか機能しないため、レスポンスの存続期間がそのまま更新可能な期間となります。
- ライブリスト。 各テンプレートの末尾に同名の新しい
<?marker>を置くことで、後続のテンプレートがその位置に追加し続けられます。リストがライブであり続けるのは、接続が開いている間だけです。 - ステータス行。 上記の例のように、1つの
<?start>/<?end>範囲と1つのテンプレートで構成します。 - フォームの結果。 通常の
POSTナビゲーションで返されるページのシェルが即座にレンダリングされ、処理が完了した時点で結果がストリーミングされます。
ページ読み込み後のクリックに反応するもの(フィルター、インライン編集、楽観的UIなど)には、引き続きクライアント側の状態管理、JavaScript、またはライブラリが必要です。
宣言的部分更新とhtmxの比較
htmxは、ユーザーイベントに応じて新しいHTTPリクエストを送信し、返されたフラグメントをページに差し込みます。<template for> は、すでにストリーミング中のレスポンス内でのみ機能します。サーバーがそのレスポンスを終了した後の更新には、JavaScriptか新しいナビゲーションが必要です。
htmxはあくまでJavaScriptライブラリです。htmx 4.0 では <hx-partial> タグが追加され、1つのレスポンスから複数の要素をターゲットにできるようになりました。htmxによる無限スクロール(ローダー要素がスクロールで表示されるとhtmxがサーバーから次のページを取得する)やhtmxによるフォームバリデーションといったパターンは、ユーザーが発生させるリクエストに依存しています。
| htmx | <template for> | |
|---|---|---|
| トリガー | ユーザーイベント | サーバーの書き込み順序 |
| リクエスト | 操作ごとに1回 | 1つのレスポンス |
| 実行場所 | JavaScriptライブラリ | HTMLパーサー |
| ターゲット指定 | CSSセレクター属性 | 名前付き処理命令 |
| レスポンス終了後の更新 | 可能 | 不可 |
それでもJavaScriptが必要な場合
読み込み後に届くコンテンツ向けに、Chromeは streamHTML や appendHTML などの関連メソッドとその Unsafe バリアントを追加し、既存の setHTML も拡張しています。開発者はChrome 148から chrome://flags/#enable-experimental-web-platform-features を有効にしてこれらを試すことができ、2026年9月16日にベータ版となったChrome 155に搭載されています。
const el = document.querySelector('#feed');
const response = await fetch('/feed-fragment.html');
await response.textStream().pipeTo(el.streamHTMLUnsafe());
response.textStream() はChrome 151以降で利用できます。それより古いバージョンでは、代わりに response.body.pipeThrough(new TextDecoderStream()) を使用してください。
streamHTMLUnsafe にマークアップをパイプすると、その中の <template for> はライブページにパッチを適用します。一方、setHTML や innerHTML は動作が異なります。これらはまず別のフラグメントに解析するため、内部のテンプレートがパッチを適用できるのはそのフラグメント内のノードだけであり、既存のページには適用されません。
ブラウザーサポート、フォールバック、制約事項
<template for> と、<?marker>、<?start>、<?end> 処理命令を用いたアウトオブオーダー・ストリーミングは、2026年6月30日に安定版となったChrome 150からデフォルトで有効になっています。この機能はChromeStatusで追跡されており、for 属性はWHATWG HTML Living Standardに含まれています。Chromium以外のブラウザーではまだ実装されていません。WebKitのstandards-positionsスレッドでは、WebKitのエンジニアがWebKitの立場を「support」とすることを提案しています。
Intent to Shipでは、未対応ブラウザーが処理命令をコメントとして解析するため、フォールバックコンテンツが画面に表示されたままになることが確認されています。標準の<template> 要素の内容はレンダリングされないため、それらのブラウザーではストリーミングされたデータが表示されることはありません。
| Chrome 150以降 | 未対応 | template-for-polyfill 使用時 | |
|---|---|---|---|
| フォールバックテキスト | 置き換えられる | 表示されたまま | 置き換えられる |
| ストリーミングされたコンテンツ | パッチ適用される | <template> 内に隠れる | パッチ適用される(パーサーの制限あり) |
Chromeはnpmで template-for-polyfill を公開しています。一般的なケースには対応していますが、ブラウザー独自のパーサーに介入する手段がないため、いくつかの制限が残ります。また、これ自体がJavaScriptであるため、すべてのブラウザーで動作させる必要がある「JSなし」のページでも、結局はスクリプトに依存することになります。別途提供されているhtml-setters-polyfillは新しいAPIのメソッド名を再現していますが、すべてのコンテンツを先に収集してから最後に挿入するため、ストリーミングは行いません。
<template for> について知っておくべき制約事項は次のとおりです。
- テンプレートが到達できるのは、同じ親要素を共有する処理命令のみです。テンプレートを
<body>の直下に配置すれば、<head>を含むドキュメント全体に到達できます。 <?end>は省略できます。省略した場合、置き換えは<?start>から親要素の終わりまでの範囲に適用されます。- テンプレートがストリーミング中の処理命令を移動すると、残りのコンテンツが元の位置に届き続ける可能性があります。
まとめ
アウトオブオーダー・ストリーミングは、「取得、検索、差し替え」という一連の処理をHTMLパーサーに移しますが、それが機能するのは1つの開いたレスポンス内に限られます。この手法を使う場合は、実際のデータを <?start>/<?end> のフォールバック範囲内に置き、未対応ブラウザーでも適切な内容が表示されるようにしてください。そして本番環境で採用する前に、実際に使用するプロキシやCDNの背後で server.mjs を実行し、チャンクが個別に届くかどうかを確認しましょう。
よくある質問
template forはReact Suspenseのストリーミングとどう違うのですか?
ReactのストリーミングSSRは、解決された各Suspense境界を非表示のdivとして送信し、その後にフォールバックと差し替えるインラインスクリプトを続けます。スクリプトが無効になっている場合や、Content Security Policyがnonceのないインラインスクリプトをブロックしている場合、そのコンテンツは所定の位置に移動されません。template forでは、HTMLパーサー自身が差し替えを行うため、スクリプトは実行されず、ページでインラインスクリプトを許可する必要もありません。
JavaScriptを使わずに、クロスブラウザーでHTMLをアウトオブオーダーでストリーミングする方法はありますか?
あります。名前付きスロットを使ったDeclarative Shadow DOMは、Chrome、Firefox、Safariで動作します。シェルはshadowrootmode='open'を指定したtemplateで、フォールバックコンテンツを含むslot要素を持ちます。一致するslot属性を持つ要素が後からストリーミングされ、それらのスロットを埋めます。トレードオフとして、シャドウルートは通常bodyに置かれるため、シャドウツリー内のシェルのマークアップにはドキュメントレベルのCSSが適用されません。
ページのシェルを送信した後に、時間のかかるクエリが失敗した場合はどうなりますか?
HTTPステータスとヘッダーは最初のチャンクが書き込まれた時点で確定するため、サーバーはもはや500エラーやリダイレクト、新しいCookieに切り替えることができません。代わりにマークアップで失敗を処理します。エラーメッセージを含む同名のテンプレートを書き込めば、ローディング中のフォールバックが置き換えられます。Nodeでは、ヘッダー送信後にres.setHeaderを呼び出すとERR_HTTP_HEADERS_SENTがスローされます。
Complete picture for complete understanding
Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.
Star on GitHub12k