歡迎光臨
每天分享高質量文章

開發無框架單頁面應用 — 老碼農的祖傳秘方

什麼是單頁面應用(SPA)?

維基百科上的描述是這樣的:

“A single-page application (SPA), is a web application or web site

that fits on a single web page with the goal of providing a more

fluid user experience akin to a desktop application.”

也就是說,單頁面應用是僅包含單個網頁的應用,目的是為了提供類似於本地應用的流暢使用者體驗。

需不需要框架?

要實現單頁面應用,現在已經有很多現成的框架了,比如AngularJS、Ember.js、Backbone.js等等。它們都是很全面的開發平臺,為單頁面應用開發提供了必需的頁面模板、路徑解析和處理、後臺服務api訪問、DOM操作等功能。

事實上,現代的web應用開發基本都離不開一個甚至多個框架,開發無框架應用的想法聽起來蠻不靠譜的,對吧?

但是我總覺得現在是時候拋棄框架了。前兩年我都在用AngularJS做開發,可以說已經比較熟悉它了,我的第一個單頁面應用就是在AngularJS的啟發下做出來的。框架曾經是我的摯愛。

但是現在每次看著它們那龐大臃腫的身軀和晦澀的語法,我都會想到諸葛亮的那句名言:“好累,感覺不會再愛了”。還有不同框架下各種工具、外掛難以混用的現狀,讓我不得不經常需要自己寫原生程式碼解決很多問題。時間長了,我自然冒出一個想法:“為啥不乾脆拋棄框架,直接寫原生程式碼呢?畢竟,框架也是原生程式碼寫出來的嘛。”

怎麼實現無框架SPA?

在微博裡表達了這個想法之後,有不少朋友提出了各種意見和建議,我非常感謝。其中還有個小朋友評論道:“我看到了一個從大型機到web的大叔,在摳效能[偷笑]這是職業病嘛。”。看到這條評論,我含笑不語。

這種職業病在我們從90年代過來的老碼農裡還是比較普遍的,當年記憶體64K,磁碟360K,必須精打細算才能過日子。1個byte要掰成2個4位用,連結串列要自己實現,每一K記憶體裡放了啥都門清。後來工作了,在ES/9000上做開發,系統資源也是非常金貴的。

記得有一次我們單位因為某個資料庫應用系統吃記憶體太厲害,找IBM加了128K記憶體,一下子就花了60多萬人民幣,60多萬哪!當時我的心在滴血:“把錢給我一半,我幫你們最佳化一下,省下這些記憶體行不?”。後來有機會瞻仰了一下那個系統的程式碼,我滴個媽呀,無數的join操作,當時罵孃的心都有了,但程式碼是我們部門一位元老寫的,我一個新來的菜鳥惹不起…

總之,那時寫程式碼是藝術,現在有的同學動不動就把一堆東西全load到記憶體裡,反正記憶體不夠了就加,這不是敗家子麼!哼!(老碼農倚老賣老,不能算新聞)

好了,一不小心扯遠了,還是說單頁面應用的事情。

總之,無框架單頁面應用看似可行,但難度有多大?我還是心裡沒底,需要一點理論依據給自己壯膽。所以我就在網上到處尋摸了一番,偶然找到了這篇 Google 工程師 Joe Gregorio 寫的文章《別再用JS框架了》,裡面的分析有一種與我心有慼慼的感覺,看完還給它翻譯成中文了。

不過,他提出的方法是更超前的,例如X-Tag和Polymer,我曾經試過,印象中只有 Google 的 Chrome Canary 才有支援,而且要先在選項中開啟一些試驗功能,瀏覽器會變得不那麼穩定。而我想做的是現在的瀏覽器就已經能支援的功能,所以他這篇文章只能讓我堅定方向,但是具體的做法還得靠自己去發現。

後來又看了幾篇比較偏學術的文章,例如這篇 Mixu 寫的《Single page apps in depth》,對我也不太適用。他的模板都需要先編譯為JS物件存放,和 AngularJS 的方法類似,但我覺得在一個小規模應用裡應該有更加優雅的實現方法。

找了好幾天檔案,我突然意識到自己浪費了不少時間。所謂理論依據應該是高層次的,解決可行性的問題,剩下的就是自己去想辦法實現了。可行性不是明擺著的嘛,那麼多框架不也是用原生程式碼實現的麼?

想到這兒,我就開始自己嘗試了。前後一共只花了兩三天時間,寫出來一共160行JS,就基本解決了問題。其實把程式碼寫完了回顧一下,這些方法都算不上什麼創新,都是標準的東西而已。肯定有別人也這麼做了,只是我不知道而已吧。

可能有讀者看到這兒不耐煩了:“Talk is cheap. Show me the code.”好吧,下麵就是程式碼的描述。

老碼農的實現方法

基礎物件

首先是定義預設的兩個頁面片段(預設頁面和出錯頁面,這兩個頁面是基礎功能,所以放在庫裡)相關程式碼,對每個片段對應的url(例如home)定義一個同名的物件,裡面存放了對應的 html 片段檔案路徑、初始化方法。

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,初始化時未出現,在後面的方法中才會用到):

var settings = {}; //global parameters

settings.partialCache = {}; //cache for partial pages

settings.divDemo = document.getElementById(“demo”); //div for loading partials, defined in index.html

主程式

下麵就是主程式了,所有的公用方法打包放到一個物件miniSPA中,這樣可以避免汙染名稱空間:

// Main Object here

var miniSPA = {};

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

onhashchange是在location.hash發生改變的時候觸發的事件,能夠透過它獲取區域性 url 的改變。在index.html中定義瞭如下的連結:

Demo Contents:

Home (Default)

POST request

GET request

Invalid url

每個 url 都以#號開頭,這樣就能被onhashchange事件抓取到。最後的 div 就是區域性掃清的 html 片段嵌入的位置。

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]找到對應的物件(類似於最上部定義的home和notfound),如物件不存在(無定義的路徑)則轉到404處理,否則透過ajaxRequest方法獲取window[url].partial中定義的 html 片段並載入到區域性掃清的div,並執行window[url].init初始化方法。

ajaxRequest方法主要是和後端的服務進行互動,透過XMLHttpRequest傳送請求(GET或POST),如果獲取的是 html 片段就把它快取到settings.partialCache[url]裡,因為 html 片段是相對固定的,每次請求傳回的內容不會變化。如果是其他請求(比如向 Github 的 markdown 服務 POST 一個字串)就不能快取了。

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 片段。

miniSPA.render = function(url){

settings.rootScope = window[url];

miniSPA.refresh(settings.divDemo, settings.rootScope);

}

獲取後端資料後,如何渲染 html 片段是個比較複雜的問題。這就是 DOM 操作了。總體思想就是從 html 片段的根部入手,遍歷 DOM 樹,逐個替換屬性和文字中的佔位變數(例如

{{emojis.key}}

),匹配和替換是在feedData方法中完成的。

這裡有幾個特殊的地方:

對於 img 元素,src 屬性一開始是未知的,也不能直接把變數定義到它裡面。所以需要在獲取了data-src 屬性後複製到 src 中去。

對於提交按鈕,因為對應的提交方法是定義在片段物件中的(例如postMD.submit),直接寫onclick倒也不是不可以,但是為了優雅起見(看到此處不許呵呵呵…),我還是在下麵的refresh方法中對data-action進行替換。

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

  • {{data.key}}

    等 888 個 emoji 表情來了之後,就要自動把

  • 元素擴充套件到 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.setAttribute(node.attributes[k].name, miniSPA.feedData(node.attributes[k].value, scope)); //replace variables defined in attributes

    }

    if(node.hasAttribute(‘data-src’)){

    node.setAttribute(‘src’,node.getAttribute(‘data-src’)); //replace src attribute

    }

    if(node.hasAttribute(‘data-action’)){

    node.onclick = settings.rootScope[node.getAttribute(‘data-action’)]; //replace src attribute

    }

    var childrenCount = children.length;

    for(var j=0; 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 檔案才區區 160 行,比起那些動輒幾萬行的框架是不是簡單得不能再簡單了?

    有了上面的miniSPA.js程式碼以及配套的404.html和home.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, ”)+'”,”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 :

    GET request: Fetch emojis from Github pulic API.

    This is a list of emojis get from https://api.github.com/emojis:

    Get {{emojis.length}} items totally.


    • {{data.key}}

      postMD.html :

      POST request: send MD text and get rendered HTML

      markdown text here (for example: Hello world github/linguist#1 **cool**, and #1! ):