一覧WB-14-3 · Web APIとアプリ構成

クライアント側とサーバー側のレンダリング

ノートとペンの商品画面について、応答HTMLに何が入っているかを読みます。続いてサーバー側のページ遷移とSPA内の切替を行い、Documentの同一性、URL、API取得、ローカルの操作状態がどう変わるか確認します。

説明

画面を表示する方法には、サーバーが内容入りのHTMLを返す方法と、ブラウザがデータを取得してDOMを作る方法があります。SPAは同じDocumentの中で画面を切り替える構成で、サーバー生成HTMLにもJavaScriptによる操作を加えられます。

ノートとペンの商品画面について、応答HTMLに何が入っているかを読みます。続いてサーバー側のページ遷移とSPA内の切替を行い、Documentの同一性、URL、API取得、ローカルの操作状態がどう変わるか確認します。

1サーバーが返す商品HTML

サーバー生成の応答を文字列で取得し、見出しと価格を解析します。

(globalThis.ssrText=await(await fetch("/ssr?id=1")内容入りのHTMLを取得).text(),globalThis.parsedSSR=new DOMParser()応答文字列を解析.parseFromString(ssrText,"text/html"),{name:parsedSSR.querySelector("#name")初期HTMLの見出し.textContent,price:parsedSSR.querySelector("#price").textContent})

JavaScriptを実行する前の応答HTMLに、ノートと120が入っています。DOMParserで解析した文書の結果なので、取得後にAPIから埋めた値ではありません。

2SPAの初期HTML

SPAの応答HTMLも同じ方法で調べます。

(globalThis.spaText=await(await fetch("/spa?id=1")SPAの初期HTML).text(),globalThis.parsedSPA=new DOMParser().parseFromString(spaText,"text/html"),{name:parsedSPA.querySelector("#name").textContent,price:parsedSPA.querySelector("#price")初期状態の価格欄.textContent})

こちらの初期HTMLは「読込み中」で価格は空です。この例ではブラウザが商品データを取得してから内容を埋めます。SPAでも初期HTMLをサーバー生成する構成はあり、SPAかどうかと初期描画の生成場所は完全に同じ分類ではありません。

3サーバー生成HTMLにも操作を付ける

実際に文書を開き、追加ボタンを一回起動します。

(globalThis.ssr=openDocument("/ssr?id=1"),globalThis.doc=await ssr.loaded文書の読込みを待つ,globalThis.ssrDocument=doc,doc.getElementById("count").click()追加ボタンを起動,{name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent})

HTMLに含まれていた商品情報に加えて、JavaScriptによる追加回数の表示も動きます。サーバー生成HTMLがそのまま非対話型であるとは限りません。

4リンクで別の商品文書へ移る

ペンへのリンクを起動し、iframeの次のloadを待ちます。

(await new Promise(resolve=>{ssr.frame.onload=resolve次の文書の読込みを待つ;doc.getElementById("next").click()リンクを起動}),globalThis.doc=ssr.frame.contentDocument,{sameDocument:doc===ssrDocumentDocumentの同一性,name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent,url:doc.defaultView.location.pathname+doc.defaultView.location.search})

新しいDocumentになり、商品はペン、追加回数は0へ戻ります。ページ遷移で前の文書のローカル変数が引き継がれるわけではありません。保存や引継ぎが必要な状態は、別に設計します。

5SPAで最初の商品を表示する

SPAを開き、ノートのデータがDOMへ反映されるまで待ちます。

(globalThis.spa=openDocument("/spa?id=1")SPAを開始,await spa.loaded,await waitTitle(spa.frame,"ノート")データ反映まで待つ,globalThis.doc=spa.frame.contentDocument,globalThis.spaDocument=doc現在のDocumentを保存,{name:doc.getElementById("name").textContent,price:doc.getElementById("price").textContent})

初期HTMLは空でも、APIの結果を使ってノートと120が表示されました。waitTitleは見出しへ指定値が反映されるのを待つ関数です。文書のloadと、その後の非同期データ取得の完了は別の時点になり得ます。

6SPAのローカル状態を変更する

追加回数を一つ増やします。

(doc.getElementById("count").click()文書内の状態を更新,{count:doc.getElementById("count").textContent})

追加回数は1です。この値が、同じDocumentで商品を切り替えた後にどうなるか確認します。

7同じDocumentで商品を切り替える

ペンへのボタンを起動し、表示の更新を待ちます。

(doc.getElementById("next").click()SPA内の商品切替,await waitTitle(spa.frame,"ペン"),{sameDocument:doc===spaDocumentDocumentが維持されたか,name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent,url:doc.defaultView.location.pathname+切替後のURLdoc.defaultView.location.search})

Documentは同じで、商品名とURLが変わり、追加回数は1のままです。この例ではFetchで商品を取得し、DOMを書き換えてhistory.pushStateでURLを更新します。状態が残ることが望ましいかは画面の意味によるため、商品切替時の初期化も設計対象です。

8SPA内の取得記録

子文書のResource Timingから取得したパスを確認します。

doc.defaultView.performance.getEntriesByType("resource")SPA文書内の取得記録.map(e=>new URL(e.name).pathname+new URL(e.name).search商品IDまで含めて確認)

app-data?id=1とid=2の取得が記録されています。画面切替のたびに商品を含む新しいHTML文書を取得する方式とは異なります。URLを更新するpushState自体がデータを取得してくれるわけではありません。

9切替後のURLから直接開く

新しい文書として、ペンのSPA URLを開きます。

(globalThis.direct=openDocument("/spa?id=2")直接URLから新規開始,await direct.loaded,await waitTitle(direct.frame,"ペン"),globalThis.doc=direct.frame.contentDocument,{name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent,sameDocument:doc===spaDocument新しいDocumentであること})

URLから商品IDを読み取ってペンを表示し、追加回数は0です。再読込みや共有URLからも開始できるよう、サーバーの配信経路とブラウザ側の初期化を対応させます。前の文書内の操作履歴がなければ開けない構成にはしません。

まとめ

応答HTMLに内容があるかと、画面を同じDocumentで切り替えるかは別の観点です。

SPAの切替ではデータ取得・DOM更新・URL更新・状態管理をアプリが対応させます。

直接URLや再読込みでも同じ商品を表示できるよう、初期化と配信経路を設計します。

操作履歴と解説を振り返れます。

ブラウザ / Web API

1
(globalThis.ssrText=await(await fetch("/ssr?id=1")).text(),globalThis.parsedSSR=new DOMParser().parseFromString(ssrText,"text/html"),{name:parsedSSR.querySelector("#name").textContent,price:parsedSSR.querySelector("#price").textContent})
{
  "name": "ノート",
  "price": "120"
}
2
(globalThis.spaText=await(await fetch("/spa?id=1")).text(),globalThis.parsedSPA=new DOMParser().parseFromString(spaText,"text/html"),{name:parsedSPA.querySelector("#name").textContent,price:parsedSPA.querySelector("#price").textContent})
{
  "name": "読込み中",
  "price": ""
}
3
(globalThis.ssr=openDocument("/ssr?id=1"),globalThis.doc=await ssr.loaded,globalThis.ssrDocument=doc,doc.getElementById("count").click(),{name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent})
{
  "name": "ノート",
  "count": "追加 1"
}
4
(await new Promise(resolve=>{ssr.frame.onload=resolve;doc.getElementById("next").click()}),globalThis.doc=ssr.frame.contentDocument,{sameDocument:doc===ssrDocument,name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent,url:doc.defaultView.location.pathname+doc.defaultView.location.search})
{
  "sameDocument": false,
  "name": "ペン",
  "count": "追加 0",
  "url": "/ssr?id=2"
}
5
(globalThis.spa=openDocument("/spa?id=1"),await spa.loaded,await waitTitle(spa.frame,"ノート"),globalThis.doc=spa.frame.contentDocument,globalThis.spaDocument=doc,{name:doc.getElementById("name").textContent,price:doc.getElementById("price").textContent})
{
  "name": "ノート",
  "price": "120"
}
6
(doc.getElementById("count").click(),{count:doc.getElementById("count").textContent})
{
  "count": "追加 1"
}
7
(doc.getElementById("next").click(),await waitTitle(spa.frame,"ペン"),{sameDocument:doc===spaDocument,name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent,url:doc.defaultView.location.pathname+doc.defaultView.location.search})
{
  "sameDocument": true,
  "name": "ペン",
  "count": "追加 1",
  "url": "/spa?id=2"
}
8
doc.defaultView.performance.getEntriesByType("resource").map(e=>new URL(e.name).pathname+new URL(e.name).search)
[
  "/app-data?id=1",
  "/app-data?id=2"
]
9
(globalThis.direct=openDocument("/spa?id=2"),await direct.loaded,await waitTitle(direct.frame,"ペン"),globalThis.doc=direct.frame.contentDocument,{name:doc.getElementById("name").textContent,count:doc.getElementById("count").textContent,sameDocument:doc===spaDocument})
{
  "name": "ペン",
  "count": "追加 0",
  "sameDocument": false
}

演習完了