コンテンツにスキップ

部分更新

検索結果の並び順を変えたい。それだけなのに、通常のナビゲーションではヘッダーも、 メニューも、検索フォームも、フッターも再び届きます。ブラウザはすでに持っている。 変わる必要があったのは、結果一覧だけです。

部分更新は、ページを別の仕組みに作り替えずにこの無駄を減らします。完全なドキュメントを 求めるクライアントには、従来どおり完全なドキュメントを返す。いまのレイアウトを持つ ページには、描画結果が異なる領域だけを返す。URLもハンドラもテンプレートも同じです。

最初の動作確認にクリックハンドラは要りません。機能を有効にし、同一オリジンのリンクを 押すか、GETフォームを送信します。

config.dev.toml
[html]
[html.update]
enabled = true
validator_key = "${HTML_UPDATE_VALIDATOR_KEY}"
<nav>
<a href="/orders?sort=newest">新着順</a>
<a href="/orders?sort=price">価格順</a>
</nav>
<form method="get" action="/orders">
<input name="q" type="search" value={query}>
<button type="submit">検索</button>
</form>

リンクはリンクのまま、フォームはフォームのままです。JavaScriptが動かなくても目的の ページへ着きます。ランタイムが動いているときだけ、現在のドキュメントを残し、変わった 領域をそこへ当てます。

画面上の結果が同じでも、誰がリクエストを始めるかによって使う経路は変わります。

入口 始め方 向いている操作 更新への載せ方
<a href> 同一オリジンへのナビゲーション ページ移動、並び替え、ページ送り 自動
<form method="get"> クエリ文字列付きのナビゲーション 検索、絞り込み 自動
<form method="post"> データの変更 作成、名前変更、入力検証 コンポーネントスクリプトが更新ヘッダー付きで送信し、応答を適用
JavaScript updatenavigateredrawfetch ローカルな操作、独自UI 明示的

リンクとGETフォームを自動で扱えるのは、ブラウザに任せた場合と更新後の意味が同じだから です。POSTは違います。安全でないフォームをランタイムが一律に横取りすると、読み込み中の 表示、失敗時の扱い、CSRFまで勝手に決めることになる。そこでアプリケーションが必要な フォームだけを選びます。

次のコンポーネントスクリプトは、通常のPOSTを残したまま更新経路を上乗せします。

export component RenameForm(orderID: string): html {
<script component>
export function setup({ el: form, teardown }) {
if (!window.popcornweb) return;
async function submit(event) {
event.preventDefault();
const response = await fetch(form.action, {
method: "POST",
headers: window.popcornweb.updateHeaders(),
credentials: "same-origin",
body: new FormData(form),
});
await window.popcornweb.apply(response);
}
form.addEventListener("submit", submit);
teardown(() => form.removeEventListener("submit", submit));
}
</script>
<form method="post" action="/orders/rename">
<input type="hidden" name="order_id" value={orderID}>
<input name="name" required>
<button type="submit">名前を変更</button>
</form>
}

updateHeaders() はアクション更新であることを伝え、設定済みなら現在のCSRFヘッダーも 加えます。apply() はハンドラが返した領域を適用します。4xx応答に載せた入力エラーも 対象です。リスナーの解除は後始末の関数を返すのではなく teardown に登録します。setup の戻り値は、そのコンポーネントが公開するハンドラの集合という一つの意味だけを持つからです (コンポーネントスクリプトを 参照)。そこまでしてコンポーネントスクリプトに書く意味は、フォームを差し替えても送信 リスナーが二重に残らないことにあります。

JavaScriptから各経路を直接始めることもできます。

if (window.popcornweb) {
await window.popcornweb.update({ sort: "newest" });
await window.popcornweb.navigate("/orders/17");
await window.popcornweb.redraw("card-17", { orderID: 17 });
}

update() が受け取るのはクエリの差分ではなくクエリ全体です。GETフォームと同じく、 名指さなかったパラメータは消えます。残すものは location.search から読んで渡し直します。

配列を渡すと、要素ごとに1組のペアになります。{ tag: ["boots", "hats"] }?tag=boots&tag=hats です。これはチェックボックス群が送る繰り返しキーそのもので、 サーバが読む唯一の配列の綴りでもあります。空配列は何も書きません。ひとつも チェックされていないフォームが送るクエリと同じです。カンマで結合しないのは、 フォームには書けないクエリになってしまうのと、要素の中のカンマと区切りのカンマを 区別する方法がないからです。

部分更新のために2つ目のページ実装が生まれるわけではありません。同じ描画結果を、 ドキュメントとして返すか、いまのDOMへの指示として返すか。その違いです。

1つのハンドラを通る2つの流れ。青はブラウザがページを要求し、ハンドラがチェーン全体を描画し、ブラウザが新しいページを描く。緑はランタイムがレンダーヘッダーとマニフェストを加え、同じハンドラがブラウザの持つダイジェストと各領域を比較し、差し替え指示を返して、ランタイムが生きたDOMへ適用する。

更新ヘッダーがなければ、応答はドキュメントです。ブラウザが解析してページを置き換えます。 クローラ、curl、JavaScriptを無効にしたブラウザ、拡張用ヘッダーを落とすプロキシは この経路を通ります。

更新ヘッダーがあれば、リクエストは現在の領域マニフェストを運びます。サーバーは同じ ルートを描画し、境界ごとにダイジェストを比較する。異なった境界だけ、新しいマークアップと 差し替え指示を返します。ランタイムはそれを表示中のDOMへ適用します。

ここで速くなる理由と、壊れにくい理由がつながります。変わらないマークアップは通信も 再解析もされません。一方、完全なドキュメントの経路はフォールバックとして残るのではなく、 最初から正規の経路として残っています。

差分はどれくらい小さくなるか

Section titled “差分はどれくらい小さくなるか”

目安を決めるのはコンポーネント数ではなく、変わった領域のマークアップ量です。非圧縮の ドキュメント本文を D、変わった領域の合計を R、更新用の小さな指示とマニフェストを O とすると、通常の再読み込みは D、部分更新はおおよそ R + O を運びます。

次はベンチマークではなく、規模をつかむための例です。描画後のドキュメントを50 KiB、 シェルとナビゲーションを34 KiB、結果一覧を14 KiB、注文概要を2 KiBとします。 HTTPヘッダーと圧縮は含めません。

変わったもの 完全なドキュメント 部分更新本文の目安 送らずに済むマークアップ
並び替え後の結果一覧 50 KiB 14 KiB + O 約72%
注文概要1つ 50 KiB 2 KiB + O 約96%
結果一覧と注文概要 50 KiB 16 KiB + O 約68%
どの境界も同じ 50 KiB O のみ ほぼ100%

実際の転送量には圧縮が効き、小さな応答ほど O の比率が上がります。それでも方向は 変わりません。独立して変わる領域を小さな境界にできれば、通信とDOM処理の両方が減ります。

ただし、サーバーの計算量まで同じ割合で減るとは限りません。ナビゲーションは比較のために ページチェーンを描画します。狙ったコンポーネントだけを再描画し、無関係なデータ取得を 飛ばしたとき、初めてサーバー側の仕事も減ります。

境界が答えるのはひとつです。この描画単位が変わったとき、周囲を巻き込まず安全に 差し替えられる最小範囲はどこか。

.pw.html の描画チェーンでは、レイアウトとページが自動的に境界になります。生成時に 各ルートへ識別子が付き、描画したマークアップからダイジェストが作られます。普通の入れ子の コンポーネントは、意図的に境界ではありません。500行の表で各行を境界にすると、何も 変わる前からナビゲーションのマニフェストが500件増えてしまいます。

まず自動のページ境界とレイアウト境界を使います。さらに細かい境界を足すのは、次の3つが 同時に成り立つ領域です。

  1. 単独で変わる頻度が高く、分ける効果がある。
  2. 隣のUIを巻き込まずに置換できる、安定したルートを1つ持つ。
  3. 直接再描画するときの入力を、安全に復元できる。

3番目は見た目より厳しい条件です。並び順、絞り込み、ページ番号のようにURLへ置ける状態は、 URLへ残してナビゲーションに任せます。共有すべきでないブラウザ内の状態なら、reloadableな コンポーネントが候補になります。

package templates
@reloadable
export component OrderCard(id: string, orderID: int): html {
<article class="card">
<h3>注文 {orderID}</h3>
</article>
}

reloadableなコンポーネントはexportされ、ルートをちょうど1つ持ち、呼び出し側が書く id を 宣言します。残りの引数は、クエリ文字列から一意に復元できる型だけです。レコード、スライス、 html は生成時に拒否されます。再描画リクエストから元に戻せないためです。

境界の階層は次の形に落ち着きます。

  • レイアウト境界は、複数ページが共有するドキュメントシェルを守る。
  • ページ境界は、ルート固有の内容を置き換える。
  • reloadableコンポーネント境界は、URLで運べる入力を持ち、単独で再描画する領域に限る。
  • 普通のコンポーネントは、最寄りの境界の内側にある実装詳細のままにする。

小さいほどよい、とは限りません。境界が増えればマニフェストも増えます。時計や乱数を 描く境界は毎回異なるので、一度も一致しません。継続して変わる値は ライブレンダリングへ置くか、ブラウザで表示用に 整形します。

ナビゲーション、再描画、アクション

Section titled “ナビゲーション、再描画、アクション”

3つの経路は、変わった入力がどこにあるかで分かれます。

ナビゲーション:状態はリクエストにある

Section titled “ナビゲーション:状態はリクエストにある”

同一オリジンのリンクやGETフォームが、ルートまたはクエリを変えます。ランタイムはその URLを要求し、ハンドラは通常どおり描画し、サーバーが変わった境界を返します。既定の経路 なので、アプリケーション独自のJavaScriptは要りません。

検索語、並び順、絞り込み、ページ番号はここに置きます。URLにあることで、共有、 ブックマーク、戻る・進むがそのまま成立します。

再描画:状態はブラウザにある

Section titled “再描画:状態はブラウザにある”

redraw(id, parameters) は、ページ全体を実行せず、reloadableなコンポーネント1つに 再描画を求めます。通常のハンドラでは、無関係なクエリより前で答えられます。

func Orders(w http.ResponseWriter, r *http.Request) {
if !pw.Authenticated(r) {
pw.WriteProblem(w, r, pw.Unauthorized())
return
}
if pw.Redraw(w, r, templates.OrdersPage) {
return
}
orders, err := loadOrders(r.Context())
if err != nil {
pw.WriteProblem(w, r, err)
return
}
pw.WriteHTMLPage(w, r, nil,
templates.OrdersPage(templates.OrdersPageParams{Orders: orders}))
}

ページは呼び出さず、名前だけを渡します。pw.Redraw は認可より下、ページ用の重い データ取得より上に置く。これならページの保護を引き継ぎながら、コンポーネントに不要な 処理を飛ばせます。

再描画の引数はリクエストした相手から届きます。orderID=17 を読むコンポーネントは、 現在の利用者が注文17を読めるか、ハンドラと同じように検査しなければなりません。ページを 名指すことで制限できるのは、URLがどの種類のコンポーネントへ答えるかまでです。引数の 認可まではしません。

アクション:ハンドラが変更を持つ

Section titled “アクション:ハンドラが変更を持つ”

アクションは変更を1回だけ実行し、その後で応答の形を選びます。変更処理を分岐より上に 置けば、通常のクライアントと更新対応クライアントで処理がずれません。

func Rename(w http.ResponseWriter, r *http.Request) {
order, err := renameOrder(r)
if err != nil {
pw.WriteProblem(w, r, err)
return
}
if !pw.WantsUpdate(r) {
http.Redirect(w, r, "/orders", http.StatusSeeOther)
return
}
pw.WriteUpdate(w, r, http.StatusOK,
pw.Replace("order-summary",
templates.Summary(templates.SummaryParams{Order: order})))
}

通常のPOSTはpost-redirect-getへ進みます。更新ヘッダー付きのPOSTは、同じ1往復で変更済みの 領域を受け取ります。4xxのアクション応答にも入力エラーの領域を載せられます。拒否内容を 表示することが目的なので、apply() はそれを適用します。変更後の行き先自体が変わるなら、 pw.WriteUpdateNavigate(w, r, "/orders/17") を使います。

描画結果は、サーバーが作り終えた後でも新しい応答に追い越され、捨てられることがあります。 だからナビゲーションと再描画は副作用を持てません。変更はアクションハンドラに置きます。

ドキュメントを残すと、変わった境界の外側にある状態も残ります。スクロール位置、開いた コントロール、選択、まだ送信していない入力です。差し替えられる領域の中でも、対応する コントロールは現在値を保ち、フォーカス中ならフォーカスとキャレットも保ちます。

ナビゲーションと update() は同じ動きをしません。リンクとGETフォームは新しい履歴へ 進むので、ページ先頭または指定されたフラグメントへ移り、以前の対象が消えた場合は フォーカスを戻します。update() は同じルートの引数変更なので、ビューポートを動かしません。 戻る・進むでは、領域が着地してから保存済みのスクロール位置を復元します。

IMEの変換が未確定なら、更新は終わるまで待ちます。日本語や中国語の入力途中にコントロールを 差し替えると、編集中の文字が確定または消失してしまうからです。待っている間に新しい応答が 追い越した場合は、古い応答を破棄します。

地図、canvas、再生中の動画のようにサーバーが中身を所有しないDOMは、安定したキーを付けて 置換後へ移せます。

<div data-tb-preserve="chart"><canvas></canvas></div>

ナビゲーションまたは再描画の処理中は、ドキュメントルートに data-tb-updating が付きます。

[data-tb-updating] .results { opacity: 0.6; }

部分更新がよく効くのは、サーバー描画したページを正としたまま、リクエストのたびに ドキュメントの一部だけが変わる画面です。どちらかが崩れると、別の方法のほうが単純になります。

状況 合う方法 理由
リロードが速く、回数も少ない 通常のナビゲーション ランタイム、マニフェスト、validator用の秘密が要らない
アプリケーションがフラグメントのURLと差し替え先を決める フラグメントまたはhtmx ページ境界のネゴシエーションなしに、swapを明示的に所有できる
リクエストなしにサーバー側で新しい値が生まれる ライブレンダリング 開いた接続から繰り返し配信できる
状態が1つのウィジェット内だけにある コンポーネントスクリプトまたはReactの島 サーバーとの往復が要らない
画面の大半が長寿命のクライアント状態になる クライアント描画中心の構成 サーバーとの再照合が単純な所有モデルではなくなる
POSTを常にブラウザ標準の動作で扱いたい post-redirect-get 再読み込み、履歴、失敗時の動作をブラウザに任せられる

修飾キー付きのクリック、targetdownload、クロスオリジンURL、フラグメントだけが違う リンク、GET以外のフォームはランタイムが横取りしません。本来は対象になるリンクやGETフォームを ブラウザに任せたい場合は、要素または祖先へ data-tb-ignore を付けます。

  • 強い html.update.validator_key を配布する。設定なしで有効にすると起動を拒否する。 ローテーション時は比較が外れ、次の応答が完全なドキュメントになるだけです。
  • 追い越された応答は捨てられるので、描画から副作用を除く。
  • 再描画の引数を、信頼できないリクエスト入力として認可する。
  • 広い境界の中へ、時計など毎回変わるマークアップを入れない。
  • 同じリンクとフォームをJavaScriptなしでも試す。拡張前の時点で、移動先と変更処理が 正しくなければならない。
  • 転送量とサーバー処理を分けて測る。ナビゲーションが減らすのは応答とDOM処理で、 早い位置の再描画はサーバークエリも飛ばせる。

最初に試すなら、GETの絞り込みを持つ結果ページが向いています。並び順を切り替えても アドレスバーと戻るボタンは普通に動く。その一方、ネットワーク応答からは、すでに画面に あるページのコピーが消え、結果一覧の境界だけが届くようになります。

設定キーと既定値はアプリケーション設定一覧にあります。