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> &nbsp;
                                    <label><input type="radio" name="material_type" value="stone"> 플루토늄</label> &nbsp;
                                    <label><input type="radio" name="material_type" value="clay"> 라듐</label> &nbsp;
                                    <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"/>
                                        소성(&#8451;)
                                    </label>
                                    <input class="text" type="text" size="2" name="max_temperture" value="1250" title="최대 온도" />
                                    &gt; 온도 &gt;
                                    <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> &nbsp;
                                    <label><input type="radio" name="mood" value="reduction"> 환원</label> &nbsp;
                                    <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="최대 흡수율" />
                                    &gt; 흡수율 &gt;
                                    <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="최대 수축율" />
                                    &gt; 수축율 &gt;
                                    <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 색도" />
                                    &gt; L &gt;
                                    <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>

이 과정에 사용된 부가적인 라이브러리

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