Single Page Application(以下、SPA)を構成する技術要素のひとつに、History API がある。この機能によってページ遷移の履歴を管理できるようになり、SPA を成立させることができる。
しかしこの History API はお世辞にも使いやすいとは言い難い。作られた当初は SPA のような使われ方を想定していなかったと思われるので仕方ないのだが、実際に History API で SPA を作ろうとすると、かなり苦労する。そのため History API を直接触ることは稀で、React Router や Next.js が提供している機能を使うことが多い。
History API については以前ブログに書いたので、これを読むと雰囲気を掴めると思う。
そこで、もっと SPA を作るのに適した API を作ろうということで提案されているのが、App History API である。
WICG で議論が行われており、Chrome への実装が進んでいる。Chrome Canary では既に使えるようになっている。
この記事では App History API の機能を見ていき、実際に SPA を作ってみる。
注意点として、この API はまだ実験的なものであり、標準化されて他のブラウザにも実装されていくのかは分からないし、仕様もまだまだ変化していくと思われる。そもそも Chrome での扱いも今後どうなっていくのか分からない。取り敢えず Chrome 95 以降では、後述するフラグを有効にすることで使えるようになるようだが。
そのため実用性のある内容ではない。あくまでも、個人的に興味があるから触ってみたに過ぎない。
動作確認は Google Chrome Canary の96.0.4647.2
で行っている。
事前準備
Chrome Canary を入手する。
https://www.google.com/intl/ja/chrome/canary/
Chrome Canary を起動したらchrome://flags/
にアクセスし、Experimental Web Platform features
フラグを有効にして再起動する。
これで App History API を使えるようになった。
index.js
という JavaScript ファイルを用意する。このファイルに App History API を使ったプログラムを書いていくが、この時点ではまだ何も書かなくてよい。
次に、ウェブサーバを立てる。この記事ではv1.14.0
の Deno を使っているが、同等の機能を提供できるものなら何でもよい。
以下の内容のserver.ts
を用意して$ deno run --watch --allow-net --allow-read server.ts
を実行すると、サーバが起動する。
import { listenAndServe } from "https://deno.land/std@0.107.0/http/mod.ts"; const JS_FILE_URL = "/index.js"; const indexHtml = ` <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>App History API: index</title> </head> <body> <p>This is index page.</p> <p><a href="/foo">to foo</a></p> <script src="${JS_FILE_URL}"></script> </body> </html> `; const fooHtml = ` <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>App History API: foo</title> </head> <body> <p>This is foo page.</p> <p><a href="/">to index</a></p> <script src="${JS_FILE_URL}"></script> </body> </html> `; listenAndServe(":8080", async (req) => { const { pathname } = new URL(req.url); switch (true) { case pathname === "/": { return new Response(indexHtml, { status: 200, headers: { "Content-Type": "text/html" }, }); } case pathname === "/foo": { return new Response(fooHtml, { status: 200, headers: { "Content-Type": "text/html" }, }); } case pathname === JS_FILE_URL: { const js = await Deno.readFile(`.${JS_FILE_URL}`); return new Response(js, { status: 200, headers: { "Content-Type": "text/javascript" }, }); } default: { return new Response("Not Found\n", { status: 404, headers: { "Content-Type": "text/plain" }, }); } } });
http://localhost:8080/
にアクセスすると、/foo
へのリンクが表示される。
これをクリックするとサーバに HTTP リクエストが送られ、サーバが返した/foo
の HTML をブラウザが表示する。この HTML には/
へのリンクがあるが、これをクリックしたときも同様の挙動となる。
これは SPA との対比で Multiple Page Application(MPA)と呼ばれる、「一般的」なウェブサイトである。
このウェブサイトに App History API を導入し、どのように挙動が変わるのかを見ていく。
transitionWhile によるナビゲーションの制御
これからindex.js
にコードを書いていくが、まずは App History API を使える環境かを確認してから、処理を書いていく。
function hasAppHistory() { return globalThis && "appHistory" in globalThis; } function main() { if (!hasAppHistory()) return; // ここに処理を書いていく } main();
appHistory
オブジェクトにはnavigate
イベントがあり、これはその名の通りナビゲーションが行われるときに発生する。
イベントハンドラを以下のように設定すれば、リンクをクリックした際にnavigate!
というダイアログが表示され、それから画面の遷移が発生する。
appHistory.addEventListener("navigate", () => { alert("navigate!"); });
イベントハンドラの引数はAppHistoryNavigateEvent
で、このオブジェクトが持っているtransitionWhile
メソッドを使うことでナビゲーション時の挙動を制御できる。
appHistory.addEventListener("navigate", (e) => { console.log(e); // AppHistoryNavigateEvent console.log(e.transitionWhile); // ƒ transitionWhile() { [native code] } });
ナビゲーション時に行いたい処理をtransitionWhile
に渡せばよいのだが、引数に文を渡すことは当然できないので、以下のように関数に処理を書き、その関数をtransitionWhile
に渡すときに実行すればよい。
以下の場合、リンクをクリックすると画面にはabc
と表示される。従来のナビゲーションは行われないので、HTTP リクエストは発生しない。そしてアドレスバーを見ると、きちんとリンク先のものに変化している。
つまり、従来の History API のpushState
と同等の処理をしつつ、表示内容の制御も行えていることになる。
appHistory.addEventListener("navigate", (e) => { const myNavigation = () => { document.querySelector("body").replaceWith("abc"); }; e.transitionWhile(myNavigation()); });
myNavigation
を書き換えて、HTML の再読み込みなしに/
と/foo
を行き来できるようにする。
appHistory.addEventListener("navigate", (e) => { const myNavigation = async () => { const resource = await (await fetch(e.destination.url)).text(); const parser = new DOMParser(); const { title, body } = parser.parseFromString(resource, "text/html"); document.title = title; document.querySelector("body").replaceWith(body); }; e.transitionWhile(myNavigation()); });
AppHistoryNavigateEvent
のdestination.url
に遷移先の URL が格納されているので、fetch
でその URL のリソースを取得する。
あとはそれを使って表示内容やページタイトルを書き換えればよい。
これで、ページのリロードを行わずに遷移できる。
また、ブラウザバックやフォワードも問題なく動作している。History API ではpopstate
イベントを使って対応する必要があったが、これも不要になる。
navigatesuccess と navigateerror
appHistory
オブジェクトにはnavigate
イベントの他に、navigatesuccess
イベントやnavigateerror
イベントがある。
navigatesuccess
イベントはその名の通り、ナビゲーションが成功したときに発生する。
以下のコードの場合、リンクをクリックすると画面にはabc
が表示され、ログにはsuccess!
が流れる。
appHistory.addEventListener("navigate", (e) => { const myNavigation = () => { document.querySelector("body").replaceWith("abc"); }; e.transitionWhile(myNavigation()); }); appHistory.addEventListener("navigatesuccess", () => { console.log("success!"); }); appHistory.addEventListener("navigateerror", () => { console.log("error!"); });
ではnavigateerror
はナビゲーション中に例外が発生したときに発生するのかというと、そうではない。myNavigation
のなかで例外を投げても、navigateerror
イベントは発生しない。
myNavigation
のなかで発生した例外をキャッチしたければ、以下のように書く必要がある。
appHistory.addEventListener("navigate", (e) => { const myNavigation = () => { throw new Error("xyz"); }; try { e.transitionWhile(myNavigation()); } catch (e) { console.log(e.message); // xyz } });
navigateerror
がいつ発生するのかというと、transitionWhile
にPromise
を渡し、それがリジェクトされた際に発生する。
そのため以下のコードのときにリンクをクリックすると、画面にはabc
が表示されログにはerror! xyz
が流れる。
appHistory.addEventListener("navigate", (e) => { e.transitionWhile( new Promise((_, reject) => { document.querySelector("body").replaceWith("abc"); reject(new Error("xyz")); }) ); }); appHistory.addEventListener("navigatesuccess", () => { console.log("success!"); }); appHistory.addEventListener("navigateerror", (e) => { console.log("error!", e.error.message); });
Promise のなかで例外を投げるとリジェクトされるので、以下のコードでも同じ結果になる。
e.transitionWhile( new Promise(() => { document.querySelector("body").replaceWith("abc"); throw new Error("xyz"); }) );
実際に SPA を作ってみる
React と App History API を組み合わせて簡単な SPA を作ってみた。
冒頭の記事のなかで History API を使って作ったものと、全く同じ内容。App History API を使えばpushState
やpopstate
を考えなくてよいため、かなりシンプルになっている。
import React, { useState, useEffect } from "react"; export const App = () => { const [pathname, setPathname] = useState(window.location.pathname); useEffect(() => { appHistory.addEventListener("navigate", (e) => { const myNavigation = () => { setPathname(new URL(e.destination.url).pathname); }; e.transitionWhile(myNavigation()); }); }, []); const Content = () => { switch (pathname) { case "/foo": return <div>foo</div>; case "/bar": return <div>bar</div>; default: return <div>Not Found</div>; } }; return ( <div> <Content /> <nav> <a href="/foo">to foo</a> <a href="/bar">to bar</a> </nav> </div> ); };
もちろん、React のような UI ライブラリを使わずに書くこともできる。
以下は、API から JSON を取得して UI を作る、というよくある SPA。
transitionWhile
のなかでルーティングを行い、パスに応じた処理を行っている。
// index.js import { createIndexElement, createMemberElement, createNotFoundElement, } from "./element.js"; export const MEMBERS = ["alice", "bob", "carol"]; export const ALLOW_PAGE_URL_LIST = [ "/", ...MEMBERS.map((member) => `/${member}`), ]; function hasAppHistory() { return globalThis && "appHistory" in globalThis; } async function router(pathname) { if (!ALLOW_PAGE_URL_LIST.includes(pathname)) { renderApp(createNotFoundElement()); return; } if (pathname === "/") { renderApp(createIndexElement()); return; } const json = await (await fetch(`/api${pathname}`)).json(); const { id, name } = json; renderApp(createMemberElement(id, name)); } function renderApp(dom) { const app = document.querySelector("#app"); app.textContent = ""; app.appendChild(dom); } function main() { if (!hasAppHistory()) return; router(window.location.pathname); appHistory.addEventListener("navigate", (e) => { const myNavigation = () => { const { pathname } = new URL(e.destination.url); return router(pathname); }; e.transitionWhile(myNavigation()); }); } main();
// element.js import { MEMBERS } from "./index.js"; export function createIndexElement() { const ul = document.createElement("ul"); MEMBERS.forEach((member) => { const anchor = document.createElement("a"); anchor.setAttribute("href", `/${member}`); anchor.textContent = member; const li = document.createElement("li"); li.appendChild(anchor); ul.appendChild(li); }); const anchor = document.createElement("a"); anchor.setAttribute("href", `/foo`); anchor.textContent = "foo"; const li = document.createElement("li"); li.appendChild(anchor); ul.appendChild(li); return ul; } export function createMemberElement(id, name) { const div = document.createElement("div"); const idSpan = document.createElement("span"); idSpan.textContent = `id: ${id}`; div.appendChild(idSpan); let br = document.createElement("br"); div.appendChild(br); const nameSpan = document.createElement("span"); nameSpan.textContent = `name: ${name}`; div.appendChild(nameSpan); br = document.createElement("br"); div.appendChild(br); const anchor = document.createElement("a"); anchor.setAttribute("href", `/`); anchor.textContent = "to index page"; div.appendChild(anchor); return div; } export function createNotFoundElement() { const div = document.createElement("div"); const span = document.createElement("span"); span.textContent = "Nof Found."; div.appendChild(span); const br = document.createElement("br"); div.appendChild(br); const anchor = document.createElement("a"); anchor.setAttribute("href", `/`); anchor.textContent = "to index page"; div.appendChild(anchor); return div; }
// server.ts import { listenAndServe } from "https://deno.land/std@0.107.0/http/mod.ts"; import { MEMBERS, ALLOW_PAGE_URL_LIST } from "./index.js"; const INDEX_JS_FILE_URL = "/index.js"; const ELEMENT_JS_FILE_URL = "/element.js"; const ALLOW_API_URL_LIST = MEMBERS.map((member) => `/api/${member}`); const html = ` <!DOCTYPE html> <html lang="ja"> <head> <meta charset="UTF-8"> <title>App History API</title> </head> <body> <h1>Sample</h1> <div id="app"></div> <script type="module" src="${INDEX_JS_FILE_URL}"></script> </body> </html> `; const apiEndPointToJson = new Map([ ["/api/alice", { id: 1, name: "Alice" }], ["/api/bob", { id: 2, name: "Bob" }], ["/api/carol", { id: 3, name: "Carol" }], ]); listenAndServe(":8080", async (req) => { const { pathname } = new URL(req.url); switch (true) { case ALLOW_PAGE_URL_LIST.includes(pathname): { return new Response(html, { status: 200, headers: { "Content-Type": "text/html" }, }); } case ALLOW_API_URL_LIST.includes(pathname): { return new Response(JSON.stringify(apiEndPointToJson.get(pathname)), { status: 200, headers: { "Content-Type": "application/json" }, }); } case pathname === INDEX_JS_FILE_URL: { const js = await Deno.readFile(`.${INDEX_JS_FILE_URL}`); return new Response(js, { status: 200, headers: { "Content-Type": "text/javascript" }, }); } case pathname === ELEMENT_JS_FILE_URL: { const js = await Deno.readFile(`.${ELEMENT_JS_FILE_URL}`); return new Response(js, { status: 200, headers: { "Content-Type": "text/javascript" }, }); } default: { return new Response("Not Found\n", { status: 404, headers: { "Content-Type": "text/plain" }, }); } } });