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)設定を調整する必要がある場合の処理も追加しています。
レンダリング部分
レンダリング部分では、実際にユーザーに表示するコンテンツを処理します。レンダリングプロセスと共にpushState、popStateを活用して、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>
少々お待ちください... 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 class="cnal_label"> <input class="hidden" type="checkbox" name="chk_enabled[keyword]" value="1"/> キーワード </label> <input class="text" type="text" size="12" name="keyword"/> <button class="cnal_button" type="submit">追加</button>
- <label class="cnal_label"> <input class="hidden" type="checkbox" name="chk_enabled[material_type]" value="1"/> 鉱物 </label> <label><input type="radio" name="material_type" value="kaolin"> ウラン</label> <label><input type="radio" name="material_type" value="stone"> プルトニウム</label> <label><input type="radio" name="material_type" value="clay"> ラジウム</label> <button class="cnal_button" type="submit">追加</button>
- <label class="cnal_label"> <input class="hidden" type="checkbox" name="chk_enabled[temperture]" value="1"/> 焼成(℃) </label> <input class="text" type="text" size="2" name="max_temperture" value="1250" title="最高温度" /> > 温度 > <input class="text" type="text" size="2" name="min_temperture" value="1000" title="最低温度" /> <button class="cnal_button" type="submit">追加</button>
- <label class="cnal_label"> <input class="hidden" type="checkbox" name="chk_enabled[mood]" value="1"/> 雰囲気 </label> <label><input type="radio" name="mood" value="oxidation"> 酸化</label> <label><input type="radio" name="mood" value="reduction"> 還元</label> <button class="cnal_button" type="submit">追加</button>
- <label class="cnal_label"> <input class="hidden" type="checkbox" name="chk_enabled[absorption]" value="1"/> 吸収(%) </label> <input class="text" type="text" size="2" name="max_absorption" value="100" title="最大吸収率" /> > 吸収率 > <input class="text" type="text" size="2" name="min_absorption" value="0" title="最小吸収率" /> <button class="cnal_button" type="submit">追加</button>
- <label class="cnal_label"> <input class="hidden" type="checkbox" name="chk_enabled[contraction]" value="1"/> 収縮(%) </label> <input class="text" type="text" size="2" name="max_contraction" value="100" title="最大収縮率" /> > 収縮率 > <input class="text" type="text" size="2" name="min_contraction" value="0" title="最小収縮率" /> <button class="cnal_button" type="submit">追加</button>
- <label class="cnal_label"> <input class="hidden" type="checkbox" name="chk_enabled[lightness]" value="1"/> 色度(CIE) </label> <input class="text" type="text" size="2" name="max_lightness" value="150" title="最大L色度" /> > L > <input class="text" type="text" size="2" name="min_lightness" value="0" title="最小L色度" /> <button class="cnal_button" type="submit">追加</button>
現在の条件
-
条件なし
指定された条件がありません。
機関名: ギフトソールドアウト、所在地: (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
<!--[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> ```このプロセスで使用された追加ライブラリ
- URI.js - http://medialize.github.io/URI.js/
- jQuery Form - http://malsup.com/jquery/form/
- jsRender - http://jsviews.com/#jsrender
