本文由尚妝前端開發(fā)工程師欲休撰寫
本文發(fā)表于尚妝博客,歡迎訂閱!
移動端開發(fā)在某些場景中有著特殊需求,如為了提高用戶體驗(yàn)和加快響應(yīng)速度,常常在部分工程采用SPA架構(gòu)。傳統(tǒng)的單頁應(yīng)用基于url的hash值進(jìn)行路由,這種實(shí)現(xiàn)不存在兼容性問題,但是缺點(diǎn)也有--針對不支持onhashchange屬性的IE6-7需要設(shè)置定時器不斷檢查hash值改變,性能上并不是很友好。
而如今,在移動端開發(fā)中HTML5規(guī)范給我們提供了一個History接口,使用該接口可以自由操縱歷史記錄。本文并不詳細(xì)介紹History接口,而是探究History接口如何影響瀏覽器歷史堆棧,并且利用這個規(guī)律應(yīng)用到具體的實(shí)際業(yè)務(wù)中,提出兩種歷史記錄保存策略,使路由邏輯更清晰,讓SPA更容易。
History API回顧
HTML5 History API包括2個方法:history.pushState()和history.replaceState(),和1個事件:window.onpopstate。
pushState
history.pushState(stateObject, title, url),包括三個參數(shù)。
第一個參數(shù)用于存儲該url對應(yīng)的狀態(tài)對象,該對象可在onpopstate事件中獲取,也可在history對象中獲取。
第二個參數(shù)是標(biāo)題,目前瀏覽器并未實(shí)現(xiàn)。
第三個參數(shù)則是設(shè)定的url。一般設(shè)置為相對路徑,如果設(shè)置為絕對路徑時需要保證同源。
pushState函數(shù)向?yàn)g覽器的歷史堆棧壓入一個url為設(shè)定值的記錄,并改變歷史堆棧的當(dāng)前指針至棧頂。
在這里筆者使用歷史堆棧和當(dāng)前指針,用以說明瀏覽器對歷史記錄的管理策略。文檔中并沒有使用這樣的詞匯,筆者為了更形象的介紹接口對瀏覽器歷史記錄的影響,使用這樣的描述,如有不當(dāng)之處請及時指出(不過目前以這套模型為基礎(chǔ)的邏輯實(shí)現(xiàn)中并未出現(xiàn)悖論)。
replaceState
該接口與pushState參數(shù)相同,含義也相同。唯一的區(qū)別在于replaceState是替換瀏覽器歷史堆棧的當(dāng)前歷史記錄為設(shè)定的url。需要注意的是,replaceState不會改動瀏覽器歷史堆棧的當(dāng)前指針。
onpopstate
該事件是window的屬性。該事件會在調(diào)用瀏覽器的前進(jìn)、后退以及執(zhí)行history.forward、history.back、和history.go觸發(fā),因?yàn)檫@些操作有一個共性,即修改了歷史堆棧的當(dāng)前指針。在不改變document的前提下,一旦當(dāng)前指針改變則會觸發(fā)onpopstate事件。
History API與業(yè)務(wù)實(shí)踐
最常見的單頁應(yīng)用場景:列表頁、商品詳情頁以及其內(nèi)部的其他鏈接入口如圖片頁、評論頁及其推薦其他商品詳情頁。以上提到的已經(jīng)涉及到了4個單獨(dú)業(yè)務(wù)邏輯頁面(推薦的商品可復(fù)用商品詳情頁邏輯),分別是:列表、詳情、圖片詳情和評論。將這4個頁面合并到一個頁面中,這就是最簡單的SPA。為了用戶的良好體驗(yàn),必須設(shè)計(jì)合理的交互邏輯,最直觀的就是瀏覽器(或手機(jī)app、微信公眾號)的后退和前進(jìn)必須合乎業(yè)務(wù)邏輯特點(diǎn)。因此,這就涉及到了History API的使用,也牽扯到瀏覽器的歷史記錄管理。

上圖為具體的邏輯示意圖。在列表頁,點(diǎn)擊其中一個商品,這里是商品1,進(jìn)入詳情頁。詳情頁包括了該商品的輪播圖、商品的圖片詳情入口、評論入口和推薦的其他商品入口。接下來進(jìn)行如下操作:進(jìn)入圖片詳情頁,后退至詳情頁再進(jìn)入評論頁;后退至商品1詳情頁再由推薦商品入口進(jìn)入商品9詳情頁,同樣在商品9詳情頁進(jìn)入圖片詳情頁和評論頁,再后退至商品9詳情頁;由推薦商品入口進(jìn)入商品34詳情頁,再進(jìn)行類似操作。最后保證在商品34圖片詳情頁或評論頁可以順利后退至最初的商品列表頁。
上文中加粗的“后退”,意味著使用瀏覽器后退按鈕,或者使用手機(jī)自帶的返回,再或者使用頁面上提供的后退按鈕。
這樣一個很細(xì)小的需求,但是一旦真正放手去做卻不是那么容易。僅僅根據(jù)History API的2個函數(shù)和1個事件去盲目的嘗試實(shí)現(xiàn),這屬于盲人摸象,魯棒性不高。不清楚瀏覽器的歷史記錄管理策略,不了解當(dāng)前頁面的歷史記錄數(shù)量,此種情況若要實(shí)現(xiàn)上述場景就有些麻煩。所以在具體動手寫業(yè)務(wù)代碼之前,需要搞懂History的pushState和replaceState具體如何影響歷史記錄棧。
探究瀏覽器歷史記錄策略與History API的關(guān)系
由于瀏覽器并未針對每個頁面的歷史記錄提供具體訪問的接口,因此所有的測試都是黑盒。但是在移動端的中,大都是webkit內(nèi)核,其webcore的具體實(shí)現(xiàn)也都相近,因此該節(jié)得出的結(jié)論完全可以在移動端使用。
盡管無法訪問當(dāng)前頁的歷史記錄棧,但是瀏覽器卻提供了history.length屬性,它標(biāo)明了當(dāng)前歷史記錄棧的個數(shù)。該值會幫助我們更好地分析History API對歷史記錄棧的影響。

上圖為測試實(shí)例。其中白色箭頭意味著點(diǎn)擊該鏈接并執(zhí)行pushState操作(即操作1),黑色箭頭則執(zhí)行瀏覽器后退,紅色的圓點(diǎn)為歷史記錄棧中的當(dāng)前指針,而每個項(xiàng)則為歷史記錄棧,歷史記錄的個數(shù)則為其子項(xiàng)的數(shù)量。
初始在第一個搜索列表頁,執(zhí)行操作1后歷史堆棧數(shù)量增加,當(dāng)前指針上移一位至26788.html;
同理在執(zhí)行3次操作1,歷史堆棧遞增3個,當(dāng)前指針仍在棧頂,即78099.html;
此后進(jìn)行瀏覽器后退,歷史堆棧數(shù)量不變,當(dāng)前指針下移一位至8819.html;
在此處再執(zhí)行操作1,棧頂元素改變,當(dāng)前指針移至棧頂,歷史堆棧數(shù)量不變;
繼續(xù)執(zhí)行操作1,棧頂元素改變,指針移至棧頂,歷史堆棧數(shù)量加一;
執(zhí)行瀏覽器后退,棧頂元素不變,指針下移一位至8128.html,歷史堆棧數(shù)量不變;
執(zhí)行瀏覽器后退,棧頂元素不變,指針下移一位至8819.html,歷史堆棧數(shù)量不變;
執(zhí)行瀏覽器后退,棧頂元素不變,指針下移一位至8128.html,歷史堆棧數(shù)量不變;
執(zhí)行瀏覽器后退,棧頂元素不變,指針下移一位至26788.html,歷史堆棧數(shù)量不變;
執(zhí)行操作1,棧頂元素變?yōu)?721.html,指針上移至棧頂,歷史堆棧數(shù)量變?yōu)?;
執(zhí)行操作1,棧頂元素變?yōu)?387.html,指針上移至棧頂,歷史堆棧數(shù)量變?yōu)?;
執(zhí)行瀏覽器后退,棧頂元素不變,指針下移一位至9721.html,歷史堆棧數(shù)量不變;
執(zhí)行瀏覽器后退,棧頂元素不變,指針下移一位至26788.html,歷史堆棧數(shù)量不變;
執(zhí)行瀏覽器后退,棧頂元素不變,指針下移一位至search.html,歷史堆棧數(shù)量不變;
執(zhí)行操作1,棧頂元素變?yōu)閤xx.html,指針上移至棧頂,歷史堆棧數(shù)量變?yōu)?;
...
至此,實(shí)驗(yàn)結(jié)束。雖然這里僅僅列出了這一個測試用例,但是其實(shí)筆者做了更多更復(fù)雜的測試,并且平臺涉及了pc和移動端的瀏覽器、微信和原生webview,結(jié)果都一樣。這一系列測試說明了很多問題,總結(jié)之一句話則是:
瀏覽器針對每個頁面維護(hù)一個History棧。執(zhí)行pushState函數(shù)可壓入設(shè)定的url至棧頂,同時修改當(dāng)前指針;
當(dāng)執(zhí)行back操作時,history棧大小并不會改變(history.length不變),僅僅移動當(dāng)前指針的位置;
若當(dāng)前指針在history棧的中間位置(非棧頂),此時執(zhí)行pushState會改變history棧的大小。
總結(jié)pushState的規(guī)律,可發(fā)現(xiàn)當(dāng)前指針在history棧頂部時執(zhí)行pushState,會增加history棧大?。蝗鬰urrent指針不在棧頂則會在當(dāng)前指針?biāo)谖恢锰砑禹?xiàng)。執(zhí)行back操作并不修改history棧大小,因此可以通過back和forward在當(dāng)前大小的history棧中自由移動。
掌握這個規(guī)律,就知道如何維護(hù)歷史記錄,就知道在什么狀態(tài)下需要pushState?;氐阶畛醯男枨螅a(chǎn)品經(jīng)理規(guī)定從商品34的評論頁,按后退按鈕可以到達(dá)最初的列表頁,但是他并沒有詳細(xì)規(guī)定如何后退。在這里就會有2中實(shí)現(xiàn)方式:
- 每一次后退,會回到上次的訪問地方。如,在商品34的評論頁,會后退至商品34的詳情頁,再后退則會回到商品9的詳情頁,直至回到列表頁。
- 總共維護(hù)三層歷史記錄,第一層(棧底)為列表頁,第二層為詳情頁,第三層(棧頂)為評論頁或圖片詳情頁。在該種實(shí)現(xiàn)下,由商品34的評論頁第一次后退至商品34的詳情頁,第二次后退至列表頁。
針對第一種,其實(shí)實(shí)現(xiàn)最為簡單,因?yàn)檫@完全是由瀏覽器默認(rèn)控制歷史記錄堆棧,而我們只需在合適的時機(jī)調(diào)用pushState將url插入到堆棧,然后在onpopstate處理函數(shù)中監(jiān)聽對應(yīng)的時間即可:
window.addEventListener('popstate', function (e) {
console.log('popstate')
// 后退(前進(jìn))至商品詳情頁,異步加載數(shù)據(jù)并渲染
if(e.state && e.state.indexOf('/shop/sku/') !== -1){
ajaxDetail(e.state,true);
}else
// 后退(前進(jìn))至評論頁,異步加載數(shù)據(jù)渲染
if(e.state && e.state.indexOf('/shop/comment/commentList.html') !== -1){
ajaxComment(e.state,true);
}else
// 后退(前進(jìn))至圖片詳情頁,異步加載數(shù)據(jù)渲染
if(e.state && e.state.indexOf('/shop/item/pictext/') !== -1){
ajaxPic(e.state,true);
}else
// 后退(前進(jìn))至列表頁,隱藏浮層
if(e.state && e.state.indexOf('/search/') !== -1){
// 隱藏spa的浮層
$('.spa-container').css('zIndex','-1');
}
});
針對第二種實(shí)現(xiàn),則是本文的重點(diǎn)。畢竟,由瀏覽器默認(rèn)維護(hù)的歷史堆棧在某些業(yè)務(wù)場景中并不匹配,因此需要開發(fā)者自己維護(hù)一個歷史記錄棧。在本次實(shí)現(xiàn)中,由于總共涉及4張頁面的顯示,因此我們設(shè)定了3層歷史堆棧,這很好理解。
為了構(gòu)建這樣的歷史記錄棧,在主頁面(即列表頁)中需要額外添加兩條歷史記錄。這是由于默認(rèn)打開列表頁時,當(dāng)前頁面的url已加入歷史記錄棧中,
function push(state){
history.pushState(state, null, location.pathname + location.search);
}
// 'abc'用于標(biāo)示初始列表頁
history.replaceState('abc',null,location.pathname + location.search)
// 壓入兩條歷史記錄
push();
push();
這樣,打開列表頁后就會創(chuàng)建3個歷史記錄,并且這3個歷史記錄的url都為列表頁的url,這與后面的操作并無影響。
在列表頁中打開詳情頁,需要做額外的處理。由于按照我們設(shè)計(jì)的歷史記錄棧,第二層應(yīng)該為詳情頁,而此時在初始化后,歷史記錄棧的當(dāng)前指針已指向棧頂元素,因此需要將當(dāng)前指針下移一位。這里就需要history.back來完成。
$('.item-list').on('click','a',handler);
// 異步加載詳情數(shù)據(jù)
var handler = function(e,isScrollXClick){
var a = this;
ajaxDetail($(a).attr('href'),isScrollXClick);
return false;
};
var isScrollXClick;
/**
* @params: url 請求路徑 isScrollXClick: 是否點(diǎn)擊推薦商品
*
*/
var ajaxDetail = function(url,isScrollXClick){
$.ajax({
url: '/api' + url,
success: function(data){
...
...
if(!isScrollXClick){
console.log('I am back!')
// 在代碼中進(jìn)行back or forward并不會立即出發(fā)popstate事件,以v8引擎為例,在執(zhí)行back之后
// 的大概18us之后會觸發(fā)事件,而此時如果立即通過replaceState修改url則會造成失敗,修改的是
// history stack棧頂?shù)膗rl.
// 這里通過異步執(zhí)行replaceState兼容
history.back();
}
// 異步觸發(fā)
setTimeout(function(){
history.replaceState(url, null, url);
})
// 針對推薦欄的商品,循環(huán)綁定事件,此處用事件代理優(yōu)化
$('#J_PDSlider').on('click','a',function(e){
isScrollXClick = 1;
handler.call(this,e,isScrollXClick);
return false;
});
},
error: function(xhr, type){
alert('Ajax error!')
}
})
};
在此處實(shí)現(xiàn),通過isScrollXClick變量判斷是否點(diǎn)擊的是推薦商品,如果不是則需要執(zhí)行back操作,下移指針。此時指針是指在第二層,但是瀏覽器和第二層歷史記錄的url仍為初始化設(shè)定的url,因此需要修改,在這里異步修改當(dāng)前url。
之所以異步執(zhí)行replaceState,是由于webkit觸發(fā)popState事件決定的。在代碼中執(zhí)行history.back 或者h(yuǎn)istory.forward,并不會立即返回,也不會立即觸發(fā)popState事件。由于沒有閱讀webkit的源碼,因此無從推測執(zhí)行back或者forward后具體需要額外做什么操作,它們之間有著10us級別的間隔,因此此處必須使用setTimeout實(shí)現(xiàn)異步改變url。
在具體開發(fā)過程中,這個問題困擾著筆者好幾天,終于在一次調(diào)試過程中發(fā)現(xiàn)瀏覽器url的變動,才聯(lián)想到可能是由事件觸發(fā)的時間差導(dǎo)致。
對于圖片詳情和評論的邏輯處理,則和上文類似,無需多言。
最后一次后退需要回到列表頁,而在初始化階段我們給列表頁設(shè)置了state為“abc”,特殊的標(biāo)示該路由,因此在popState事件處理中,我們就可以根據(jù)該項(xiàng)回到初始頁:
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();
}
});
如果回到初始頁,隱藏浮層,同時在執(zhí)行2次push操作。根據(jù)上節(jié)發(fā)現(xiàn)的規(guī)律,在初始頁執(zhí)行2次push操作,會在當(dāng)前指針位置重新添加2個歷史記錄,當(dāng)前指針指向棧頂元素,歷史記錄棧的數(shù)量不變,仍為3。這樣就完成了簡單的由開發(fā)者自定義維護(hù)歷史堆棧的spa系統(tǒng)。
回顧
之所以會寫這篇文章完全是出于偶然,由于實(shí)際項(xiàng)目的各種需求我們不應(yīng)該僅僅將眼光停留在使用API的層面上。另外,在開發(fā)過程中遇到難以解決的問題,需要提出各種合理的設(shè)想并用詳實(shí)的實(shí)驗(yàn)證明,在得到相對應(yīng)的結(jié)論后需要利用該結(jié)論去例證其他場景,這樣才能確保解決方案的可靠性。目前網(wǎng)絡(luò)上或者書籍中并未提供任何手動維護(hù)歷史記錄堆棧的方法,也未明確指出History API與瀏覽器歷史記錄之間如何影響,因此本文對于旨在利用History API實(shí)現(xiàn)spa的開發(fā)者而言還是有些指導(dǎo)意義的。