1. 程式人生 > >AJAX 單頁面應用的兩種實現思路

AJAX 單頁面應用的兩種實現思路

現在,單頁面應用已經是一種趨勢,這不僅能提升使用者體驗,還能降低伺服器資源的損耗,也是 Web App 與原生 App 一戰的最大資本!

在我們還無法完全享受 fetch API 帶給我們的便利時,我們的單頁面開發的根基仍舊是 AJAX。當我們知道了 AJAX 這個東西后,感覺前路一片光明,但是真正用起來就會發現還有很多問題是我們需要考慮的。

本文我會首先介紹一下我所理解的前後端分離,然後我們介紹 AJAX 實現單頁面應用的兩種思路及其對比。 

前後端分離

做為一個在學校一直和 CMS 打交道,同時還要負責資料庫、伺服器運維的程式設計師,我是深深體會過“上古時代” 的前後端耦合帶來的痛苦的。所幸的是,前端、後端都是自己一個人做,也就不需要去和前端或者後端撕逼了。

上古時代的撕逼

小前:誒,後端,我的頁面有程式碼更新了,我把新程式碼傳給你,你幫我更新一下後端的模板。 
小後:更你妹,你今天都™叫我更新十多次了,還要我更新?沒得商量,100塊一次,再後面的每次修改加價 20! 
小前:咱兩誰跟誰呀,談錢多傷感情呀! 
小後:談感情才傷錢,去去去,自己花點時間學者寫我們後端的程式碼,我可以教你,200 包會! 
小前:滾!都賴設計,今天都改了十多次了。 
小設:啊,這十多次裡有七八次是產品把需求改了,能賴我嗎? 
小汪:改點需求怎麼了,老大說了要讓產品做到極致,那就得改。你們是在用程式碼改變世界,你想讓使用者邊用你的東西邊罵你嗎?還不快利索點改! 
……

而 AJAX 和 Node

 的出現於流行則讓整個 Web 開發步入了 “大前端” 時代。網上一大片關於現在前後端分離與 “大前端” 趨勢的文章,而想真的嚐到這些甜頭,自己實踐就是必須的了,這裡不說太多。

這裡先明確一下,下面要講的內容裡的 AJAX 單頁面應用的架構是這樣(為了方便,這裡明確了技術棧每一項的方向,真實的開發可以自己選擇語言和資料庫):

  1. 後端使用 Java + MySQL 為公司內網的伺服器提供內網資料 API,供內網其他 Web 伺服器調取。
  2. 前端編寫 Node 伺服器,模板渲染,吐出首屏,路由管理,以及提供直接面向瀏覽器的資料 API。
  3. 再靠前一點,使用 Apache 或 Nginx 做負載均衡,轉發請求到內網的其他伺服器上。
  4. 瀏覽器端只有在首屏是接收伺服器返回的整個頁面,之後全部採用 AJAX 來進行資料的更新,利用伺服器端 API 返回的資料進行模板渲染,達到頁面的更新。

這裡有幾個問題可以延伸去思考:

  1. 由伺服器端進行首屏渲染的好處
  2. 這裡面可能存在的資料安全問題?如何避免?

就這樣,大家各司其職,前端利用 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(這裡簡單起見,沒有使用模板引擎進行模板+資料的渲染)

  1. <aclass="ajax-anchor"data-href="abc"href="/abc">#abc</a>
  2. <aclass="ajax-anchor"data-href="def"href="/def">#def</a>
  3. <aclass="ajax-anchor"data-href="hij"href="/hij">#hij</a>
  4. <divid="contariner">
  5. 初始資料!
  6. </div>

然後利用 Mock.js 進行 AJAX 攔截,提供假資料模板:

  1. Mock.mock(/http:\/\/yangfch3\.com(\/\w+)*\?[\w^\w]*/,{
  2. "array|+1":[
  3. "AMD",
  4. "CMD",
  5. "UMD"
  6. ]
  7. });

禁用 AJAX 請求錨點的預設點選事件(用到了 ES6 的特性,在實際使用過程中請考慮相容性)

  1. var ajaxAnchors = document.querySelectorAll('.ajax-anchor');
  2. var contariner = document.querySelector('#contariner');
  3. window.addEventListener('click',function(e){
  4. if([...ajaxAnchors].indexOf(e.target)>-1){
  5. e.preventDefault();
  6. location.hash = e.target.dataset['href'];
  7. }
  8. },false);

使用 hashChange 事件來觸發請求

  1. var callback =function(responseText, status, xhr){
  2. contariner.innerHTML = responseText;
  3. };
  4. window.addEventListener('hashchange',function(e){
  5. var api ='https://api.yangfch3.com?q='+ location.hash.substr(1);
  6. newAjax(api, callback);
  7. },false)

現在我們,點選對應的連結,頁面只進行了區域性的資料更新,並且我們點選瀏覽器後退、前進按鈕可以恢復之前的頁面狀態!

瀏覽器的狀態快取機制(back-forward cache)讓我們能在不做任何處理的情況下回到或前進到某一狀態。

如果需要在使用者每次後退進入或前進進入時頁面做出相應的響應,則可以監聽 pageshow 和 pahehide 事件進行相應的處理!

pageshow 會在當前頁面載入完後、點選瀏覽器後退/前進按鈕重新進入當前頁時觸發(問題:呼叫 history 後退/前進 API 時會不會觸發? -會);pagehide 在瀏覽器解除安裝頁面的時候觸發,而且是在 unload 事件之前觸發

pageshow 與 pagehide 事件物件 persisted 屬性可以用於檢測當前頁是否是由 BFCache 載入。

現在我們總結一下這個方案的優點:

  1. 實現簡單
  2. 符合我們的一般思路,相容性也強
  3. 狀態的回退與前進十分方便

那麼缺點呢?或者說在某些情境下存在的缺點。

直說吧,這套方案在我們的頁面內容需要被搜尋引擎收錄的時候存在缺陷。搜尋引擎收錄爬蟲在到達某個地址後不會執行頁面的 JS,收錄時不會像我們的瀏覽器一樣先發起一個 Ajax 請求生成完整內容再收錄,這就對網站的 SEO(如果需要的話)帶來了不便。

網上有著這個問題的探討,例如以下文章:

基本思路

  1. 後端:準備兩套伺服器程式碼,一套給 AJAX 單頁面應用用的資料伺服器,一套專門給搜尋引擎爬蟲用的 旁路渲染伺服器(提供的是完整的對應頁面的 HTML 程式碼)。

  2. 後端接入層:一般是 Ngnix 會 Apache,根據請求的 UA,判斷請求來自使用者還是引擎爬蟲,分流至上面後端的某臺伺服器上。

  3. 瀏覽器端:給爬蟲用的 <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 事件

有沒有一種方案,能夠:

  1. 實現頁面 url 的變化
  2. 同時不會引起頁面重新整理
  3. 並且無需採用 # 或 #! 結構,頁面的 url 是直觀的、貼近使用者平時習慣的

很幸運,我們能找到這個東西,HTML5 中 history 新 API 加上 popstate 事件能夠完美地做到這一點。

history 物件裡的 pushState() 和 replaceState() 來無更新地改變頁面的 url,使用 popState 事件來實現瀏覽器工具欄前進、後退時的狀態管理。

流程是這樣的:

  1. 頁面第一次載入,可以使用 replaceState() 來初始化 history.state 以及處理一些相關的頁面初始化事務。
  2. 使用者點選連結,觸發點選事件
  3. 點選事件的處理函式中,禁用連結的預設跳轉,使用 pushState() 來更新頁面的 url,同時根據新 url 的對應 API 發起 Ajax 請求獲得資料,更新頁面內容,同時更新 history.state 物件
  4. 使用者點選瀏覽器的前進、後退按鈕,觸發 popState 事件,我們在 popstate 事件的處理中實現前、後狀態的恢復

相關實現程式碼,可以檢視 demo 的原始碼。

這樣,我們就實現了對使用者的友好,接下來就是另外一件事了:解決搜尋引擎的收錄問題(SEO)

Discourse 做出了很好的探索:因為不使用井號結構,每個URL都是一個不同的請求。所以,要求伺服器端對所有這些請求,返回給使用者的不能是 404,同時 返回給搜尋引擎爬蟲的 HTML 也需要包含頁面的 SEO 內容!能否將這兩者做一下結合呢?看下面的解構:

  1. <html>
  2. <body>
  3. <sectionid='container'></section>
  4. <noscript>
  5.       ... ...
  6. </noscript>
  7. </body>
  8. </html>

奧祕就在 noscript 標籤那,對於不能執行 JS 的引擎爬蟲來說,noscript 裡的內容專門為其準備,而對於使用者來說,這個返回的頁面又能正常使用。

當然,對於使用者來說,noscript 顯得冗餘了,所以我們還是可以在伺服器上針對使用者與爬蟲準備兩套方案

總而言之,使用 history API 和 popState 事件的最大原因就是我們想去掉 url 裡的 # 和 #!,讓我們的 url 變得更加親近、自然!而相比思路 1 麻煩了的一點就是我們需要使用 popState 事件來手動恢復前後的狀態,好在這並不是困難的一件事,一般的框架(Vue、React、pjax 等)都有著非常方便地自動管理解決方案。

小結

這兩種思路各有好處,到底採用哪一個你需要做出決斷,決斷的做出需要考慮對使用者的友好、實現的難易程度、是否需要 SEO、伺服器端解決方案……

總之,單頁面應用的前景是光明的,在現階段,Single Page Web App 是唯一能在移動端叫板原生 App 的角色。