タイダログ

もっと怠けますか? (y/n)

手探りで始める SPA (JavaScript)

この度、手探りで SPA を始め、「完全に理解した」ことをここに宣言します。

作ったものはこちらです。

taidalog.html.xdomain.jp

taidalog.hatenablog.com

これが SPA (Single Page Application) の定義に当てはまるかよくわかりませんが(これを Web Application と呼んでいいものかっていうことね)、まあ Single Page であることは確か (Single Page de Arukoto wa tashika) ですので、胸を張って宣言したいと思います。

私は SPA を「完全に理解した」と。

で、ここまでの成果や課題などを書き連ねるのが今回のテーマです。

※特に断りがない場合は、執筆当時の最新版 taidalab Version 1.5.1 で説明します。

作業環境

  • Windows 10 Home 21H2 (OS Build 19044.1826)
  • FireFox 103.0
  • VisualStudio Code

フレームワークは使用していません。

taidalab のページ構成

Home に各コースのボタンが並んでいて、クリックするとそのコースのページが開きます。

taidalab - Home

taidalab - 10進数→2進数 (1)

taidalab - 10進数→2進数 (2)

taidalab - 2進数→10進数 (1)

以下略。

ファイル構成

taidalab Version 0.13.0 までは MPA (Multiple Page Application) でした。その時の構成は以下の通りです。

C:.
│  about.html
│  index.html
│  terms.html
│
├─css
│      home.css
│      style.css
│
├─endless-bin2dec-1
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-bin2dec-2
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-binary-addition
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-binary-complement
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-binary-subtraction
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-dec2bin-1
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-dec2bin-2
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-power-of-two-1
│  │  index.html
│  │
│  └─js
│          main.js
│
├─endless-power-of-two-2
│  │  index.html
│  │
│  └─js
│          main.js
│
├─image
│      taidalab.png
│
└─js
        common.js

taidalab Version 1.0.0 からは SPA になり、HTML ファイルは index.html ひとつのみになりました。すっきり!

※ファイル構成は taidalab Version 1.5.1 のものです。

C:.
│  index.html
│
├─css
│      home.css
│      style.css
│
├─image
│      taidalab.png
│
└─js
    │  common.js
    │  not-found.js
    │
    └─endless-binary
            addition.js
            bin2dec-1.js
            bin2dec-2.js
            complement.js
            dec2bin-1.js
            dec2bin-2.js
            power-of-two-1.js
            power-of-two-2.js
            subtraction.js

ページ遷移

一般論

MPA では、複数の HTML を切り替えることで別のページに移ります。一方 SPA の場合は 1つの HTML ファイルを JavaScript で書き換えることで別のページを表示します。

SPA では、ページを切り替える際にサーバとの通信が発生しないので、通信速度が遅くてもサクサク動きます。パソコン室で40人が同時にアクセスしても大丈夫だよ! やったね!

HTML ファイルの内容は以下の通りです。body がすっからかん!

<!DOCTYPE html>
<html>
    <head>
        <title></title>
        <meta charset="utf-8">
        <meta name="description" content="taidalab: taidalogが作ったプログラム置き場">
        <meta name="viewport" content="width=device-width, initial-scale=1">
        <!-- SNS 関係の設定は省略 -->
        <!-- stylesheet -->
        <link rel="stylesheet" href="css/style.css">
        <link rel="stylesheet" href="css/home.css">
        <!-- script -->
        <script type="text/javascript" src="js/endless-binary/dec2bin-1.js"></script>
        <script type="text/javascript" src="js/endless-binary/dec2bin-2.js"></script>
        <script type="text/javascript" src="js/endless-binary/bin2dec-1.js"></script>
        <script type="text/javascript" src="js/endless-binary/bin2dec-2.js"></script>
        <script type="text/javascript" src="js/endless-binary/power-of-two-1.js"></script>
        <script type="text/javascript" src="js/endless-binary/power-of-two-2.js"></script>
        <script type="text/javascript" src="js/endless-binary/addition.js"></script>
        <script type="text/javascript" src="js/endless-binary/subtraction.js"></script>
        <script type="text/javascript" src="js/endless-binary/complement.js"></script>
        <script type="text/javascript" src="js/not-found.js"></script>
        <script type="text/javascript" src="js/common.js"></script>
    </head>
    <body>
        <header></header>
        <main></main>
        <footer></footer>
    </body>
</html>

この HTML ファイルと、script タグで指定したスクリプトファイルを読み込んだら、そこで通信終了ですよ。あとはブラウザ上で JavaScript が何とかしてくれます。

たとえば、Home を表示したければ、以下のようにして main に要素を追加します。

const mainContentHome = '\
<form class="button-container">\
    <button type="button" onclick="switchPage(\'/endless-dec2bin-1/\');" id="buttonESAD" class="btn button-esad">10進数→2進数 (1)</button>\
    <button type="button" onclick="switchPage(\'/endless-dec2bin-2/\');" id="buttonED2B" class="btn button-ed2b">10進数→2進数 (2)</button>\
    <button type="button" onclick="switchPage(\'/endless-bin2dec-1/\');" id="buttonEB2D" class="btn button-eb2d">2進数→10進数 (1)</button>\
    <button type="button" onclick="switchPage(\'/endless-bin2dec-2/\');" id="buttonEB2D" class="btn button-eb2d">2進数→10進数 (2)</button>\
    <button type="button" onclick="switchPage(\'/endless-power-of-two-1/\');" id="buttonEPOT" class="btn button-epot">2のn乗</button>\
    <button type="button" onclick="switchPage(\'/endless-power-of-two-2/\');" id="buttonEPOTEX" class="btn button-epotex">2のn乗 - 1</button>\
    <button type="button" onclick="switchPage(\'/endless-addition/\');" id="buttonEBAD" class="btn button-ebad">加算</button>\
    <button type="button" onclick="switchPage(\'/endless-subtraction/\');" id="buttonEBSB" class="btn button-ebsb">減算</button>\
    <button type="button" onclick="switchPage(\'/endless-complement/\');" id="buttonECMP" class="btn button-ecmp">補数</button>\
</form>\
';

document.getElementsByTagName('main')[0].innerHTML = mainContentHome;

Home の main に要素を追加

各コースを表示したければ、以下のようにして main とその中身に要素を追加します。

const mainContentPages = '\
<div id="questionArea" class="question-area"></div>\
<form class="input-area">\
    <input type="text" id="numberInput" class="number-input consolas">\
    <span id="binaryRadix" class="binary-radix"></span>\
    <input type="submit" value ="確認" id="submitButton">\
    <div id="hintArea" class="hint-area"></div>\
    <div id="errorArea" class="error-area"></div>\
</form>\
<div class="history-area">\
    結果:\
    <div class="history-indented consolas">\
        <span id="outputArea"></span>\
    </div>\
</div>\
';

document.getElementsByTagName('main')[0].innerHTML = mainContentPages;

// 以下は簡略化した例です
const questionContentPages = '<span id="questionSpan" class="question-number"></span><sub id="srcRadix"></sub> を<span id="dstRadix"></span>進法で表すと?';
document.getElementById('questionArea').innerHTML = questionContentPages;
document.getElementById('questionSpan').innerText = 130;
document.getElementById('srcRadix').innerText = 10;
document.getElementById('dstRadix').innerText = 2;
document.getElementById('submitButton').className = 'submit-button d2b-button';

コースの main に要素を追加

taidalab での実現方法

ユーザが、ページを切り替えるためのボタンを押したら、以下の流れでページの書き換えを行うようにしました。

  1. 次に表示したいページの内容をまとめたオブジェクトを作成する
  2. 次に表示したいページの URL を履歴に追加する
  3. 1 で作成したオブジェクトを関数に渡してページを書き換える

まず、ページを切り替えるためのボタンを押すと、switchPage() という関数が動きます。これは上記1~3の処理をまとめた関数です。switchPage() には、次に表示したいページの URL が渡っています。たとえば、「10進数→2進数 (1)」コースの場合、switchPage('/endless-dec2bin-1/') となっています。実際にその URL に HTML ファイルがあるわけではありませんが、ページを識別するために架空の URL を使っています。

function switchPage (pathname) {
    const initialObject = newInitObject(pathname);
    window.history.pushState(null, null, initialObject.pathname);
    initPage(initialObject);
}

switchPage() に渡った URL は、そのまま newInitObject() という関数に渡ります。newInitObject() は、受け取った URL を元に、「HTML に追加する内容をまとめたオブジェクト」を返す関数です。たとえば、ページのタイトルや、ヘッダーに指定するクラス、問題の形式などを含んでいます。

function newInitObject (pathname) {
    console.log(pathname);
    switch (pathname) {
        case '/':
            console.log('/');
            return {
                pathname: '/',
                title: 'taidalab',
                headerContent: headerContentPages,
                headerColorClass: 'home-header',
                headerTitle: '<h1>taidalab</h1>',
                mainContent: mainContentHome,
                buttonColorClass: null,
                questionContent: null,
                footerContent: footerContentHome,
                widthClass: "home",
                versionNumber: versionNumber,
                initFunc: null
            };
        case '/endless-dec2bin-1/':
            console.log('/endless-dec2bin-1/');
            return {
                pathname: '/endless-dec2bin-1/',
                title: '10進数→2進数 (1) - taidalab',
                headerContent: headerContentPages,
                headerColorClass: 'd2b-header',
                headerTitle: '<h1>10進数→2進数 (1)</h1>',
                mainContent: mainContentPages,
                buttonColorClass: 'submit-button d2b-button',
                questionContent: questionContentPages,
                footerContent: footerContentPages,
                widthClass: "course",
                versionNumber: versionNumber,
                initFunc: function () { initDec2Bin1(); }
            };
        case '/endless-bin2dec-1/':
            console.log('/endless-bin2dec-1/');
            return {
                pathname: '/endless-bin2dec-1/',
                title: '2進数→10進数 (1) - taidalab',
                headerContent: headerContentPages,
                headerColorClass: 'b2d-header',
                headerTitle: '<h1>2進数→10進数 (1)</h1>',
                mainContent: mainContentPages,
                buttonColorClass: 'submit-button b2d-button',
                questionContent: questionContentPages,
                footerContent: footerContentPages,
                widthClass: "course",
                versionNumber: versionNumber,
                initFunc: function () { initBin2Dec1(); }
            };
        (略)
    }
}

実際にページを書き換える前に、window.history.pushState() で次に表示したいページの URL を履歴に追加します。こうすることで、ブラウザの戻るボタンや進むボタンでページの移動ができるようになります。

最後に、newInitObject() で作成したオブジェクトを initPage() という関数に渡してページを書き換えます。これはもう、受け取ったオブジェクトの内容を HTML に追加するだけの関数です。

function initPage (initial_object) {
    document.title = initial_object.title;
    document.getElementsByTagName('header')[0].innerHTML = initial_object.headerContent;
    document.getElementsByTagName('header')[0].className = initial_object.headerColorClass + ' ' + initial_object.widthClass;
    document.getElementById('headerContainer').innerHTML = initial_object.headerTitle;
    document.getElementsByTagName('main')[0].className = initial_object.widthClass;
    document.getElementsByTagName('main')[0].innerHTML = initial_object.mainContent;
    document.getElementsByTagName('footer')[0].innerHTML = initial_object.footerContent;
    document.getElementsByTagName('footer')[0].className = initial_object.widthClass;

    if (initial_object.questionContent != null) {
        document.getElementById('questionArea').innerHTML = initial_object.questionContent;
    }

    if (initial_object.buttonColorClass != null) {
        document.getElementById('submitButton').className = initial_object.buttonColorClass;
    }

    if (initial_object.radixContent != null) {
        document.getElementById('binaryRadix').innerHTML = initial_object.radixContent;
    }

    if (initial_object.versionNumber != null) {
        document.getElementById('versionNumber').innerText = initial_object.versionNumber;
    }

    if (initial_object.initFunc != null) {
        initial_object.initFunc();
    }
}

初回の読み込みでは、newInitObject()'/' を渡して initPage() することで、Home を表示しています。

window.addEventListener("DOMContentLoaded", (ev) => {
    console.log('DOMContentLoaded');
    const initialObject = newInitObject('/');
    initPage(initialObject);
});

ブラウザの戻るボタンや進むボタンを押した際は、移動先のページの URL を newInitObject() に渡して initPage() しています。

window.addEventListener("popstate", (ev) => {
    const initialObject = newInitObject(window.location.pathname);
    initPage(initialObject);
});

リロード対策

Home 以外でリロードすると Not Found になります。その URL には HTML ファイルがないから、当然そうなりますわね。

これを、.htaccess の設定で Not Found ページを独自の HTML ファイルにして、そこから元のページにリダイレクトする、ということがしたいのですが、.htaccess の設定が上手くいきません。どうして。

ちなみに、こう書いています。index.html はルート直下に存在します。

ErrorDocument 404 /index.html

(2022/08/01 追記)

上記の通りに .htaccess を編集すると、Not Found ページではなく空白のページに飛ぶようになっていました。開発ツールでログを見たところ、

http://taidalog.html.xdomain.jp/endless-dec2bin-1/css/style.css が見つかりません
http://taidalog.html.xdomain.jp/endless-dec2bin-1/css/home.css が見つかりません
http://taidalog.html.xdomain.jp/endless-dec2bin-1/js/endless-binary/dec2bin-1.js が見つかりません

のようなメッセージが並んでいました。

これはあれですね。index.htmlhead 内で、CSS ファイルや JavaScript ファイルのパスを / で始めなかったせいですね。「一般論」に戻ってみたらわかります。

/ で始めると、そのサイトのルートからのパスだと解釈してくれます。"/css/style.css" は、"http://taidalog.html.xdomain.jp/css/style.css" という意味です。

逆に / で始めないと、現在のパスからのパスになります。"http://taidalog.html.xdomain.jp/endless-dec2bin-1/" でリロードして Not Found になり、上記の設定の通り "http://taidalog.html.xdomain.jp/index.html" に飛んだ場合、"css/style.css" は、"http://taidalog.html.xdomain.jp/endless-dec2bin-1/css/style.css" という意味です。そんなところに CSS ファイルや JavaScript ファイルを置いていませんので、正常に参照できず、ページを描画できなかったわけですね。

そんなわけで index.htmlhead 内を以下のように書き換えたら上手く動きました(変更箇所のみ抜粋)。

        <!-- stylesheet -->
        <link rel="stylesheet" href="/css/style.css">
        <link rel="stylesheet" href="/css/home.css">
        <!-- script -->
        <script type="text/javascript" src="/js/endless-binary/dec2bin-1.js"></script>
        <script type="text/javascript" src="/js/endless-binary/dec2bin-2.js"></script>
        <script type="text/javascript" src="/js/endless-binary/bin2dec-1.js"></script>
        <script type="text/javascript" src="/js/endless-binary/bin2dec-2.js"></script>
        <script type="text/javascript" src="/js/endless-binary/power-of-two-1.js"></script>
        <script type="text/javascript" src="/js/endless-binary/power-of-two-2.js"></script>
        <script type="text/javascript" src="/js/endless-binary/addition.js"></script>
        <script type="text/javascript" src="/js/endless-binary/subtraction.js"></script>
        <script type="text/javascript" src="/js/endless-binary/complement.js"></script>
        <script type="text/javascript" src="/js/not-found.js"></script>
        <script type="text/javascript" src="/js/common.js"></script>

……そうですね、いろいろなサイト様に注意事項として載っていることですね。

とりあえず、Home へのリダイレクトはできました。今後は元のページに戻れるようにします。

(2022/08/01 追記ここまで)

リロード対策(完成)

taidalab での実現方法」で書いた通り、taidalab Version 1.5.1 では以下の関数(+組み込みのメソッド)でページ遷移を実現しています。

  1. switchPage() 関数に移動先のパスを渡す
  2. 1 のパスを newInitObject() 関数に渡し、ページに表示する内容をまとめたオブジェクトを生成する
  3. window.history.pushState() で、移動先のパスを履歴に追加する
  4. 2 のオブジェクトを initPage() 関数に渡し、ページを書き換える

なおかつ、初回の読み込みで newInitObject()'/' を渡して initPage() することで Home を表示しています。

(ここまで「taidalab での実現方法」の内容)

taidalab Version 1.6.0 からは、初回の読み込みで newInitObject() に渡すものを、'/' ではなく window.location.pathname に変更しました。これは現在のパスという意味です。初回は "http://taidalog.html.xdomain.jp" = '/' を開いているはずですので、同じことですね。switchPage()'/' が渡り、Home が表示されます。

さて、この状態でサイト内の別のページ、仮に "/endless-dec2bin-1/" としましょう、そこへ飛んでリロードするとどうなるでしょうか。Not Found になります。その URL には HTML ファイルがないから、当然そうなりますわね(2度目)。そして、.htaccess に以下のように設定してあるので、index.html に飛びます。

ErrorDocument 404 /index.html

この時、パスは "/endless-dec2bin-1/" のままです。そういうものらしいです。パスは変わっていませんが、index.html へ飛ばされてきたので、再度 index.html を開いたことになります。つまり「初回の読み込み」ということになるので、newInitObject()window.location.pathname = "/endless-dec2bin-1/" が渡ります。結果、"/endless-dec2bin-1/" のページが表示され、画面のリロードができたことになります。

存在するページの URL を直接打ち込んだ場合も、同様の流れでそのページを表示します。

めでたしめでたし。

404.html 無しで Not Found ページを表示する

taidalab - 404: Page Not Found

taidalab Version 1.6.0 で実現できました。

リロード対策(完成)」の延長線上の話です。

存在しない URL にアクセスしようとすると当然 Not Found になって index.html に飛びますわね。

この時のパスは、アクセスしようとした、存在しない URL のパスのままです。リダイレクトにより index.html に飛びますが、「初回の読み込み」ということになるので、newInitObject()window.location.pathname =「存在しないパス」が渡ります。newInitObject() は、存在しないパスを受け取ると、Not Found ページ用のオブジェクトを返すようになっています。そのオブジェクトが initPage() に渡ることで、Not Found ページが表示されます。

このようにして、404.html を別途作成することなく Not Found ページを表示することができました。

They lived happily ever after.

結び

taidalab には複数のコースがありますが、各コースのページは作りがほぼ同じです。これを MPA として作っていた時には、中身がほぼ同じ HTML ファイルが複数個あったことになります。同じものはひとつにまとめたい性分の私としましては納得いかない状態でした。

この度、SPA として作り替えたことで、ファイルがひとつにまとまり、また通信の頻度を落とすことができました。作る側と使う側の双方の精神衛生によろしい仕上がりになったことを嬉しく思います。

あとはこれを F# で書けたらいいんだけどなぁ……

(2022/08/15 追記)

F# で書きました!

taidalog.hatenablog.com

(2022/08/15 追記ここまで)

参考

更新履歴