VanillaJS で SPA を自作してみる
Angular、React、Vue、Svelte など、SPA を構築するためのフレームワークはいくつか存在します。それらは独自の概念や JSX のような構文を持ちながらも、最終的にはブラウザ上で実行できる JavaScript コードへと変換されて動いています。
ということは、 フレームワークなんか使わなくても、SPA を自作できるのでは? と思い、シングル HTML ファイルで動く小さな SPA を構築してみました。
コンポーネントの描画
まずは、複数定義したコンポーネントを表示する方法を考えます。これは単純に、innerHTML
で適当な親要素に HTML を注入してしまえば楽そうです。
const render = html => document.getElementById('app').innerHTML = html;
const homeComponent = () => render(`<h1>Home 画面</h1>`);
const aboutComponent = () => render(`<h1>About 画面</h1>`);
とりあえずコンポーネント単位の「初期表示」だけ考えれば、こんな感じでも良いでしょう。
状態管理
例えばログイン画面があって、ログインするとホーム画面にはユーザの名前が表示される、というような、ログインユーザの名前を持ち回りたい場合を考えます。
世の状態管理ライブラリの多くは、つまるところインメモリなグローバル変数を利用していることが多いです。最も単純化すると、次のように実現できます。
// グローバルなストアに `name` プロパティを持つ
const state = { name: null };
// 状態を設定するための関数を用意する
const setState = (key, value) => state[key] = value;
// 任意のイベントで、次のように値を設定する
setState('name', loginUserName);
// 参照したい場所で、次のように値を参照する
render(`<h1>ようこそ、${state.name}さん!`);
ルーティング
ここまでは、コンポーネントを定義して状態管理用のグローバル変数を用意したに過ぎません。
ここから画面にコンポーネントを表示するために、ルーティング処理を考えてみます。
今回はサーバサイドを必要としないシンプルな構成とするため、昔ながらのハッシュバングによるパス定義を使いつつ、History API の popstate
イベントを駆使してクライアントサイドのみで実現してみます。
// こんなコンポーネントがあって、それらを行き来できる仕組みを作ります
const homeComponent = () => render(`<h1>Home 画面</h1><p><a href="#!/about">About 画面へ</a></p>`);
const aboutComponent = () => render(`<h1>About 画面</h1><p><a href="#!/user" >User 画面へ</a></p>`);
const userComponent = () => render(`<h1>User 画面</h1><p><a href="#!/home" >Home 画面へ</a></p>`);
// パスを定義します
const routes = {
'#!/' : homeComponent,
'#!/about': aboutComponent,
'#!/user' : userComponent
};
const router = hash => {
let component = routes[Object.keys(routes).find(route => route === hash)];
if(!component) {
component = homeComponent;
hash = '#!/';
}
component();
};
// 初期表示時のハッシュを参照して表示するコンポーネントを特定します
router(location.hash); // DOMContentLoaded イベントで定義しても良いですが、`type="module"` で実装すれば同等なので省略
// リンクのクリック時やブラウザバック時はこちらで処理します
window.addEventListener('popstate', () => router(location.hash));
これで、
spa.html
やspa.html#!/
でアクセスしてきた場合は HomeComponentspa.html#!/about
は AboutComponentspa.html#!/user
は UserComponent
が初期表示されるようになります。
リンクは <a href="#!/about">
のようにハッシュリンクを書いておくと、hashchange
イベントと popstate
イベントが発火するため、popstate
イベントの中で同じルーティング関数を呼び出して画面遷移を実現しています。
ボタン押下によるイベントを定義してみる
最後にちょっと複雑なことをやってみます。といっても SPA ではよくあるもので、 「Submit ボタンをクリックしたら、入力値が保存されて別の画面に遷移する」 という処理を実現してみます。
const userComponent = () => {
render(`
<h1>ユーザ登録</h1>
<p>
<input type="text" id="user-name" value="${state.name ?? ''}">
<input type="button" id="user-submit" value="登録">
</p>
`);
const onClickUserSubmit = () => {
const name = document.getElementById('user-name').value.trim() || null;
setState('name', name);
document.getElementById('user-submit').removeEventListener('click', onClickUserSubmit);
moveTo('#!/');
};
document.getElementById('user-submit').addEventListener('click', onClickUserSubmit);
};
// JS 内のイベントで画面遷移する場合はこのような関数で実行します
const moveTo = hash => {
router(hash);
history.pushState(null, '', hash);
};
先程まで作っていた render()
と setState()
、router()
関数はそのままに、UserComponent 内にイベントリスナを追加してみました。ボタンをクリックしたらストアに入力値を保存し、自身のイベントを削除した後に Home 画面に画面遷移、という動きになります。
コード全量
ということで、動作するコード全量を掲載します。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>SPA</title>
</head>
<body>
<ul>
<li><a href="#!/">Home</a></li>
<li><a href="#!/about">About</a></li>
<li><a href="#!/user">User</a></li>
</ul>
<hr>
<div id="app"></div>
<script type="module">
// ユーザ定義のコンポーネント
const homeComponent = () => {
render(`<h1>こんにちは、${state.name ?? 'ゲスト'} さん!</h1>`);
};
const aboutComponent = () => {
const message = state.name == null
? `<a href="#!/user">ユーザ名を登録してみてはいかがでしょう?</a>`
: `<a href="#!/">${state.name} さん、Home に戻る</a>`;
render(`<h1>About</h1><p>${message}</p>`);
};
const userComponent = () => {
render(`<h1>ユーザ登録</h1><p><input type="text" id="user-name" value="${state.name ?? ''}"><input type="button" id="user-submit" value="登録"></p>`);
const onClickUserSubmit = () => {
const name = document.getElementById('user-name').value.trim() || null;
setState('name', name);
document.getElementById('user-submit').removeEventListener('click', onClickUserSubmit);
moveTo('#!/');
};
document.getElementById('user-submit').addEventListener('click', onClickUserSubmit);
};
// ユーザ定義のルーティング
const routes = {
'#!/' : homeComponent,
'#!/about': aboutComponent,
'#!/user' : userComponent
};
// 状態管理
const state = { name: null };
const setState = (key, value) => state[key] = value;
// コンポーネント描画
const render = html => document.getElementById('app').innerHTML = html;
// ルーティング
const router = hash => {
let component = routes[Object.keys(routes).find(route => route === hash)];
if(!component) {
component = homeComponent;
hash = '#!/';
}
component();
};
const moveTo = hash => {
router(hash);
history.pushState(null, '', hash);
};
// 初期表示と画面遷移時のイベント定義
router(location.hash);
window.addEventListener('popstate', () => router(location.hash));
</script>
</body>
</html>
ライブラリ依存ゼロの、ピュアな JavaScript のみで SPA っぽい動きができました。
ここまでで作成した各関数の名前や引数は、近しいものを SPA ライブラリで見たことがあるのではないでしょうか。おおよそ世の SPA フレームワークでも、内部では似たようなことをやっているものと思います。
しかし、ここまでで考慮しきれていない機能もたくさんあります。
- コンポーネント内に独自の子コンポーネントを渡す仕組みがない
- パスに
/:id
のような任意のパラメータ変数を付与して実行する仕組みがない - ボタンをクリックせずに UserComponent から離脱した時に
removeEventListener()
が実行されない (こうした抜け漏れはメモリリークなどの恐れがあります) - ページごとのタイトルを
title
属性に付与する仕組みがない
コンポーネントの定義には「ウェブコンポーネント」という標準が浸透してきているので、customElements.define()
で Custom Element を作ってやると良いでしょう。イベントリスナの適切な登録と削除についても、ウェブコンポーネントに閉じていれば CustomEvent
と disconnectedCallback()
などの仕組みで実装しやすくなります。
パス変数やタイトルの変更などは、ルーティングの処理中に自分で実装していくしかありません。実現不可能というわけではありませんが、段々コンパクトじゃなくなってきましたね…。
そして HTML の実装部分、やはり React の JSX なり、Angular や Vue のような HTML ファイルに分割した書き方をしたくなってきます。今回は全く触れていませんがコンポーネントに閉じた CSS の扱いも実現したいところです。
…そうなってくると、こうした諸問題を実装して解決してくれているフレームワークのありがたさがよく分かります。基本的な仕組みはこういう方向性なのだと理解したうえで、細かなところまでお膳立てをしてくれているフレームワークをありがたく使わせていただき、アプリケーションの開発に専念するとしましょう!
- 参考 : Vanilla JSでSPAやったるで
- 参考 : webkatu/vanilla-spa-sample: vanilla-spa-sample
- 参考 : vanilla-spa/_redirects at main · zathio/vanilla-spa · GitHub