jQuery로 SPA 앱 최소 구현하기
고남현 @gnh1201@hackers.pub
React나 Vue 등의 프레임워크 사용은 지금은 매우 당연시되지만, 한편으로는 지금까지도 당연시되지 않아 그나마 사용 가능한 라이브러리가 jQuery 밖에 없는 경우도 꽤나 있다. (jQuery no.1...)
이런 상황에서 SPA(Single-page application) 앱을 작성해야 한다....? 걱정말라. 아예 바닐라(프레임워크의 조력을 받지 않는) 상태로 앱을 짜라는 것 보단 낫지 않은가.
7년전 실무에서 실제로 쓰였던 jQuery 기반의 SPA 최소 구현을 공유해보고자 한다. (그땐 코딩 잘하는 AI도 없으니 일일히 다 짠거다.)
SPA 경험이 아예 없는 경우 이러한 최소 구현을 분석하는 과정을 통해 실제 React, Vue 같은 SPA 작성에 쓰이는 주류 프레임워크를 이해하는데에도 도움이 될 것이다.
App 도입부
App 도입부는 이렇게 시작하면 된다. 간단하다. 현재의 주류 프레임워크에서는 <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 Route 규칙이 해결된다.
양식(Form)에 대해서 자동완성(autocomplete) 및 입력기(IME) 설정을 조정해야 되는 경우에 대한 처리도 부가적으로 추가했다.
렌더링(Rendering) 부
렌더링 부분에서는 실제 사용자에게 보여줄 컨텐츠를 처리하게 된다. 렌더링 과정과 함께 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 form 전송이 일어났을 때 후처리 구현
}
});
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>
<div id="wrap">
<div id="header">
<div class="l_head">
<div class="cmt">Gift Soldout</div>
<h1 class="logo"><a href="/"><img src="https://gift-soldout.local/assets/img/logo.png" height="64" width="auto" alt="gift-soldout.local"/></a></h1>
<div class="lh_second">
<span class="one">Gift</span>
<span class="two">Soldout</span>
</div>
</div>
<div class="r_head">
<ul class="rh_nav">
<li class="rhn_item1"><a href="/"><span>검색</span></a></li>
<li class="rhn_item2"><a href="/gallery"><span>자료</span></a></li>
<li class="rhn_item3"><a href="/contact"><span>문의</span></a></li>
</ul>
</div>
</div>
<div id="content">
<div id="c_content" class="c_content ui-content">
<div class="cc_activity">
<div class="cca_title">
<h1>잠시만 기다려 주세요... Please wait...</h1>
<div class="ccat_clear"></div>
</div>
<div class="cca_content">
<p>본 웹 사이트는 자바스크립트(Javascript)를 사용하여야 원활한 이용이 가능합니다.</p>
<p>정상적인 웹 사이트 이용이 안되는 경우 <a href="/sitemap.html">사이트맵</a>을 확인하거나, 담당자에게 연락주시기 바랍니다.</p>
<p>This website is only available for Javascript enabled web browser.</p>
<p>If you have trouble using our website, please check <a href="/sitemap.html">sitemap</a> or contact me.</p>
<form class="pure-form pure-form-aligned" action="#" method="post">
<legend>장애 발생 시 연락주세요 (Contact me)</legend>
<fieldset>
<div class="pure-control-group">
<label for="name">Name</label>
<input id="name" name="name" type="text" title="('Name')<=(PlaceHolder)"/>
<span class="pure-form-message-inline">This is a required field.</span>
</div>
<div class="pure-control-group">
<label for="email">Email</label>
<input id="email" name="email" type="email" title="('Email')<=(PlaceHolder)">
</div>
<div class="pure-control-group">
<label for="message">Message</label>
<textarea id="message" name="message" title="('Message')<=(PlaceHolder)"></textarea>
</div>
<div class="pure-controls">
<button type="submit" class="pure-button pure-button-primary">Submit</button>
</div>
</fieldset>
</form>
</div>
</div>
</div>
<div id="c_nav" class="c_nav">
<div class="cn_activity">
<form class="ui-form" id="form_gift" method="post" action="http://gift-soldout.local">
<div class="hidden">
<input type="hidden" name="route" value="spa" />
<input type="hidden" name="p" value="/gift" />
</div>
<div class="cna_title">
<h1>검색 조건</h1>
<div class="cna_clear"></div>
</div>
<ul class="cna_list">
<li class="nolink icon icon-search">
<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>
</li>
<li class="nolink icon icon-search">
<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>
</li>
<li class="nolink icon icon-search">
<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>
</li>
<li class="nolink icon icon-search">
<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>
</li>
<li class="nolink icon icon-search">
<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>
</li>
<li class="nolink icon icon-search">
<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>
</li>
<li class="nolink icon icon-search">
<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>
</li>
</ul>
</form>
</div>
<div class="cn_activity">
<div class="cna_title">
<h1>현재 조건</h1>
<div class="cna_clear"></div>
</div>
<ul id="assist_gift" class="cna_list">
<li class="nolink icon icon-search">
<p class="cnal_title">조건 없음</p>
<p>지정된 조건이 없습니다.</p>
</li>
</ul>
</div>
</div>
<div class="c_clear"></div>
</div>
<div id="footer">
<div class="nav_copy">
<ul>
<li><a href="#"><span>개인정보처리방침</span></a></li>
<li><a href="#"><span>이메일무단수집거부</span></a></li>
<li><a href="#"><span>저작권정책</span></a></li>
<li><a href="#"><span>찾아오시는 길</span></a></li>
</ul>
<div class="nc_clear"></div>
</div>
<div class="address_box">
<div class="detail">
<p>기관명: 기프트솔드아웃, 소재지: (00000) 태양계 화성</p>
<p>전자우편: <a href="mailto:hello@gift-soldout.local">hello@gift-soldout.local</a>, 대표전화: 000-0000-0000, 팩스: 000-0000-0000</p>
<p>Gift Soldout</p>
<p>Location: Mars, Universe, Postcode: 0000</p>
<p>Email: <a href="mailto:hello@gift-soldout.local">hello@gift-soldout.local</a>, Fax: +82-000-0000-0000, Telephone: +82-000-0000-0000</p>
</div>
<div class="related">
<ul>
<li><a href="/go/xhtml10"><img src="https://gift-soldout.local/assets/img/valid-xhtml10.png" alt="Valid XHTML 1.0 Strict" title="본 웹사이트는 XHTML 1.0 Strict(엄격) 표준을 만족합니다." height="31" width="88"/></a></li>
</ul>
</div>
<div class="ab_clear"></div>
</div>
</div>
</div>
<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>
<script type="text/javascript">//<!--<![CDATA[
(function(l) {
if (l.search) {
var q = {};
l.search.slice(1).split('&').forEach(function(v) {
var a = v.split('=');
q[a[0]] = a.slice(1).join('=').replace(/~and~/g, '&');
});
if (q.p !== undefined) {
window.history.replaceState(null, null,
l.pathname.slice(0, -1) + (q.p || '') +
(q.q ? ('?' + q.q) : '') +
l.hash
);
}
}
}(window.location))
//]]>--></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>
테스트
도입부에서 보았던 Route 규칙에 따라 정상적으로 표시되는지 확인한다.
정적 코드 호스팅 서비스에서 사용하는 경우 아래 스크립트를 보조적으로 활용할 수 있다.
<!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