DPUを使ってJavaScript無しでSPA風の部分更新を実現する
現代のWebフロントエンド開発において、SPAのようなインタラクティブな部分更新を実現するためには、ReactやVue、SvelteといったJavaScriptフレームワークやライブラリをロード・実行することが当たり前となっています。
しかし、ブラウザでJavaScriptが無効化されていたり、JSのダウンロードやハイドレーションによる遅延が発生したりする環境でも、SPAのような画面の部分更新を可能にする新しい実験的機能がChromeでテストされています。
それが Declarative Partial Updates (DPU) です。
本記事では、DPUの仕組みと、Cloudflare Workers + Durable Objectsを組み合わせてクライアント側に JavaScriptを1行も書かずに実現する「順不同ストリーミング天気予報」 のデモコードをもとに、その実装テクニックを解説します。

:::message
注意: 本デモは標準化前の実験的機能に依存しています。動作確認を行うには、Chrome 148以降で以下のフラグを有効にする必要があります。今後、仕様や構文が変わる可能性があり、他ブラウザでは未対応またはpolyfill前提です。
chrome://flags/#enable-experimental-web-platform-features
:::
1. DPU (Declarative Partial Updates) とは?🔗
通常、ブラウザはHTMLを上から順にパースして描画します。しかし、重いデータベースクエリやAPI連携がある場合、その部分のデータが揃うまでページ全体のロードが遅延したり、あるいは初期表示後にクライアントサイドのJavaScriptで非同期にデータを取得してDOMを書き換える必要がありました。
DPUは、HTMLのストリーミング中にサーバーから送られてきたテンプレートを使って、ブラウザのHTMLパーサ自身にDOMの部分置換(スワップ)を自動でやらせる技術です。
主要な要素は以下の2つです。
① 処理命令によるプレースホルダー(アンカー)🔗
HTMLの中に、スワップ対象となる境界を示すマーカー(Processing Instruction)を配置します。
1<?start name="my-content">2<p>読み込み中...</p>3<?end>
② 差し替え用テンプレート (<template for="...">)🔗
あとからストリームで送られてくるHTMLチャンクの中で、<template> 要素に for 属性を指定します。
1<template for="my-content">2 <div>差し替え後のコンテンツ</div>3</template>
ブラウザのHTMLパーサがこの <template for="my-content"> を検知すると、先にレンダリングされていた <?start name="my-content"> から <?end> までの範囲の内容を、テンプレートの中身へ自動的に差し替えます。
この処理はすべてブラウザのパーサ内部で行われるため、クライアントサイドJavaScriptによるDOM操作は一切不要です。
実際のストリームの流れ🔗
DPUは「あとから届いたHTML片で、すでに描画済みの場所を更新する」仕組みです。たとえば、最初に以下のHTMLが届いたとします。
1<div class="forecast-box">2 <?start name="weather-forecast">3 <p>読み込み中...</p>4 <?end>5 <!-- 接続を維持するため、ここではまだdivを閉じない -->6</div>
この時点では、ブラウザには「読み込み中...」が表示されます。その後、同じストリームに以下のチャンクが追記されます。
1<template for="weather-forecast">2 <?start name="weather-forecast">3 <p>東京: 晴れ</p>4 <?end>5</template>
すると、ブラウザのパーサが weather-forecast の範囲を見つけ、表示中の内容をテンプレートの中身へ差し替えます。差し替え後のDOMは、概念的には以下のようになります。
1<div class="forecast-box">2 <?start name="weather-forecast">3 <p>東京: 晴れ</p>4 <?end>5 <!-- 接続を維持するため、ここではまだdivを閉じない -->6</div>
更新後の中にも同じ <?start> / <?end> を残しているため、次に大阪のチャンクが届いたときも、同じ場所を再び差し替えられます。
2. デモアプリのアーキテクチャ🔗
この仕組みを使って「ユーザーがボタンを押したときに、JavaScriptなしで特定のエリアの天気予報だけを差し替える」というSPA風のインタラクティブな画面を構築します。
通信モデルは以下のようになります。
sequenceDiagram
autonumber
actor User as ユーザー (ブラウザ)
participant Worker as Cloudflare Worker
participant DO as Durable Object
User->>Worker: 1. アクセス (GET /)
Worker->>DO: 2. 接続確立 (/connect?sessionId=xxx)
DO-->>User: 3. 初期HTMLを長時間ストリームで返却 (接続維持)
Note over User, DO: 接続を維持したまま、ユーザーが地域「東京」をクリック
User->>Worker: 4. 隠しiframe経由でリクエスト (GET /select?region=tokyo)
Worker->>DO: 5. トリガーを中継 (/trigger?region=tokyo)
Note over DO: 気象庁API等からデータ取得
DO-->>User: 6. 既存ストリームに <template for="..."> チャンクを追記
Note over User: ブラウザがパーサで検知し、天気を部分更新!
なぜ Durable Objects が必要なのか?🔗
Cloudflare Workersでもストリーミングレスポンス自体は扱えます。しかし、このデモでは「最初に開いたHTMLレスポンスの WritableStream に、あとから別リクエストをきっかけに追記する」必要があります。
Durable Objectsを使用することで、特定のユーザーセッション(sessionId)に対応するステートフルなインスタンスをメモリ上に維持し、同じセッションのHTMLストリーム(WritableStream)に対して、別リクエストから非同期にデータを書き込むことが可能になります。
3. デモコード解説🔗
それでは、実際のソースコード(src/index.ts)に沿って、重要なテクニックを解説します。
① メインストリーム接続を維持するための「隠しiframe」🔗
通常、HTMLのリンク(<a>)をクリックすると、ブラウザは別のページに遷移しようとし、現在のページへのストリーミング接続を切断してしまいます。
これを防ぐために、HTML内に非表示の <iframe> を配置し、リンクの遷移先ターゲットをその iframe に指定するハックを用います。
1function initialHtml(sessionId: string): string {2 return `<!DOCTYPE html>3<html lang="ja">4<head>5 <meta charset="UTF-8">6 <title>JS不要の順不同ストリーミング天気予報</title>7 ...8</head>9<body>10 <h1>天気予報 (ブラウザJS無効)</h1>1112 <!-- リンクのtarget属性をhidden-iframeに向ける -->13 <ul class="region-list">14 <li><a href="/select?sessionId=${sessionId}®ion=tokyo" target="hidden-iframe">東京</a></li>15 <li><a href="/select?sessionId=${sessionId}®ion=osaka" target="hidden-iframe">大阪</a></li>16 ...17 </ul>1819 <!-- 隠し iframe。ここを通じてリクエストを送信する -->20 <iframe name="hidden-iframe" style="display:none;"></iframe>2122 <!-- 初期表示のプレースホルダー。23 このdivを閉じずに接続を維持し、後続の <template for> も同じ親要素内へ流す -->24 <div class="forecast-box">25 <?start name="weather-forecast">26 <p class="placeholder">上の地域を選択してください。ストリーム更新を待機中です。</p>27 <?end>28`;29}
ユーザーが「東京」をクリックすると、リクエストは hidden-iframe の中で実行されます。メインページの接続は維持されたままであるため、Durable Objectが保持するストリームへ新しいHTMLを追記すれば、それが親ウィンドウ側の画面に即座に反映されます。
ここで重要なのは、DPUの <template for> が対象マーカーと同じ親要素にストリームされる必要がある点です。そのため、上の例では forecast-box を閉じずにストリーム接続を維持し、後続の更新チャンクも同じ forecast-box の子として流し込む構造にしています。
② 何回でも更新を可能にする「自己修復アンカー」テクニック🔗
DPUの最も重要な挙動として、「<template for="xxx"> による置換が行われると、元の <?start name="xxx"> と <?end> マーカー自体もろともコンテンツが丸ごと置き換わる」 という点があります。
つまり、1回目のボタン押下でそのままHTMLを置き換えてしまうと、次のボタンを押したときにはもう weather-forecast というマーカーがDOM上に存在しないため、2回目以降の更新ができなくなってしまいます。
これを解決するために、送出する更新チャンク(weatherChunk)の中に全く同じ名前の <?start> / <?end> マーカーを再配置します。
1function weatherChunk(data: WeatherData): string {2 return `3<template for="weather-forecast">4 <!-- 置換後のHTMLの中に、次回のためのマーカーを再配置する -->5 <?start name="weather-forecast">6 <div class="weather-card">7 <h3>${data.name} の天気</h3>8 <p class="desc">${data.weather}</p>9 <p class="temp">${data.temp}</p>10 <p class="time">気象庁API取得時刻: ${data.time}</p>11 </div>12 <?end>13</template>14`;15}
この「自己修復アンカー」テクニックにより、同じストリーム接続が維持されている間は、ボタンを押すたびに古い天気が新しい天気で置換され、かつ次の置換のための準備も整います。
なお、実運用でAPIレスポンスやユーザー入力をHTMLへ埋め込む場合は、必ずHTMLエスケープしてください。DPUはHTML片をそのままパースさせる仕組みなので、通常のサーバーサイドHTML生成と同じくXSS対策が必要です。
最低限の例としては、HTMLへ埋め込む直前に以下のようなエスケープ処理を通します。
1function escapeHtml(value: string): string {2 return value3 .replaceAll("&", "&")4 .replaceAll("<", "<")5 .replaceAll(">", ">")6 .replaceAll('"', """)7 .replaceAll("'", "'");8}
③ Durable Object によるストリーム制御🔗
Durable Objects 内では、IdentityTransformStream を使用して、レスポンスの ReadableStream と、そこにデータを書き込むための WritableStream(writer)のペアを作成し、メモリに保存します。
1export class WeatherSessionDO implements DurableObject {2 sessions: Map<string, DoSession>;34 constructor(readonly state: DurableObjectState, readonly env: Env) {5 this.sessions = new Map();6 }78 async fetch(request: Request): Promise<Response> {9 const url = new URL(request.url);10 const sessionId = url.searchParams.get("sessionId");11 if (!sessionId) return new Response("sessionId is required", { status: 400 });12 ...1314 // 1. 初回アクセス時の長時間ストリーミング接続15 if (url.pathname === "/connect") {16 const { readable, writable } = new IdentityTransformStream();17 const writer = writable.getWriter();18 this.sessions.set(sessionId, { writer });1920 // 初期HTMLを書き込む (接続は切らない)21 writer.write(encoder.encode(initialHtml(sessionId))).catch(() => {22 this.sessions.delete(sessionId);23 });2425 return new Response(readable, {26 encodeBody: "manual",27 headers: {28 "Content-Type": "text/html; charset=utf-8",29 "Cache-Control": "no-cache, no-transform",30 "X-Content-Type-Options": "nosniff"31 }32 });33 }3435 // 2. 地域が選択された際のトリガー36 if (url.pathname === "/trigger") {37 const region = url.searchParams.get("region");38 const session = this.sessions.get(sessionId);39 if (!session) return new Response(null, { status: 204 });4041 // 天気データを取得42 const data = await fetchWeather(region);4344 try {45 // 保存しておいた writer を使って、ストリームの末尾にテンプレートを追記46 await session.writer.write(encoder.encode(weatherChunk(data)));47 } catch {48 this.sessions.delete(sessionId);49 }5051 // iframeへのレスポンスは 204 No Content を返し、遷移を発生させない52 return new Response(null, { status: 204 });53 }54 }55}
レスポンスヘッダに設定されている Cache-Control: no-cache, no-transform は重要です。中間プロキシやCDNによる変換・バッファリングを避け、ストリームをできるだけそのまま届ける意図があります。
X-Content-Type-Options: nosniff はバッファリング抑止そのものではありませんが、MIME sniffingを抑止し、レスポンスを意図したHTMLとして扱わせるために付けています。
4. 技術的な注意点と制限🔗
親要素のスコープ制限🔗
DPUの仕様として、<template for="xxx"> は、対象となる <?start name="xxx"> マーカーと**同じ親要素(同一コンテナ内)**にストリームされる必要があります。階層構造が大きく異なる場所にあるマーカーを更新することはできません。
そのため、更新したい領域を囲むコンテナを先に閉じてしまい、その後ろの body 直下などへ <template for> を追記しても、対象マーカーを見つけられない場合があります。ストリーミングHTMLの構造として、後続のテンプレートが対象マーカーと同じコンテナに入るように設計する必要があります。
たとえば、以下のように forecast-box の中へ後続の <template for> が流れる構造なら更新対象になります。
1<div class="forecast-box">2 <?start name="weather-forecast">3 <p>読み込み中...</p>4 <?end>56 <template for="weather-forecast">7 <?start name="weather-forecast">8 <p>東京: 晴れ</p>9 <?end>10 </template>11</div>
一方で、以下のように対象マーカーを含む forecast-box を閉じたあと、外側へ <template for> を流してしまう構造は避けるべきです。
1<div class="forecast-box">2 <?start name="weather-forecast">3 <p>読み込み中...</p>4 <?end>5</div>67<template for="weather-forecast">8 <p>東京: 晴れ</p>9</template>
この制約があるため、DPUのストリーミングHTMLでは「どこに追記されるか」を通常のHTML生成より意識する必要があります。
ローカル開発環境の差異🔗
Cloudflareのローカル開発ツールである wrangler dev では、HTMLのストリーミングや宣言型部分更新のバッファ挙動がエッジサーバー(本番)と若干異なる場合があります。ローカルでストリームが途中で止まって見えたりする場合でも、本番のWorkersにデプロイすると正しく動作することが多いため、最終確認はデプロイ先で行うのが推奨されます。
接続が切れた場合の扱い🔗
このデモは、最初に開いたHTMLレスポンスのストリームが維持されていることを前提にしています。ブラウザの再読み込み、タブの破棄、ネットワーク切断、Durable Object側での writer.write() 失敗などが発生すると、そのセッションのストリームには追記できなくなります。
サンプルコードでは writer.write() の失敗時に sessions.delete(sessionId) を行っています。これは、すでに使えなくなった writer を保持し続けないためです。実運用に近づけるなら、再接続時に新しい sessionId を発行する、古いセッションを期限付きで掃除する、UI上で再読み込みを促す、といった設計が必要になります。
このデモでやっていないこと🔗
このデモはDPUの挙動を見せるための最小構成です。そのため、一般的なSPAで期待されるすべての機能を実装しているわけではありません。
- URL履歴や戻る/進むの状態管理
- 接続断からの自動復旧
- 複数タブや複数デバイス間の同期
- 本格的な認証・認可
- APIレスポンスの厳密なHTMLエスケープ
- Chrome以外のブラウザ対応
これらを含めて作り込む場合は、DPUだけで完結させるのではなく、要件に応じて通常のフォーム遷移、SSE、HTMX、クライアントJavaScriptなどと比較して選ぶのが現実的です。
他のアプローチとの違い🔗
DPUの立ち位置をざっくり比較すると、以下のようになります。
| アプローチ | クライアントJS | 更新方法 | 特徴 |
|---|---|---|---|
| 通常のSPA | 必要 | fetch() 後にDOM更新 | 柔軟だが、JSバンドルと状態管理が必要 |
| HTMX | 必要 | サーバーHTML片を取得してDOM更新 | HTML中心で書けるが、HTMXのJS実行は必要 |
| SSE | 多くの場合必要 | イベントを受けてDOM更新 | サーバーからのpushに強いが、描画処理は別途必要 |
| DPU | 不要 | ストリーム中の <template for> をHTMLパーサが処理 | ブラウザネイティブに部分更新できるが、現時点では実験機能 |
5. まとめ🔗
Declarative Partial Updates (DPU) を使うことで、ブラウザ標準のパーサの力だけで以下のようなメリットを享受できるようになります。
- ゼロ・JavaScript: クライアント用のJSバンドルをダウンロード・パース・実行するオーバーヘッドが一切ない。
- 高速なインタラクティブ性: サーバー側でHTML片を組み立てて送るだけで、SPA風に画面の一部を切り替えられる。
- サーバー主導のUI: サーバーサイドで状態管理とUI構築を完結できるため、APIエンドポイントの設計やクライアント側での状態同期処理から解放される。
現在はまだ実験的機能ですが、これが標準化されれば、React Server Componentsのストリーミング実装や、HTMXのようなアプローチがブラウザネイティブの機能だけで非常にシンプルに実装できるようになる未来が期待されます。
興味のある方は、ぜひChromeの実験フラグを有効化して、Durable Objectsでのストリーミング部分更新の可能性を体験してみてください。