開發無框架單頁面應用

基礎對象

首先是定義缺省的兩個頁面片斷(缺省頁面和出錯頁面,這兩個頁面是基礎功能,因此放在庫裏)相關代碼,對每一個片斷對應的url(例如home)定義一個同名的對象,裏面存放了對應的 html 片斷文件路徑、初始化方法。css

var home = {};            //default partial page, which will be loaded initially
home.partial = "lib/home.html";
home.init = function(){   //bootstrap method
                          //nothing but static content only to render
}
 
var notfound = {};               //404 page
notfound.partial = "lib/404.html";
notfound.init = function(){
    alert('URL does not exist. please check your code.');
}

隨後是全局變量,包含了 html 片斷代碼的緩存、局部刷新所在 div 的 DOM 對象和向後端服務請求返回的根數據(rootScope,初始化時未出現,在後面的方法中才會用到):html

var settings = {};               //global parameters
settings.partialCache = {};      //cache for partial pages
settings.divDemo = document.getElementById("demo");      //div for loading partials, defined in index.html

主程序

下面就是主程序了,全部的公用方法打包放到一個對象miniSPA中,這樣能夠避免污染命名空間:node

// Main Object here
var miniSPA = {};

而後是 changeUrl 方法,對應在index.html中有以下觸發定義:git

<body onhashchange="miniSPA.changeUrl();">

onhashchange是在location.hash發生改變的時候觸發的事件,可以經過它獲取局部 url 的改變。在index.html中定義了以下的連接:github

<h1> Demo Contents:</h1>
    <a href="#home">Home (Default)</a>
    <a href="#postMD">POST request</a>
    <a href="#getEmoji">GET request</a>
    <a href="#wrong">Invalid url</a>
    <div id="demo"></div>

每一個 url 都以#號開頭,這樣就能被onhashchange事件抓取到。最後的 div 就是局部刷新的 html 片斷嵌入的位置。ajax

miniSPA.changeUrl = function() {          //handle url change
    var url = location.hash.replace('#','');
    if(url === ''){
        url = 'home';           //default page
    }
    if(! window[url]){
        url = "notfound";
    }
    miniSPA.ajaxRequest(window[url].partial, 'GET', '',function(status, page){
        if(status == 404){
            url = 'notfound';       //404 page
            miniSPA.ajaxRequest(window[url].partial,'GET','',function(status, page404){
                settings.divDemo.innerHTML = page404;
                miniSPA.initFunc(url);              //load 404 controller
            });
        }
        else{
            settings.divDemo.innerHTML = page;
            miniSPA.initFunc(url);              //load url controller
        }
    });
}

上面的代碼先獲取改變後的 url,先經過window[url]找到對應的對象(相似於最上部定義的homenotfound),如對象不存在(無定義的路徑)則轉到404處理,不然經過ajaxRequest方法獲取window[url].partial中定義的 html 片斷並加載到局部刷新的div,並執行window[url].init初始化方法。正則表達式

ajaxRequest方法主要是和後端的服務進行交互,經過XMLHttpRequest發送請求(GETPOST),若是獲取的是 html 片斷就把它緩存到settings.partialCache[url]裏,由於 html 片斷是相對固定的,每次請求返回的內容不會變化。若是是其餘請求(好比向 Github 的 markdown 服務 POST 一個字符串)就不能緩存了。bootstrap

miniSPA.ajaxRequest = function(url, method, data, callback) {    //load partial page
    if(settings.partialCache[url]){
        callback(200, settings.partialCache[url]);
    }
    else {
        var xmlhttp;
        if(window.XMLHttpRequest){
            xmlhttp = new XMLHttpRequest();
            xmlhttp.open(method, url, true);
            if(method === 'POST'){
                xmlhttp.setRequestHeader("Content-type","application/x-www-form-urlencoded");
            }
            xmlhttp.send(data);
            xmlhttp.onreadystatechange = function(){
                if(xmlhttp.readyState == 4){
                    switch(xmlhttp.status) {
                        case 404:                             //if the url is invalid, show the 404 page
                            url = 'notfound';
                            break;
                        default:
                            var parts = url.split('.');
                            if(parts.length>1 && parts[parts.length-1] == 'html'){         //only cache static html pages
                                settings.partialCache[url] = xmlhttp.responseText;        //cache partials to improve performance
                            }
                    }
                    callback(xmlhttp.status, xmlhttp.responseText);
                }
            }
        }
        else{
            alert('Sorry, your browser is too old to run this app.')
            callback(404, {});
        }
    }
}

對於不支持XMLHttpRequest的瀏覽器(主要是 IE 老版本),原本是能夠在 else 里加上xmlhttp = new ActiveXObject(‘Microsoft.XMLHTTP’);的,不過,我手頭也沒有那麼多老版本 IE 用於測試,並且老版本 IE 原本就是我深惡痛絕的東西,憑什麼要支持它啊?因此就乾脆直接給個alert完事。後端

render方法通常在每一個片斷的初始化方法中調用,它會設定全局變量中的根對象,並經過refresh方法渲染 html 片斷。api

miniSPA.render = function(url){
    settings.rootScope = window[url];
    miniSPA.refresh(settings.divDemo, settings.rootScope);
}

獲取後端數據後,如何渲染 html 片斷是個比較複雜的問題。這就是 DOM 操做了。整體思想就是從 html 片斷的根部入手,遍歷 DOM 樹,逐個替換屬性和文本中的佔位變量(例如<img src="emojis.value"><p>{{emojis.key}}</p>),匹配和替換是在feedData方法中完成的。

這裏最麻煩的是data-repeat屬性,這是爲了批量渲染格式相同的一組元素用的。好比從 Github 獲取了全套的 emoji 表情,共計 888 個(也許下次升級到1000個),就須要渲染 888 個元素,把 888 個圖片及其說明放到 html 片斷中去。而 html 片斷中對此只有一條定義:

<ul>
        <li data-repeat="emojis" data-item="data">
            <figure>
                <img src='{{data.value}}' width='100' height='100'>
                <figcaption>{{data.key}}</figcaption>
            </figure>
        </li>
    </ul>

等 888 個 emoji 表情來了以後,就要自動把<li>元素擴展到 888 個。這就須要先clone定義好的元素,而後根據後臺返回的數據逐個替換元素中的佔位變量。

miniSPA.refresh = function(node, scope) {
    var children = node.childNodes;
    if(node.nodeType != 3){                            //traverse child nodes, Node.TEXT_NODE == 3
        for(var k=0; k<node.attributes.length; k++){
            node.setAttribute(node.attributes[k].name, miniSPA.feedData(node.attributes[k].value, scope));       //replace variables defined in attributes
        }
        var childrenCount = children.length;
        for(var j=0; j<childrenCount; j++){
            if(children[j].nodeType != 3 && children[j].hasAttribute('data-repeat')){     //handle repeat items
                var item = children[j].dataset.item;
                var repeat = children[j].dataset.repeat;
                children[j].removeAttribute('data-repeat');
                var repeatNode = children[j];
                for(var prop in scope[repeat]){
                    repeatNode = children[j].cloneNode(true);                  //clone sibling nodes for the repeated node
                    node.appendChild(repeatNode);
                    var repeatScope = scope;
                    var obj = {};
                    obj.key = prop;
                    obj.value = scope[repeat][prop];                           //add the key/value pair to current scope
                    repeatScope[item] = obj;
                    miniSPA.refresh(repeatNode,repeatScope);                           //iterate over all the cloned nodes
                }
                node.removeChild(children[j]);                                 //remove the empty template node
            }
            else{
                miniSPA.refresh(children[j],scope);                                    //not for repeating, just iterate the child node
            }
        }
    }
    else{
        node.textContent = miniSPA.feedData(node.textContent, scope);           //replace variables defined in the template
    }
}

從上面的代碼能夠看到,refresh方法是一個遞歸執行的函數,每次處理當前 node 以後,還會遞歸處理全部的孩子節點。經過這種方式,就能把模板中定義的全部元素的佔位變量都替換爲真實數據。

feedData用來替換文本節點中的佔位變量。它經過正則表達式獲取{{...}}中的內容,並把多級屬性(例如data.map.value)切分開,逐級循環處理,直到最底層得到相應的數據。

miniSPA.feedData = function(template, scope){                                     //replace variables with data in current scope
    return template.replace(/\{\{([^}]+)\}\}/gmi, function(model){
        var properties = model.substring(2,model.length-2).split('.');          //split all levels of properties
        var result = scope;
        for(var n in properties){
            if(result){
                switch(properties[n]){                  //move down to the deserved value
                    case 'key':
                        result = result.key;
                        break;
                    case 'value':
                        result = result.value;
                        break;
                    case 'length':                     //get length from the object
                        var length = 0;
                        for(var x in result) length ++;
                        result = length;
                        break;
                    default:
                        result = result[properties[n]];
                }
            }
        }
        return result;
    });
}

initFunc方法的做用是解析片斷對應的初始化方法,判斷其類型是否爲函數,並執行它。這個方法是在changeUrl方法裏調用的,每次訪問路徑的變化都會觸發相應的初始化方法。

miniSPA.initFunc = function(partial) {                            //execute the controller function responsible for current template
    var fn = window[partial].init;
    if(typeof fn === 'function') {
        fn();
    }
}

最後是miniSPA庫自身的初始化。很簡單,就是先獲取404.html片斷並緩存到settings.partialCache.notfound中,以便在路徑變化時使用。當路徑不合法時,就會從緩存中取出404片斷並顯示在局部刷新的 div 中。

miniSPA.ajaxRequest('lib/404.html', 'GET','',function(status, partial){
    settings.partialCache.notfound = partial;
});        //cache 404 page first

好了,核心的代碼就是這麼多。整個 js 文件才區區 155 行,比起那些動輒幾萬行的框架是否是簡單得不能再簡單了?

有了上面的miniSPA.js代碼以及配套的404.htmlhome.html,並把它們打包放在lib目錄下,下面就能夠來看個人應用裏有啥內容。

應用代碼

說到應用那就更簡單了,app.js一共30行,實現了一個GET和一個POST訪問。

首先是getEmoji對象,定義了一個 html 片斷文件路徑和一個初始化方法。初始化方法中分別調用了miniSPA中的ajaxRequest方法(用於獲取 Github API 提供的 emoji 表情數據, JSON格式)和render方法(用來渲染對應的 html 片斷)。

var getEmoji = {};
getEmoji.partial = "getEmoji.html"
getEmoji.init = function(){
    document.getElementById('spinner').style.visibility = 'visible';
    document.getElementById('content').style.visibility = 'hidden';
    miniSPA.ajaxRequest('https://api.github.com/emojis','GET','',function(status, partial){
        getEmoji.emojis = JSON.parse(partial);
        miniSPA.render('getEmoji');         //render related partial page with data returned from the server
        document.getElementById('content').style.visibility = 'visible';
        document.getElementById('spinner').style.visibility = 'hidden';
    });
}

而後是postMD對象,它除了 html 片斷文件路徑和初始化方法(由於初始化不須要獲取外部數據,因此只須要調用render方法就能夠了)以外,重點在於submit方法。submit會把用戶提交的輸入文本和其餘兩個選項打包 POST 給 Github 的 markdown API,並獲取後臺解析標記返回的 html。

var postMD = {};
postMD.partial = "postMD.html";
postMD.init = function(){
    miniSPA.render('postMD');               //render related partial page
}
postMD.submit = function(){
    document.getElementById('spinner').style.visibility = 'visible';
    var mdText = document.getElementById('mdText');
    var md = document.getElementById('md');
    var data = '{"text":"'+mdText.value.replace(/\n/g, '<br>')+'","mode": "gfm","context": "github/gollum"}';
    miniSPA.ajaxRequest('https://api.github.com/markdown', 'POST', data,function(status, page){
        document.getElementById('spinner').style.visibility = 'hidden';
        md.innerHTML = page;                //render markdown partial returned from the server
    });
    mdText.value = '';
}
miniSPA.changeUrl();                        //initialize

這兩個對象對應的 html 片斷以下:

getEmoji.html :

<h2>GET request: Fetch emojis from Github pulic API.</h2>
<p> This is a list of emojis get from https://api.github.com/emojis: </p>
<i id="spinner" class="csspinner duo"></i>
<span id="content">
    <h4>Get <strong class="highlight">{{emojis.length}}</strong> items totally.</h4>
    <hr>
    <ul>
        <li data-repeat="emojis" data-item="data">
            <figure>
                <img src='{{data.value}}' width='100' height='100'>
                <figcaption>{{data.key}}</figcaption>
            </figure>
        </li>
    </ul>
</span>

postMD.html :

<h2> POST request: send MD text and get rendered HTML</h2>
<p> markdown text here (for example:  <strong>Hello world github/linguist#1 **cool**, and #1! </strong>): </p>
<textarea id="mdText" cols="80" rows="6"></textarea>
<button onclick="postMD.submit();">submit</button>
<hr>
<h4>Rendered elements from Github API (https://api.github.com/markdown):</h4>
<i id="spinner" class="csspinner duo"></i>
<div id="md"></div>
相關文章
相關標籤/搜索