クライアント側とサーバー側のレンダリング
ノートとペンの商品画面について、応答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
(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"
}(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": ""
}(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"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}body { background: rgb(17, 17, 17); color: rgb(238, 238, 238); font: 16px / 1.6 system-ui; margin: 16px; }
a { color: rgb(128, 196, 214); }</style></head><body inert><h1 id="name">ノート</h1><p id="price">120</p><button id="count">追加 1</button><a id="next" href="/ssr?id=2">ペンへ</a></body></html>(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"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}body { background: rgb(17, 17, 17); color: rgb(238, 238, 238); font: 16px / 1.6 system-ui; margin: 16px; }
a { color: rgb(128, 196, 214); }</style></head><body inert><h1 id="name">ペン</h1><p id="price">80</p><button id="count">追加 0</button><a id="next" href="/ssr?id=2">ペンへ</a></body></html>(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"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}body { background: rgb(17, 17, 17); color: rgb(238, 238, 238); font: 16px / 1.6 system-ui; margin: 16px; }
a { color: rgb(128, 196, 214); }</style></head><body inert><h1 id="name">ノート</h1><p id="price">120</p><button id="count">追加 0</button><button id="next">ペンへ</button></body></html>(doc.getElementById("count").click(),{count:doc.getElementById("count").textContent}){
"count": "追加 1"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}body { background: rgb(17, 17, 17); color: rgb(238, 238, 238); font: 16px / 1.6 system-ui; margin: 16px; }
a { color: rgb(128, 196, 214); }</style></head><body inert><h1 id="name">ノート</h1><p id="price">120</p><button id="count">追加 1</button><button id="next">ペンへ</button></body></html>(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"
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}body { background: rgb(17, 17, 17); color: rgb(238, 238, 238); font: 16px / 1.6 system-ui; margin: 16px; }
a { color: rgb(128, 196, 214); }</style></head><body inert><h1 id="name">ペン</h1><p id="price">80</p><button id="count">追加 1</button><button id="next">ペンへ</button></body></html>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" ]<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}body { background: rgb(17, 17, 17); color: rgb(238, 238, 238); font: 16px / 1.6 system-ui; margin: 16px; } a { color: rgb(128, 196, 214); }</style></head><body inert><h1 id="name">ペン</h1><p id="price">80</p><button id="count">追加 1</button><button id="next">ペンへ</button></body></html>
(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
}<!doctype html><html lang="ja"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; img-src data:; base-uri 'none'; form-action 'none'"><style>:root{color-scheme:dark}body{margin:16px;background:#111;color:#eee;font:14px/1.6 system-ui}body::after{content:"";display:block;height:calc(100vh - 32px)}a{color:#80c4d6}table{border-collapse:collapse}th,td{border:1px solid #555;padding:4px 8px}img{max-width:100%}body { background: rgb(17, 17, 17); color: rgb(238, 238, 238); font: 16px / 1.6 system-ui; margin: 16px; }
a { color: rgb(128, 196, 214); }</style></head><body inert><h1 id="name">ペン</h1><p id="price">80</p><button id="count">追加 0</button><button id="next">ペンへ</button></body></html>演習完了