AJAX 單頁面應用的兩種實現思路
現在,單頁面應用已經是一種趨勢,這不僅能提升使用者體驗,還能降低伺服器資源的損耗,也是 Web App 與原生 App 一戰的最大資本!
在我們還無法完全享受 fetch
API 帶給我們的便利時,我們的單頁面開發的根基仍舊是 AJAX。當我們知道了 AJAX 這個東西后,感覺前路一片光明,但是真正用起來就會發現還有很多問題是我們需要考慮的。
本文我會首先介紹一下我所理解的前後端分離,然後我們介紹 AJAX 實現單頁面應用的兩種思路及其對比。
前後端分離
做為一個在學校一直和 CMS 打交道,同時還要負責資料庫、伺服器運維的程式設計師,我是深深體會過“上古時代” 的前後端耦合帶來的痛苦的。所幸的是,前端、後端都是自己一個人做,也就不需要去和前端或者後端撕逼了。
上古時代的撕逼
小前:誒,後端,我的頁面有程式碼更新了,我把新程式碼傳給你,你幫我更新一下後端的模板。
小後:更你妹,你今天都™叫我更新十多次了,還要我更新?沒得商量,100塊一次,再後面的每次修改加價 20!
小前:咱兩誰跟誰呀,談錢多傷感情呀!
小後:談感情才傷錢,去去去,自己花點時間學者寫我們後端的程式碼,我可以教你,200 包會!
小前:滾!都賴設計,今天都改了十多次了。
小設:啊,這十多次裡有七八次是產品把需求改了,能賴我嗎?
小汪:改點需求怎麼了,老大說了要讓產品做到極致,那就得改。你們是在用程式碼改變世界,你想讓使用者邊用你的東西邊罵你嗎?還不快利索點改!
……
而 AJAX
和 Node
這裡先明確一下,下面要講的內容裡的 AJAX 單頁面應用的架構是這樣(為了方便,這裡明確了技術棧每一項的方向,真實的開發可以自己選擇語言和資料庫):
- 後端使用 Java + MySQL 為公司內網的伺服器提供內網資料 API,供內網其他 Web 伺服器調取。
- 前端編寫 Node 伺服器,模板渲染,吐出首屏,路由管理,以及提供直接面向瀏覽器的資料 API。
- 再靠前一點,使用 Apache 或 Nginx 做負載均衡,轉發請求到內網的其他伺服器上。
- 瀏覽器端只有在首屏是接收伺服器返回的整個頁面,之後全部採用 AJAX 來進行資料的更新,利用伺服器端 API 返回的資料進行模板渲染,達到頁面的更新。
這裡有幾個問題可以延伸去思考:
- 由伺服器端進行首屏渲染的好處
- 這裡面可能存在的資料安全問題?如何避免?
就這樣,大家各司其職,前端利用 JavaScript + Node 入侵了伺服器端,後端的工作變得更加專一,前端的控制力變得更加強。雖然前端的任務似乎加重了,但是整個開發的效率則是大大提升,前後端唯一需要耦合的就是資料 API 的標準規範!
今天我們主要目標是前端使用 AJAX 進行單頁面開發這一環。說到 AJAX 就脫離不了資料 API,網上有著許多免費、公開的的 API 服務提供,當然也可以換一種思路:攔截 AJAX 請求,返回假資料。很幸運,後面那種思路已經有 “輪子” 幫我們做了,這裡選擇 Mock.js
進行
AJAX 請求的攔截與特定模板假資料的生成。
思路1:url hash + hashChange 事件
頁面不重新整理而帶來 url 變化我們最先想到的肯定就是 url hash 了。我們使用 location.hash
可以輕鬆的訪問與變更 hash 值。
至於 hash 值變動帶來頁面可能的上下閃動(頁面上可能有對應 hash 值 id 的元素),我們只需要禁用錨點點選的預設事件就行。
hash 值的變動同時還會觸發全域性物件上的 hashChange
事件,在這個事件裡我們就能做很多事情了。我們在這個事件階段需要做的就是依照 hash 值得變動,解析
url 之後,向對應的伺服器端 API 發起 AJAX 請求獲得資料更新頁面。
首先我們來封裝一下 AJAX 請求生成器(點選連結後面連結檢視原始碼):ajax.js
準備好首屏頁面 index.html
(這裡簡單起見,沒有使用模板引擎進行模板+資料的渲染)
<aclass="ajax-anchor"data-href="abc"href="/abc">#abc</a>
<aclass="ajax-anchor"data-href="def"href="/def">#def</a>
<aclass="ajax-anchor"data-href="hij"href="/hij">#hij</a>
<divid="contariner">
初始資料!
</div>
然後利用 Mock.js 進行 AJAX 攔截,提供假資料模板:
Mock.mock(/http:\/\/yangfch3\.com(\/\w+)*\?[\w^\w]*/,{
"array|+1":[
"AMD",
"CMD",
"UMD"
]
});
禁用 AJAX 請求錨點的預設點選事件(用到了 ES6 的特性,在實際使用過程中請考慮相容性)
var ajaxAnchors = document.querySelectorAll('.ajax-anchor');
var contariner = document.querySelector('#contariner');
window.addEventListener('click',function(e){
if([...ajaxAnchors].indexOf(e.target)>-1){
e.preventDefault();
location.hash = e.target.dataset['href'];
}
},false);
使用 hashChange 事件來觸發請求
var callback =function(responseText, status, xhr){
contariner.innerHTML = responseText;
};
window.addEventListener('hashchange',function(e){
var api ='https://api.yangfch3.com?q='+ location.hash.substr(1);
newAjax(api, callback);
},false)
現在我們,點選對應的連結,頁面只進行了區域性的資料更新,並且我們點選瀏覽器後退、前進按鈕可以恢復之前的頁面狀態!
瀏覽器的狀態快取機制(back-forward cache)讓我們能在不做任何處理的情況下回到或前進到某一狀態。
如果需要在使用者每次後退進入或前進進入時頁面做出相應的響應,則可以監聽
pageshow
和pahehide
事件進行相應的處理!
pageshow
會在當前頁面載入完後、點選瀏覽器後退/前進按鈕重新進入當前頁時觸發(問題:呼叫 history 後退/前進 API 時會不會觸發? -會);pagehide
在瀏覽器解除安裝頁面的時候觸發,而且是在 unload 事件之前觸發
pageshow
與pagehide
事件物件persisted
屬性可以用於檢測當前頁是否是由BFCache
載入。
現在我們總結一下這個方案的優點:
- 實現簡單
- 符合我們的一般思路,相容性也強
- 狀態的回退與前進十分方便
那麼缺點呢?或者說在某些情境下存在的缺點。
直說吧,這套方案在我們的頁面內容需要被搜尋引擎收錄的時候存在缺陷。搜尋引擎收錄爬蟲在到達某個地址後不會執行頁面的 JS,收錄時不會像我們的瀏覽器一樣先發起一個 Ajax 請求生成完整內容再收錄,這就對網站的 SEO(如果需要的話)帶來了不便。
網上有著這個問題的探討,例如以下文章:
基本思路:
後端:準備兩套伺服器程式碼,一套給 AJAX 單頁面應用用的資料伺服器,一套專門給搜尋引擎爬蟲用的 旁路渲染伺服器(提供的是完整的對應頁面的 HTML 程式碼)。
後端接入層:一般是
Ngnix
會Apache
,根據請求的 UA,判斷請求來自使用者還是引擎爬蟲,分流至上面後端的某臺伺服器上。瀏覽器端:給爬蟲用的
<a>
的href
使用跳轉型連結,這樣爬蟲遇到這個連結時才會繼續跳轉、深爬,爬蟲遇到#xxx
這樣的href
是不理會的;我們的 JavaScript 程式碼則禁用這些跳轉連結的預設行為,代之為變更hash
值,使頁面無需重新整理。說通俗點就是:給爬蟲看的是一套,對使用者做的是另一套!
Google 當然也是考慮到了這一點的,所以提出了 #!
方案。
搜尋引擎爬蟲雖然不會去對你的
#xxx
做出例會,但是能夠智慧地識別#!xxx
這樣的href
,轉化為請求?_escaped_fragment=xxx
,你需要做的就是在伺服器上準備好?_escaped_fragment=xxx
對應的 HTML 程式碼,就能被搜尋引擎收錄了。
#
#!
結構對於程式設計師來說還是比較容易接受的,但是對於需要直觀的連結用於記憶的站點來說就不那麼友好了。
有些站點是
abc.com/#/xxx/yyy
,有些是abc.com/#xxx/yyy
,還有abc.com/#!/xxx/yyy
、abc.com/#!xxx/yyy
這樣的,同時輸入網址時,還需要 shift + 陣列組合輸入,不方便!
當然,如果你的單頁面應用是無需 SEO 的話(例如後臺管理介面),那麼事情就相對簡單一些了!
下面我們開始介紹 Ajax 單頁面應用的第二種實現思路,開始逃離 #
和 #!
。
思路2:histroy API + popstate 事件
有沒有一種方案,能夠:
- 實現頁面 url 的變化
- 同時不會引起頁面重新整理
- 並且無需採用
#
或#!
結構,頁面的 url 是直觀的、貼近使用者平時習慣的
很幸運,我們能找到這個東西,HTML5 中 history 新 API 加上 popstate 事件能夠完美地做到這一點。
history 物件裡的 pushState()
和 replaceState()
來無更新地改變頁面的
url,使用 popState
事件來實現瀏覽器工具欄前進、後退時的狀態管理。
流程是這樣的:
- 頁面第一次載入,可以使用
replaceState()
來初始化history.state
以及處理一些相關的頁面初始化事務。 - 使用者點選連結,觸發點選事件
- 點選事件的處理函式中,禁用連結的預設跳轉,使用
pushState()
來更新頁面的 url,同時根據新 url 的對應 API 發起 Ajax 請求獲得資料,更新頁面內容,同時更新history.state
物件 - 使用者點選瀏覽器的前進、後退按鈕,觸發
popState
事件,我們在popstate
事件的處理中實現前、後狀態的恢復
相關實現程式碼,可以檢視 demo 的原始碼。
這樣,我們就實現了對使用者的友好,接下來就是另外一件事了:解決搜尋引擎的收錄問題(SEO)。
Discourse 做出了很好的探索:因為不使用井號結構,每個URL都是一個不同的請求。所以,要求伺服器端對所有這些請求,返回給使用者的不能是 404,同時 返回給搜尋引擎爬蟲的 HTML 也需要包含頁面的 SEO 內容!能否將這兩者做一下結合呢?看下面的解構:
<html>
<body>
<sectionid='container'></section>
<noscript>
... ...
</noscript>
</body>
</html>
奧祕就在 noscript
標籤那,對於不能執行 JS 的引擎爬蟲來說,noscript
裡的內容專門為其準備,而對於使用者來說,這個返回的頁面又能正常使用。
當然,對於使用者來說,noscript
顯得冗餘了,所以我們還是可以在伺服器上針對使用者與爬蟲準備兩套方案!
總而言之,使用 history API 和 popState 事件的最大原因就是我們想去掉 url 裡的 #
和 #!
,讓我們的
url 變得更加親近、自然!而相比思路 1 麻煩了的一點就是我們需要使用 popState
事件來手動恢復前後的狀態,好在這並不是困難的一件事,一般的框架(Vue、React、pjax 等)都有著非常方便地自動管理解決方案。
小結
這兩種思路各有好處,到底採用哪一個你需要做出決斷,決斷的做出需要考慮對使用者的友好、實現的難易程度、是否需要 SEO、伺服器端解決方案……
總之,單頁面應用的前景是光明的,在現階段,Single Page Web App 是唯一能在移動端叫板原生 App 的角色。