什麼是單頁面應用(SPA)?
維基百科上的描述是這樣的:
1 2 3 |
“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框架了》,裡面的分析有一種與我心有慼慼的感覺,看完還給它翻譯成中文了。
不過,他提出的方法是更超前的,例如 imports
和 Polymer
,我曾經試過,印象中只有 Google 的 Chrome Canary 才有支援,而且要先在選項中開啟一些試驗功能,瀏覽器會變得不那麼穩定。而 X-Tag 和 Bosonic 也要依賴於一個小的庫。而我想做的是現在的瀏覽器就已經能支援的功能,用原生程式碼來實現。所以他這篇文章只能讓我堅定方向,但是具體的做法還得靠自己去發現。
後來又看了幾篇比較偏學術的文章,例如這篇 Mixu 寫的《Single page apps in depth》,對我也不太適用。他的模板都需要先編譯為JS物件存放,和 AngularJS 的方法類似,但我覺得在一個小規模應用裡應該有更加優雅的實現方法。
找了好幾天文件,我突然意識到自己浪費了不少時間。所謂理論依據應該是高層次的,解決可行性的問題,剩下的就是自己去想辦法實現了。可行性不是明擺著的嘛,那麼多框架不也是用原生程式碼實現的麼?
想到這兒,我就開始自己嘗試了。前後一共只花了兩三天時間,寫出來一共一百多行JS,就基本解決了問題。其實把程式碼寫完了回顧一下,這些方法都算不上什麼創新,都是標準的東西而已。肯定有別人也這麼做了,只是我不知道而已吧。
可能有讀者看到這兒不耐煩了:“Talk is cheap. Show me the code.”
好吧,下面就是程式碼的描述。
老碼農的實現方法
基礎物件
首先是定義預設的兩個頁面片段(預設頁面和出錯頁面,這兩個頁面是基礎功能,所以放在庫裡)相關程式碼,對每個片段對應的url(例如home
)定義一個同名的物件,裡面存放了對應的 html 片段檔案路徑、初始化方法。
1 2 3 4 5 6 7 8 9 10 11 |
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
,初始化時未出現,在後面的方法中才會用到):
1 2 3 |
var settings = {}; //global parameters settings.partialCache = {}; //cache for partial pages settings.divDemo = document.getElementById("demo"); //div for loading partials, defined in index.html |
主程式
下面就是主程式了,所有的公用方法打包放到一個物件miniSPA
中,這樣可以避免汙染名稱空間:
1 2 |
// Main Object here var miniSPA = {}; |
然後是 changeUrl 方法,對應在index.html
中有如下觸發定義:
1 |
<body onhashchange="miniSPA.changeUrl();"> |
onhashchange
是在location.hash發生改變的時候觸發的事件,能夠通過它獲取區域性 url 的改變。在index.html
中定義瞭如下的連結:
1 2 3 4 5 6 |
<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 片段嵌入的位置。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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 一個字串)就不能快取了。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 |
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 片段。
1 2 3 4 |
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 片段中對此只有一條定義:
1 2 3 4 5 6 7 8 |
<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
定義好的元素,然後根據後臺返回的資料逐個替換元素中的佔位變數。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 |
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
)切分開,逐級迴圈處理,直到最底層獲得相應的資料。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 |
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
方法裡呼叫的,每次訪問路徑的變化都會觸發相應的初始化方法。
1 2 3 4 5 6 |
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 中。
1 2 3 |
miniSPA.ajaxRequest('lib/404.html', 'GET','',function(status, partial){ settings.partialCache.notfound = partial; }); //cache 404 page first |
好了,核心的程式碼就是這麼多。整個 js 檔案才區區 155 行,比起那些動輒幾萬行的框架是不是簡單得不能再簡單了?
有了上面的miniSPA.js
程式碼以及配套的404.html
和home.html
,並把它們打包放在lib
目錄下,下面就可以來看我的應用裡有啥內容。
應用程式碼
說到應用那就更簡單了,app.js
一共30行,實現了一個GET
和一個POST
訪問。
首先是getEmoji
物件,定義了一個 html 片段檔案路徑和一個初始化方法。初始化方法中分別呼叫了miniSPA
中的ajaxRequest
方法(用於獲取 Github API 提供的 emoji 表情資料, JSON格式)和render
方法(用來渲染對應的 html 片段)。
1 2 3 4 5 6 7 8 9 10 11 12 |
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。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
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 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
<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 :
1 2 3 4 5 6 7 8 |
<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> |
演示地址
以上程式碼的線上演示可以在我的 Github 專案頁面看到。
以上演示程式碼已經在Chrome
,Firefox
,Safari
和Opera
較新版本上測試過。IE 9
以上版本估計也可以,不過沒測過。
另外,這些程式碼還有不少值得優化的地方,不過時間有限,主要是為了達到演示目的,所以暫時就不去改它了。
打賞支援我寫出更多好文章,謝謝!
打賞作者
打賞支援我寫出更多好文章,謝謝!
任選一種支付方式