document.write で疑似 SPA
- カテゴリ:
- JavaScript
- コメント数:
- Comments: 0
◆ fetch した HTML を document.write
◆ グローバルが共通なので module 前提だとロード済み module をロードしなくて済む
◆ 一度開いたページを再度開く場合は全モジュールがロード済みなのですごく高速
◆ グローバルが共通なので module 前提だとロード済み module をロードしなくて済む
◆ 一度開いたページを再度開く場合は全モジュールがロード済みなのですごく高速
なにするの
以前どこかで書いたような気がする思いつきをやってみたものです非 SPA ページを SPA にしたいなぁ とか思ってたときに fetch で HTML を取得して innerHTML 書き換えれば普通のページ遷移より快適になるじゃないの?と思いました
ただそれだと DOCTYPE 宣言は変えられなかったり JavaScript が実行されないとかで問題点もあります
しかし document.write で書き換えた場合はこれらの問題が起きません
なので fetch + document.write を使えば SPA じゃないページを SPA っぽくしてみます
前提
document.write で現在のページを書き換えてもグローバル変数は残りますなので 各ページは基本的にグローバル変数を更新しないのが前提です
ES modules を普通に使ってれば気にする必要がない部分です
良いところとして type="module" のスクリプトファイルは同じページで複数回ロードしても 2 度目以降はネットワークリクエストは発生せず 以前実行した結果の export 対象を返してくれるだけで無駄がありません
今のページと移動先のページが同じモジュールを使ってれば その部分はとても高速に処理されます
さらに移動先が過去に開いたページの場合は全部のモジュールがロード済みなので 動的 import やサーバリクエストがない SPA ページと同レベルの速さです
また WebCoponents の CustomElements もグローバルに登録されるので 同じものを使う場合は高速です
例
疑似 SPA を実現するためのライブラリファイル pspa.js を用意しますこのファイルは全部の HTML でロードが必要です
a タグクリックを検出して target 属性がなければ疑似 SPA によるナビゲートを行います
[pspa.js]
if (!window.pspa) {
window.pspa = {
async navigate(url) {
const navigate_origin = new URL(url, location).origin
if (location.origin !== navigate_origin) {
location = url
return
}
const html = await fetch(url).then(res => res.text())
this.clean()
this.history.push(location.href)
document.write(html)
document.close()
history.replaceState(null, "", url)
},
history: [],
clean() {},
}
}
window.addEventListener("click", eve => {
const a = eve.path.find(e => e instanceof HTMLElement && e.matches("a"))
if (!a || a.hasAttribute("target")) return
window.pspa.navigate(a.href)
eve.preventDefault()
})
例として 01, 02, 03 のページを用意します
01.html では page1.js モジュールの <page-1></page-1> 要素がルートコンポーネントで 同様に 02.html は page2.js で 03.html は page3.js とします
page-1 ~ page-3 が使うコンポーネントに counter.js と timer.js があります
これらはただカウントアップしたり毎秒更新される時刻を表示するだけのものです
[01.html]
<!DOCTYPE html>
<meta charset="utf-8"/>
<script src="pspa.js"></script>
<script type="module">
import "./page1.js"
</script>
<page-1></page-1>
[02.html]
<!DOCTYPE html>
<meta charset="utf-8"/>
<script src="pspa.js"></script>
<script type="module">
import "./page2.js"
</script>
<page-2></page-2>
[03.html]
<!DOCTYPE html>
<meta charset="utf-8"/>
<script src="pspa.js"></script>
<script type="module">
import "./page3.js"
</script>
<page-3></page-3>
[page1.js]
import "./counter.js"
customElements.define(
"page-1",
class extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" }).innerHTML = `
<div>
<counter-elem></counter-elem>
</div>
<div>
<a href="./01.html">01</a>
<a href="./02.html">02</a>
<a href="./03.html">03</a>
</div>
`
}
}
)
[page2.js]
import "./counter.js"
import "./timer.js"
customElements.define(
"page-2",
class extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" }).innerHTML = `
<style>
:host {
display: flex;
flex-flow: column;
width: 100vw;
height: 100vh;
position: fixed;
left: 0;
top: 0;
color: white;
background: #883a3a;
justify-content: center;
align-items: center;
}
a { color: white; }
</style>
<div>
<counter-elem></counter-elem>
<timer-elem></timer-elem>
</div>
<div>
<a href="./01.html">01</a>
<a href="./02.html">02</a>
<a href="./03.html">03</a>
</div>
`
}
}
)
[page3.js]
import "./counter.js"
const esc = text => {
const div = document.createElement("div")
div.textContent = text
return div.innerHTML
}
customElements.define(
"page-3",
class extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" }).innerHTML = `
<style>
:host {
font-size: 30px;
color: #59aaff;
display: flex;
flex-flow: column;
}
section {
display: flex;
}
.history {
font-size: 14px;
color: gray;
}
.counter, .history {
flex: 1 1 0;
}
</style>
<section>
<div class="counter">
<counter-elem></counter-elem>
<counter-elem></counter-elem>
</div>
<div class="history">
<div>::PSPA History::</div>
${window.pspa.history.map(x => `<div>${esc(x)}</div>`).join("")}
</div>
</section>
<div>
<a href="./01.html">01</a>
<a href="./02.html">02</a>
<a href="./03.html">03</a>
</div>
`
}
}
)
[counter.js]
customElements.define(
"counter-elem",
class extends HTMLElement {
constructor() {
super()
this.num = 0
this.attachShadow({ mode: "open" }).innerHTML = `
<div>
<span id="n">${this.num}</span>
<button id="b">Up</button>
</div>
`
this.elems = {
n: this.shadowRoot.querySelector("#n"),
b: this.shadowRoot.querySelector("#b"),
}
this.elems.b.onclick = this.onclickButton
}
onclickButton = () => {
this.elems.n.textContent = ++this.num
}
}
)
[timer.js]
customElements.define(
"timer-elem",
class extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: "open" }).innerHTML = `
<div id="t">${new Date().toLocaleString()}</div>
`
this.elems = {
t: this.shadowRoot.querySelector("#t"),
}
}
connectedCallback() {
this.timer = setInterval(this.updateTime, 1000)
}
disconnectedCallback() {
clearInterval(this.timer)
}
updateTime = () => {
this.elems.t.textContent = new Date().toLocaleString()
}
}
)
03 のページでは疑似 SPA で開いた履歴を表示もしています
最初に別ページに移動するときはちょっとロードに時間がかかって それ以降は高速になります
devtools の Network タブを見ても最初に開くときと 2 度目以降で開くときで必要なモジュールだけをロードしてるのがわかります
DEMO
使いみちは?
既存ページを簡単に SPA 風にするには向いてますが グローバル変数を書き換えたり prototype 拡張がページごとに違ったりとか こういう使い方をする想定がないページだと問題が出るケースがないとは言えませんそれにサーバサイドレンダリングで長い HTML を受け取って さらに JavaScript を全部実行だと効果薄めです
効果的な module や WebComponents はすごく最近のものです
そういうのが使っていける状況なら ServiceWorker や http2 とか もっとまともな方法で速度アップしたほうが良いと思います