VanillaJS で SPA を自作してみる

Pocket

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.htmlspa.html#!/ でアクセスしてきた場合は HomeComponent
  • spa.html#!/about は AboutComponent
  • spa.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 を作ってやると良いでしょう。イベントリスナの適切な登録と削除についても、ウェブコンポーネントに閉じていれば CustomEventdisconnectedCallback() などの仕組みで実装しやすくなります。

パス変数やタイトルの変更などは、ルーティングの処理中に自分で実装していくしかありません。実現不可能というわけではありませんが、段々コンパクトじゃなくなってきましたね…。

そして HTML の実装部分、やはり React の JSX なり、Angular や Vue のような HTML ファイルに分割した書き方をしたくなってきます。今回は全く触れていませんがコンポーネントに閉じた CSS の扱いも実現したいところです。

…そうなってくると、こうした諸問題を実装して解決してくれているフレームワークのありがたさがよく分かります。基本的な仕組みはこういう方向性なのだと理解したうえで、細かなところまでお膳立てをしてくれているフレームワークをありがたく使わせていただき、アプリケーションの開発に専念するとしましょう!

Pocket

コメントを残す

メールアドレスが公開されることはありません。 * が付いている欄は必須項目です