移動端開發在某些場景中有着特殊需求,如爲了提升用戶體驗和加快響應速度,經常在部分工程採用SPA架構。傳統的單頁應用基於url的hash值進行路由,這種實現不存在兼容性問題,可是缺點也有--針對不支持onhashchange屬性的IE6-7須要設置定時器不斷檢查hash值改變,性能上並非很友好。css
而現在,在移動端開發中HTML5規範給咱們提供了一個History接口,使用該接口能夠自由操縱歷史記錄。本文並不詳細介紹History接口,而是探究History接口如何影響瀏覽器歷史堆棧,而且利用這個規律應用到具體的實際業務中,提出兩種歷史記錄保存策略,使路由邏輯更清晰,讓SPA更容易。html
HTML5 History API包括2個方法:history.pushState()和history.replaceState(),和1個事件:window.onpopstate。web
history.pushState(stateObject, title, url),包括三個參數。ajax
第一個參數用於存儲該url對應的狀態對象,該對象可在onpopstate事件中獲取,也可在history對象中獲取。api
第二個參數是標題,目前瀏覽器並未實現。瀏覽器
第三個參數則是設定的url。通常設置爲相對路徑,若是設置爲絕對路徑時須要保證同源。微信
pushState函數向瀏覽器的歷史堆棧壓入一個url爲設定值的記錄,並改變歷史堆棧的當前指針至棧頂。網絡
在這裏筆者使用歷史堆棧和當前指針,用以說明瀏覽器對歷史記錄的管理策略。文檔中並無使用這樣的詞彙,筆者爲了更形象的介紹接口對瀏覽器歷史記錄的影響,使用這樣的描述,若有不當之處請及時指出(不過目前以這套模型爲基礎的邏輯實現中並未出現悖論)。架構
該接口與pushState參數相同,含義也相同。惟一的區別在於replaceState是替換瀏覽器歷史堆棧的當前歷史記錄爲設定的url。須要注意的是,replaceState不會改動瀏覽器歷史堆棧的當前指針。app
該事件是window的屬性。該事件會在調用瀏覽器的前進、後退以及執行history.forward、history.back、和history.go觸發,由於這些操做有一個共性,即修改了歷史堆棧的當前指針。在不改變document的前提下,一旦當前指針改變則會觸發onpopstate事件。
最多見的單頁應用場景:列表頁、商品詳情頁以及其內部的其餘連接入口如圖片頁、評論頁及其推薦其餘商品詳情頁。以上提到的已經涉及到了4個單獨業務邏輯頁面(推薦的商品可複用商品詳情頁邏輯),分別是:列表、詳情、圖片詳情和評論。將這4個頁面合併到一個頁面中,這就是最簡單的SPA。爲了用戶的良好體驗,必須設計合理的交互邏輯,最直觀的就是瀏覽器(或手機app、微信公衆號)的後退和前進必須合乎業務邏輯特色。所以,這就涉及到了History API的使用,也牽扯到瀏覽器的歷史記錄管理。
上圖爲具體的邏輯示意圖。在列表頁,點擊其中一個商品,這裏是商品1,進入詳情頁。詳情頁包括了該商品的輪播圖、商品的圖片詳情入口、評論入口和推薦的其餘商品入口。接下來進行以下操做:進入圖片詳情頁,後退至詳情頁再進入評論頁;後退至商品1詳情頁再由推薦商品入口進入商品9詳情頁,一樣在商品9詳情頁進入圖片詳情頁和評論頁,再後退至商品9詳情頁;由推薦商品入口進入商品34詳情頁,再進行相似操做。最後保證在商品34圖片詳情頁或評論頁能夠順利後退至最初的商品列表頁。
上文中加粗的「後退」,意味着使用瀏覽器後退按鈕,或者使用手機自帶的返回,再或者使用頁面上提供的後退按鈕。
這樣一個很細小的需求,可是一旦真正放手去作卻不是那麼容易。僅僅根據History API的2個函數和1個事件去盲目的嘗試實現,這屬於盲人摸象,魯棒性不高。不清楚瀏覽器的歷史記錄管理策略,不瞭解當前頁面的歷史記錄數量,此種狀況若要實現上述場景就有些麻煩。因此在具體動手寫業務代碼以前,須要搞懂History的pushState和replaceState具體如何影響歷史記錄棧。
因爲瀏覽器並未針對每一個頁面的歷史記錄提供具體訪問的接口,所以全部的測試都是黑盒。可是在移動端的中,大都是webkit內核,其webcore的具體實現也都相近,所以該節得出的結論徹底能夠在移動端使用。
儘管沒法訪問當前頁的歷史記錄棧,可是瀏覽器卻提供了history.length屬性,它標明瞭當前歷史記錄棧的個數。該值會幫助咱們更好地分析History API對歷史記錄棧的影響。
上圖爲測試實例。其中白色箭頭意味着點擊該連接並執行pushState操做(即操做1),黑色箭頭則執行瀏覽器後退,紅色的圓點爲歷史記錄棧中的當前指針,而每一個項則爲歷史記錄棧,歷史記錄的個數則爲其子項的數量。
初始在第一個搜索列表頁,執行操做1後歷史堆棧數量增長,當前指針上移一位至26788.html;
同理在執行3次操做1,歷史堆棧遞增3個,當前指針仍在棧頂,即78099.html;
此後進行瀏覽器後退,歷史堆棧數量不變,當前指針下移一位至8819.html;
在此處再執行操做1,棧頂元素改變,當前指針移至棧頂,歷史堆棧數量不變;
繼續執行操做1,棧頂元素改變,指針移至棧頂,歷史堆棧數量加一;
執行瀏覽器後退,棧頂元素不變,指針下移一位至8128.html,歷史堆棧數量不變;
執行瀏覽器後退,棧頂元素不變,指針下移一位至8819.html,歷史堆棧數量不變;
執行瀏覽器後退,棧頂元素不變,指針下移一位至8128.html,歷史堆棧數量不變;
執行瀏覽器後退,棧頂元素不變,指針下移一位至26788.html,歷史堆棧數量不變;
執行操做1,棧頂元素變爲9721.html,指針上移至棧頂,歷史堆棧數量變爲3;
執行操做1,棧頂元素變爲8387.html,指針上移至棧頂,歷史堆棧數量變爲4;
執行瀏覽器後退,棧頂元素不變,指針下移一位至9721.html,歷史堆棧數量不變;
執行瀏覽器後退,棧頂元素不變,指針下移一位至26788.html,歷史堆棧數量不變;
執行瀏覽器後退,棧頂元素不變,指針下移一位至search.html,歷史堆棧數量不變;
執行操做1,棧頂元素變爲xxx.html,指針上移至棧頂,歷史堆棧數量變爲2;
...
至此,實驗結束。雖然這裏僅僅列出了這一個測試用例,可是其實筆者作了更多更復雜的測試,而且平臺涉及了pc和移動端的瀏覽器、微信和原生webview,結果都同樣。這一系列測試說明了不少問題,總結之一句話則是:
**瀏覽器針對每一個頁面維護一個History棧。執行pushState函數可壓入設定的url至棧頂,同時修改當前指針;
當執行back操做時,history棧大小並不會改變(history.length不變),僅僅移動當前指針的位置;
若當前指針在history棧的中間位置(非棧頂),此時執行pushState會改變history棧的大小。
總結pushState的規律,可發現當前指針在history棧頂部時執行pushState,會增長history棧大小;若current指針不在棧頂則會在當前指針所在位置添加項。執行back操做並不修改history棧大小,所以能夠經過back和forward在當前大小的history棧中自由移動。**
掌握這個規律,就知道如何維護歷史記錄,就知道在什麼狀態下須要pushState。回到最初的需求,產品經理規定從商品34的評論頁,按後退按鈕能夠到達最初的列表頁,可是他並無詳細規定如何後退。在這裏就會有2中實現方式:
每一次後退,會回到上次的訪問地方。如,在商品34的評論頁,會後退至商品34的詳情頁,再後退則會回到商品9的詳情頁,直至回到列表頁。
總共維護三層歷史記錄,第一層(棧底)爲列表頁,第二層爲詳情頁,第三層(棧頂)爲評論頁或圖片詳情頁。在該種實現下,由商品34的評論頁第一次後退至商品34的詳情頁,第二次後退至列表頁。
針對第一種,其實實現最爲簡單,由於這徹底是由瀏覽器默認控制歷史記錄堆棧,而咱們只需在合適的時機調用pushState將url插入到堆棧,而後在onpopstate處理函數中監聽對應的時間便可:
window.addEventListener('popstate', function (e) { console.log('popstate') // 後退(前進)至商品詳情頁,異步加載數據並渲染 if(e.state && e.state.indexOf('/shop/sku/') !== -1){ ajaxDetail(e.state,true); }else // 後退(前進)至評論頁,異步加載數據渲染 if(e.state && e.state.indexOf('/shop/comment/commentList.html') !== -1){ ajaxComment(e.state,true); }else // 後退(前進)至圖片詳情頁,異步加載數據渲染 if(e.state && e.state.indexOf('/shop/item/pictext/') !== -1){ ajaxPic(e.state,true); }else // 後退(前進)至列表頁,隱藏浮層 if(e.state && e.state.indexOf('/search/') !== -1){ // 隱藏spa的浮層 $('.spa-container').css('zIndex','-1'); } });
針對第二種實現,則是本文的重點。畢竟,由瀏覽器默認維護的歷史堆棧在某些業務場景中並不匹配,所以須要開發者本身維護一個歷史記錄棧。在本次實現中,因爲總共涉及4張頁面的顯示,所以咱們設定了3層歷史堆棧,這很好理解。
爲了構建這樣的歷史記錄棧,在主頁面(即列表頁)中須要額外添加兩條歷史記錄。這是因爲默認打開列表頁時,當前頁面的url已加入歷史記錄棧中,
function push(state){ history.pushState(state, null, location.pathname + location.search); } // 'abc'用於標示初始列表頁 history.replaceState('abc',null,location.pathname + location.search) // 壓入兩條歷史記錄 push(); push();
這樣,打開列表頁後就會建立3個歷史記錄,而且這3個歷史記錄的url都爲列表頁的url,這與後面的操做並沒有影響。
在列表頁中打開詳情頁,須要作額外的處理。因爲按照咱們設計的歷史記錄棧,第二層應該爲詳情頁,而此時在初始化後,歷史記錄棧的當前指針已指向棧頂元素,所以須要將當前指針下移一位。這裏就須要history.back來完成。
$('.item-list').on('click','a',handler); // 異步加載詳情數據 var handler = function(e,isScrollXClick){ var a = this; ajaxDetail($(a).attr('href'),isScrollXClick); return false; }; var isScrollXClick; /** * @params: url 請求路徑 isScrollXClick: 是否點擊推薦商品 * */ var ajaxDetail = function(url,isScrollXClick){ $.ajax({ url: '/api' + url, success: function(data){ ... ... if(!isScrollXClick){ console.log('I am back!') // 在代碼中進行back or forward並不會當即出發popstate事件,以v8引擎爲例,在執行back以後 // 的大概18us以後會觸發事件,而此時若是當即經過replaceState修改url則會形成失敗,修改的是 // history stack棧頂的url. // 這裏經過異步執行replaceState兼容 history.back(); } // 異步觸發 setTimeout(function(){ history.replaceState(url, null, url); }) // 針對推薦欄的商品,循環綁定事件,此處用事件代理優化 $('#J_PDSlider').on('click','a',function(e){ isScrollXClick = 1; handler.call(this,e,isScrollXClick); return false; }); }, error: function(xhr, type){ alert('Ajax error!') } }) };
在此處實現,經過isScrollXClick變量判斷是否點擊的是推薦商品,若是不是則須要執行back操做,下移指針。此時指針是指在第二層,可是瀏覽器和第二層歷史記錄的url仍爲初始化設定的url,所以須要修改,在這裏異步修改當前url。
之因此異步執行replaceState,是因爲webkit觸發popState事件決定的。在代碼中執行history.back 或者history.forward,並不會當即返回,也不會當即觸發popState事件。因爲沒有閱讀webkit的源碼,所以無從推測執行back或者forward後具體須要額外作什麼操做,它們之間有着10us級別的間隔,所以此處必須使用setTimeout實現異步改變url。
在具體開發過程當中,這個問題困擾着筆者好幾天,終於在一次調試過程當中發現瀏覽器url的變更,才聯想到多是由事件觸發的時間差致使。
對於圖片詳情和評論的邏輯處理,則和上文相似,無需多言。
最後一次後退須要回到列表頁,而在初始化階段咱們給列表頁設置了state爲「abc」,特殊的標示該路由,所以在popState事件處理中,咱們就能夠根據該項回到初始頁:
window.addEventListener('popstate', function (e) { if(e.state && e.state.indexOf('/shop/sku/') !== -1){ ajaxDetail(e.state,true); }else if(e.state && e.state.indexOf('abc') !== -1){ // 隱藏spa的浮層 $('.spa-container').css('zIndex','-1'); push(); push(); } });
若是回到初始頁,隱藏浮層,同時在執行2次push操做。根據上節發現的規律,在初始頁執行2次push操做,會在當前指針位置從新添加2個歷史記錄,當前指針指向棧頂元素,歷史記錄棧的數量不變,仍爲3。這樣就完成了簡單的由開發者自定義維護歷史堆棧的spa系統。
之因此會寫這篇文章徹底是出於偶然,因爲實際項目的各類需求咱們不該該僅僅將眼光停留在使用API的層面上。另外,在開發過程當中遇到難以解決的問題,須要提出各類合理的設想並用詳實的實驗證實,在獲得相對應的結論後須要利用該結論去例證其餘場景,這樣才能確保解決方案的可靠性。目前網絡上或者書籍中並未提供任何手動維護歷史記錄堆棧的方法,也未明確指出History API與瀏覽器歷史記錄之間如何影響,所以本文對於旨在利用History API實現spa的開發者而言仍是有些指導意義的。