jQueryでSPAアプリを最小限に実装する

고남현 @gnh1201@hackers.pub

ReactやVueなどのフレームワークの使用は現在では当たり前のことですが、一方では今でもそれが当たり前ではなく、使用可能なライブラリがjQueryしかないケースもかなりあります(jQuery No.1...)。

このような状況でSPA(Single-page application)アプリを作成しなければならない...?心配無用です。フレームワークの助けを一切受けないバニラな状態でアプリを作るよりはマシではないでしょうか。

7年前に実務で実際に使用されていたjQueryベースのSPA最小実装を共有したいと思います(当時はコーディングの上手なAIもなかったので、すべて手作業で書いたものです)。

SPAの経験がまったくない場合、このような最小実装を分析するプロセスを通じて、実際のReactやVueなどのSPAを作成するための主流フレームワークを理解する助けにもなるでしょう。

アプリの導入部

アプリの導入部はこのように始めれば良いです。シンプルです。現在の主流フレームワークでは<App />表現に近い導入部です。

(function($) {
    $.fn.App = {
        // set routing rules
        "routes": [
            { "path": "/", "tmpl": "main.tmpl.html" },
            { "path": "/gift", "tmpl": "gift.tmpl.html" },
            { "path": "/gift/:id", "tmpl": "gift.view.tmpl.html" },
            { "path": "/gallery", "tmpl": "gallery.tmpl.html" },
            { "path": "/contact", "tmpl": "contact.tmpl.html" },
            { "path": "/policy", "tmpl": "policy.tmpl.html" },
            { "path": "/policy/privacy", "tmpl": "policy.privacy.tmpl.html" },
            { "path": "/policy/email", "tmpl": "policy.email.tmpl.html" },
            { "path": "/policy/cookie", "tmpl": "policy.cookie.tmpl.html" },
            { "path": "/policy/copyright", "tmpl": "policy.copyright.tmpl.html" },
        ],

// set default title
        "title": "Gift soldout",

// set API url
        "apiUrl": "http://gift-soldout.local/",

// set page
        "page": 1,

// set limit per page
        "limit": 20,

// add script
        "addScript": function(url, callback) {
            var $s = $("<script/>").attr({
                "type": "text/javascript",
                "src": url
            }).appendTo("head");
            $s.promise().done(function() {
                if(typeof(callback) == "function") {
                    callback($s[0], $s[0].innerHTML);
                }
            });
        },

"addStylesheet": function(url, callback) {
            $s = $("<link/>").attr({
                "rel": "stylesheet",
                "type": "text/css",
                "href": url
            }).appendTo("head");
            $s.promise().done(function() {
                if(typeof(callback) == "function") {
                    callback($s[0], $s[0].innerHTML);
                }
            });
        },

"initScripts": function() {
            // disabled auto-completable form
            $("form.autocomplete-disabled").attr("autocomplete", "off");

// disable ime
            $("input.ime-disabled").css("ime-mode", "disabled");
        },

// (...omiited...)

ここまでで、スタイルシートとスクリプト、そしてURIルーティングルールが解決されます。

フォームに対して自動補完(autocomplete)や入力方式(IME)設定を調整する必要がある場合の処理も追加しています。

レンダリング部分

レンダリング部分では、実際にユーザーに表示するコンテンツを処理します。レンダリングプロセスと共にpushStatepopStateを活用して、SPAではない通常の静的ウェブサイトと同じブラウジング体験を提供します。

        // (...omiited...)
        "renderTemplate": function(uri, routes, _data, _options) {
            var _uri = URI.parse(uri);
            var _path = _uri.path;
            var _query = $().App.parseQuery(_uri.query);
            var _tmpl = "404.tmpl.html";
            var _matched = false;
            var _fail = function(xhr, status, error) {
                var _row = [xhr, status, error, xhr.responseText];
                var data = "('" + _row.join("','") + "')<=(xhr,status,error,responseText)";
                var qs = $.param({"route": "ajax.error.gif", "data": data});
                $("<img/>").attr("src", $().App.apiUrl + "?" + qs).appendTo("body");
                console.log(data);
                alert("We're sorry. Please try again in a few minutes.");
            };
            var _success = function(response) {
                var uiContent = $(".ui-content");
                var html = $.render.tmpl(response);
                var pageTitle = ("title" in response) ? response.title : $().App.title;
                uiContent.html(html);
                uiContent.find("a").click($().App.initClicks(_options));
                window.history.pushState({"html": html, "pageTitle": pageTitle}, "" , _path);
                $(document).find("title").text(pageTitle);
                $().App.initScripts();
            };

// override path
            _path = ("p" in _query) ? _query.p : _path;

// checking matched rule (try 1)
            for(var i in routes) {
                var paths = routes[i].path.split("/");
                var pos = routes[i].path.indexOf("/:");
                var _paths = _path.split("/");

// parse URI parameters
                if((paths.length - _paths.length) == 0 && pos > -1) {
                     if(_path.indexOf(routes[i].path.substring(0, pos)) == 0) {
                        _tmpl = routes[i].tmpl;
                        _matched = true;
                        for(var k in paths) {
                            if(paths[k].indexOf(":") == 0) {
                                _query[paths[k].substring(1)] = _paths[k];
                            }
                        }
                    }
                }
            }

// checking matched rule (try 2)
            if(_matched == false) {
                for(var i in routes) {
                     if(_path.indexOf(routes[i].path) == 0) {
                        _tmpl = routes[i].tmpl;
                        _matched = true;
                     }
                }
            }

// load template file
            $.get("/templates/" + _tmpl, function(response) {
                $.templates({ tmpl: response });
                if(!!_data) {
                    _success(_data);
                } else {
                    $().App.getItems(_path, {
                        "page": $().App.page,
                        "limit": $().App.limit,
                        "query": _query,
                        "referer": $().App.getCurrentUri()
                    }, _success, _fail);
                }
            }, "html").fail(_fail);

// after rendering template
            if("afterRenderTemplate" in _options) {
                _options.afterRenderTemplate(_path, _data);
            }
        },
        // (...omiited...)

データ連携

データベースから実際のデータを取得する必要があるため、データリクエストを処理するメソッドを追加します。

        // (...omiited...)
        // get items from remote server
        "getItems": function(uri, data, callback, error) {
            var params = { "route": "spa", "p": uri };
            $.ajax({
                url: $().App.apiUrl + "?" + $.param(params),
                type: "post",
                dataType: "json",
                data: data,
                success: callback,
                error: error
            });
        },
        // (...omiited...)

It's showtime

このように作成されたSPAアプリを実際に実行するためのアプリを作成します。

$(document).ready(function() {
    $().App.main({
        "afterRenderTemplate": function(path, data) {
            // レンダリング後処理の実装
        },

"afterSubmit": function(path, data) {
            // HTMLフォーム送信が発生した時の後処理実装
        }
    });

HTML/CSSパブリッシングはみんなできますよね...?パブリッシングを始めましょう。

<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Strict//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-strict.dtd">
<html xmlns="http://www.w3.org/1999/xhtml" lang="ko" xml:lang="ko">
    <head>
        <meta http-equiv="Content-Type" content="application/xhtml+xml; charset=utf-8"/>
        <meta http-equiv="X-UA-Compatible" content="IE=edge"/>
        <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0"/>

<title>Gift Soldout</title>
        <link href="https://gift-soldout.local/assets/css/app.main.css" rel="stylesheet" type="text/css"/>
        <link href="https://gift-soldout.local/favicon.ico" rel="icon" type="image/x-icon"/>
        <link href="https://gift-soldout.local/sitemap.html" rel="contents"/>
        <link href="https://gift-soldout.local/rss.xml" rel="sitemap" type="application/rss+xml"/>
        <link href="https://gift-soldout.local/favicon.ico" rel="icon" />
    </head>

<body>
        <div class="accessibility">
            <ul>
            <li><a href="#header">ウェブページの上部へ移動</a></li>
            <li><a href="#c_content">ウェブページの本文へ移動</a></li>
            <li><a href="#c_nav">ウェブページのメニューへ移動</a></li>
            <li><a href="#footer">ウェブページの下部へ移動</a></li>
            </ul>
        </div>
Gift Soldout

gift-soldout.local

Gift Soldout

少々お待ちください... Please wait...

このウェブサイトはJavaScriptを使用する必要があります。

正常にウェブサイトが利用できない場合は、サイトマップを確認するか、担当者にご連絡ください。

This website is only available for Javascript enabled web browser.

If you have trouble using our website, please check sitemap or contact me.

<form class="pure-form pure-form-aligned" action="#" method="post"> <legend>障害発生時にご連絡ください (Contact me)</legend> <fieldset>
<label for="name">Name</label> <input id="name" name="name" type="text" title="('Name')<=(PlaceHolder)"/> This is a required field.
<label for="email">Email</label> <input id="email" name="email" type="email" title="('Email')<=(PlaceHolder)">
<label for="message">Message</label> <textarea id="message" name="message" title="('Message')<=(PlaceHolder)"></textarea>
```
<button type="submit" class="pure-button pure-button-primary">Submit</button>
</fieldset> </form>
<form class="ui-form" id="form_gift" method="post" action="http://gift-soldout.local">

検索条件

</form>

現在の条件

機関名: ギフトソールドアウト、所在地: (00000) 太陽系 火星

メール: hello@gift-soldout.local、代表電話: 000-0000-0000、ファックス: 000-0000-0000

Gift Soldout

Location: Mars, Universe, Postcode: 0000

Email: hello@gift-soldout.local, Fax: +82-000-0000-0000, Telephone: +82-000-0000-0000

<script type="text/javascript">//</script>
    <!--[if lt IE 9]><script src="http://cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv.min.js" type="text/javascript"></script><![endif]-->
    <script src="https://gift-soldout.local/assets/js/jquery-3.4.1.js" type="text/javascript"></script>
    <script src="https://gift-soldout.local/assets/js/jsrender.js" type="text/javascript"></script>
    <script src="https://gift-soldout.local/assets/js/URI.js" type="text/javascript"></script>
    <script src="https://gift-soldout.local/assets/js/jquery.form.js" type="text/javascript"></script>
    <script src="https://gift-soldout.local/assets/js/app.main.js" type="text/javascript"></script>
</body>
</html> ```

テスト

導入部で見たルーティングルールに従って正常に表示されるか確認します。

静的コードホスティングサービスで使用する場合、以下のスクリプトを補助的に活用できます。

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8">
    <title>Single Page Apps for GitHub Pages</title>
    <script type="text/javascript">
      // Single Page Apps for GitHub Pages
      // https://github.com/rafrex/spa-github-pages
      // Copyright (c) 2016 Rafael Pedicini, licensed under the MIT License
      // ----------------------------------------------------------------------
      // This script takes the current url and converts the path and query
      // string into just a query string, and then redirects the browser
      // to the new url with only a query string and hash fragment,
      // e.g. http://www.foo.tld/one/two?a=b&c=d#qwe, becomes
      // http://www.foo.tld/?p=/one/two&q=a=b~and~c=d#qwe
      // Note: this 404.html file must be at least 512 bytes for it to work
      // with Internet Explorer (it is currently > 512 bytes)

// If you're creating a Project Pages site and NOT using a custom domain, // then set segmentCount to 1 (enterprise users may need to set it to > 1). // This way the code will only replace the route part of the path, and not // the real directory in which the app resides, for example: // https://username.github.io/repo-name/one/two?a=b&c=d#qwe becomes // https://username.github.io/repo-name/?p=/one/two&q=a=b~and~c=d#qwe // Otherwise, leave segmentCount as 0. var segmentCount = 0;

var l = window.location; l.replace( l.protocol + '//' + l.hostname + (l.port ? ':' + l.port : '') + l.pathname.split('/').slice(0, 1 + segmentCount).join('/') + '/?p=/' + l.pathname.slice(1).split('/').slice(segmentCount).join('/').replace(/&/g, '~and~') + (l.search ? '&q=' + l.search.slice(1).replace(/&/g, '~and~') : '') + l.hash );

</script> </head> <body> </body> </html> ```

このプロセスで使用された追加ライブラリ

0

1 comment

If you have a fediverse account, you can comment on this article from your own instance. Search https://hackers.pub/ap/articles/019a7218-260c-7089-b1a9-8a9ccae316ce on your instance and reply to it.

클릭 초기화를 빼먹었네. 다음과 같이 하면 돼.

        // (...omitted...)
        "initClicks": function(_options) {
            return function(e) {
                if($(this).attr("href").indexOf(":/") < 0) {
                    e.preventDefault();
                    $().App.renderTemplate($(this).attr("href"), $().App.routes, false, _options);
                }
            }
        },
        // (...omitted...)

읽어줘서 고마워 :)

0