「前端」History API與瀏覽器歷史堆棧管理

本文由尚妝前端開(kāi)發(fā)工程師欲休撰寫(xiě)

本文發(fā)表于尚妝博客,歡迎訂閱!

移動(dòng)端開(kāi)發(fā)在某些場(chǎng)景中有著特殊需求,如為了提高用戶體驗(yàn)和加快響應(yīng)速度,常常在部分工程采用SPA架構(gòu)。傳統(tǒng)的單頁(yè)應(yīng)用基于url的hash值進(jìn)行路由,這種實(shí)現(xiàn)不存在兼容性問(wèn)題,但是缺點(diǎn)也有--針對(duì)不支持onhashchange屬性的IE6-7需要設(shè)置定時(shí)器不斷檢查hash值改變,性能上并不是很友好。

而如今,在移動(dòng)端開(kāi)發(fā)中HTML5規(guī)范給我們提供了一個(gè)History接口,使用該接口可以自由操縱歷史記錄。本文并不詳細(xì)介紹History接口,而是探究History接口如何影響瀏覽器歷史堆棧,并且利用這個(gè)規(guī)律應(yīng)用到具體的實(shí)際業(yè)務(wù)中,提出兩種歷史記錄保存策略,使路由邏輯更清晰,讓SPA更容易。

History API回顧

HTML5 History API包括2個(gè)方法:history.pushState()和history.replaceState(),和1個(gè)事件:window.onpopstate。

pushState

history.pushState(stateObject, title, url),包括三個(gè)參數(shù)。

第一個(gè)參數(shù)用于存儲(chǔ)該url對(duì)應(yīng)的狀態(tài)對(duì)象,該對(duì)象可在onpopstate事件中獲取,也可在history對(duì)象中獲取。

第二個(gè)參數(shù)是標(biāo)題,目前瀏覽器并未實(shí)現(xiàn)。

第三個(gè)參數(shù)則是設(shè)定的url。一般設(shè)置為相對(duì)路徑,如果設(shè)置為絕對(duì)路徑時(shí)需要保證同源。

pushState函數(shù)向?yàn)g覽器的歷史堆棧壓入一個(gè)url為設(shè)定值的記錄,并改變歷史堆棧的當(dāng)前指針至棧頂。

在這里筆者使用歷史堆棧和當(dāng)前指針,用以說(shuō)明瀏覽器對(duì)歷史記錄的管理策略。文檔中并沒(méi)有使用這樣的詞匯,筆者為了更形象的介紹接口對(duì)瀏覽器歷史記錄的影響,使用這樣的描述,如有不當(dāng)之處請(qǐng)及時(shí)指出(不過(guò)目前以這套模型為基礎(chǔ)的邏輯實(shí)現(xiàn)中并未出現(xiàn)悖論)。

replaceState

該接口與pushState參數(shù)相同,含義也相同。唯一的區(qū)別在于replaceState是替換瀏覽器歷史堆棧的當(dāng)前歷史記錄為設(shè)定的url。需要注意的是,replaceState不會(huì)改動(dòng)瀏覽器歷史堆棧的當(dāng)前指針。

onpopstate

該事件是window的屬性。該事件會(huì)在調(diào)用瀏覽器的前進(jìn)、后退以及執(zhí)行history.forward、history.back、和history.go觸發(fā),因?yàn)檫@些操作有一個(gè)共性,即修改了歷史堆棧的當(dāng)前指針。在不改變document的前提下,一旦當(dāng)前指針改變則會(huì)觸發(fā)onpopstate事件。

History API與業(yè)務(wù)實(shí)踐

最常見(jiàn)的單頁(yè)應(yīng)用場(chǎng)景:列表頁(yè)、商品詳情頁(yè)以及其內(nèi)部的其他鏈接入口如圖片頁(yè)、評(píng)論頁(yè)及其推薦其他商品詳情頁(yè)。以上提到的已經(jīng)涉及到了4個(gè)單獨(dú)業(yè)務(wù)邏輯頁(yè)面(推薦的商品可復(fù)用商品詳情頁(yè)邏輯),分別是:列表、詳情、圖片詳情和評(píng)論。將這4個(gè)頁(yè)面合并到一個(gè)頁(yè)面中,這就是最簡(jiǎn)單的SPA。為了用戶的良好體驗(yàn),必須設(shè)計(jì)合理的交互邏輯,最直觀的就是瀏覽器(或手機(jī)app、微信公眾號(hào))的后退前進(jìn)必須合乎業(yè)務(wù)邏輯特點(diǎn)。因此,這就涉及到了History API的使用,也牽扯到瀏覽器的歷史記錄管理。

業(yè)務(wù)邏輯實(shí)例

上圖為具體的邏輯示意圖。在列表頁(yè),點(diǎn)擊其中一個(gè)商品,這里是商品1,進(jìn)入詳情頁(yè)。詳情頁(yè)包括了該商品的輪播圖、商品的圖片詳情入口、評(píng)論入口和推薦的其他商品入口。接下來(lái)進(jìn)行如下操作:進(jìn)入圖片詳情頁(yè),后退至詳情頁(yè)再進(jìn)入評(píng)論頁(yè);后退至商品1詳情頁(yè)再由推薦商品入口進(jìn)入商品9詳情頁(yè),同樣在商品9詳情頁(yè)進(jìn)入圖片詳情頁(yè)和評(píng)論頁(yè),再后退至商品9詳情頁(yè);由推薦商品入口進(jìn)入商品34詳情頁(yè),再進(jìn)行類似操作。最后保證在商品34圖片詳情頁(yè)或評(píng)論頁(yè)可以順利后退至最初的商品列表頁(yè)。

上文中加粗的“后退”,意味著使用瀏覽器后退按鈕,或者使用手機(jī)自帶的返回,再或者使用頁(yè)面上提供的后退按鈕。

這樣一個(gè)很細(xì)小的需求,但是一旦真正放手去做卻不是那么容易。僅僅根據(jù)History API的2個(gè)函數(shù)和1個(gè)事件去盲目的嘗試實(shí)現(xiàn),這屬于盲人摸象,魯棒性不高。不清楚瀏覽器的歷史記錄管理策略,不了解當(dāng)前頁(yè)面的歷史記錄數(shù)量,此種情況若要實(shí)現(xiàn)上述場(chǎng)景就有些麻煩。所以在具體動(dòng)手寫(xiě)業(yè)務(wù)代碼之前,需要搞懂History的pushState和replaceState具體如何影響歷史記錄棧。

探究瀏覽器歷史記錄策略與History API的關(guān)系

由于瀏覽器并未針對(duì)每個(gè)頁(yè)面的歷史記錄提供具體訪問(wèn)的接口,因此所有的測(cè)試都是黑盒。但是在移動(dòng)端的中,大都是webkit內(nèi)核,其webcore的具體實(shí)現(xiàn)也都相近,因此該節(jié)得出的結(jié)論完全可以在移動(dòng)端使用。

盡管無(wú)法訪問(wèn)當(dāng)前頁(yè)的歷史記錄棧,但是瀏覽器卻提供了history.length屬性,它標(biāo)明了當(dāng)前歷史記錄棧的個(gè)數(shù)。該值會(huì)幫助我們更好地分析History API對(duì)歷史記錄棧的影響。

測(cè)試

上圖為測(cè)試實(shí)例。其中白色箭頭意味著點(diǎn)擊該鏈接并執(zhí)行pushState操作(即操作1),黑色箭頭則執(zhí)行瀏覽器后退,紅色的圓點(diǎn)為歷史記錄棧中的當(dāng)前指針,而每個(gè)項(xiàng)則為歷史記錄棧,歷史記錄的個(gè)數(shù)則為其子項(xiàng)的數(shù)量。

初始在第一個(gè)搜索列表頁(yè),執(zhí)行操作1后歷史堆棧數(shù)量增加,當(dāng)前指針上移一位至26788.html;
同理在執(zhí)行3次操作1,歷史堆棧遞增3個(gè),當(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é)束。雖然這里僅僅列出了這一個(gè)測(cè)試用例,但是其實(shí)筆者做了更多更復(fù)雜的測(cè)試,并且平臺(tái)涉及了pc和移動(dòng)端的瀏覽器、微信和原生webview,結(jié)果都一樣。這一系列測(cè)試說(shuō)明了很多問(wèn)題,總結(jié)之一句話則是:

瀏覽器針對(duì)每個(gè)頁(yè)面維護(hù)一個(gè)History棧。執(zhí)行pushState函數(shù)可壓入設(shè)定的url至棧頂,同時(shí)修改當(dāng)前指針;
當(dāng)執(zhí)行back操作時(shí),history棧大小并不會(huì)改變(history.length不變),僅僅移動(dòng)當(dāng)前指針的位置;
若當(dāng)前指針在history棧的中間位置(非棧頂),此時(shí)執(zhí)行pushState會(huì)改變history棧的大小。
總結(jié)pushState的規(guī)律,可發(fā)現(xiàn)當(dāng)前指針在history棧頂部時(shí)執(zhí)行pushState,會(huì)增加history棧大??;若current指針不在棧頂則會(huì)在當(dāng)前指針?biāo)谖恢锰砑禹?xiàng)。執(zhí)行back操作并不修改history棧大小,因此可以通過(guò)back和forward在當(dāng)前大小的history棧中自由移動(dòng)。

掌握這個(gè)規(guī)律,就知道如何維護(hù)歷史記錄,就知道在什么狀態(tài)下需要pushState?;氐阶畛醯男枨?,產(chǎn)品經(jīng)理規(guī)定從商品34的評(píng)論頁(yè),按后退按鈕可以到達(dá)最初的列表頁(yè),但是他并沒(méi)有詳細(xì)規(guī)定如何后退。在這里就會(huì)有2中實(shí)現(xiàn)方式:

  • 每一次后退,會(huì)回到上次的訪問(wèn)地方。如,在商品34的評(píng)論頁(yè),會(huì)后退至商品34的詳情頁(yè),再后退則會(huì)回到商品9的詳情頁(yè),直至回到列表頁(yè)。
  • 總共維護(hù)三層歷史記錄,第一層(棧底)為列表頁(yè),第二層為詳情頁(yè),第三層(棧頂)為評(píng)論頁(yè)或圖片詳情頁(yè)。在該種實(shí)現(xiàn)下,由商品34的評(píng)論頁(yè)第一次后退至商品34的詳情頁(yè),第二次后退至列表頁(yè)。

針對(duì)第一種,其實(shí)實(shí)現(xiàn)最為簡(jiǎn)單,因?yàn)檫@完全是由瀏覽器默認(rèn)控制歷史記錄堆棧,而我們只需在合適的時(shí)機(jī)調(diào)用pushState將url插入到堆棧,然后在onpopstate處理函數(shù)中監(jiān)聽(tīng)對(duì)應(yīng)的時(shí)間即可:

window.addEventListener('popstate', function (e) {

    console.log('popstate')
    // 后退(前進(jìn))至商品詳情頁(yè),異步加載數(shù)據(jù)并渲染
    if(e.state && e.state.indexOf('/shop/sku/') !== -1){
      ajaxDetail(e.state,true);
    }else
    // 后退(前進(jìn))至評(píng)論頁(yè),異步加載數(shù)據(jù)渲染
    if(e.state && e.state.indexOf('/shop/comment/commentList.html') !== -1){
      ajaxComment(e.state,true);
    }else
    // 后退(前進(jìn))至圖片詳情頁(yè),異步加載數(shù)據(jù)渲染
    if(e.state && e.state.indexOf('/shop/item/pictext/') !== -1){
      ajaxPic(e.state,true);
    }else
    // 后退(前進(jìn))至列表頁(yè),隱藏浮層
    if(e.state && e.state.indexOf('/search/') !== -1){
      // 隱藏spa的浮層
      $('.spa-container').css('zIndex','-1');
    }

  });

針對(duì)第二種實(shí)現(xiàn),則是本文的重點(diǎn)。畢竟,由瀏覽器默認(rèn)維護(hù)的歷史堆棧在某些業(yè)務(wù)場(chǎng)景中并不匹配,因此需要開(kāi)發(fā)者自己維護(hù)一個(gè)歷史記錄棧。在本次實(shí)現(xiàn)中,由于總共涉及4張頁(yè)面的顯示,因此我們?cè)O(shè)定了3層歷史堆棧,這很好理解。

為了構(gòu)建這樣的歷史記錄棧,在主頁(yè)面(即列表頁(yè))中需要額外添加兩條歷史記錄。這是由于默認(rèn)打開(kāi)列表頁(yè)時(shí),當(dāng)前頁(yè)面的url已加入歷史記錄棧中,

function push(state){
    history.pushState(state, null, location.pathname + location.search);
  }
  // 'abc'用于標(biāo)示初始列表頁(yè)
  history.replaceState('abc',null,location.pathname + location.search)

  // 壓入兩條歷史記錄
  push();
  push();

這樣,打開(kāi)列表頁(yè)后就會(huì)創(chuàng)建3個(gè)歷史記錄,并且這3個(gè)歷史記錄的url都為列表頁(yè)的url,這與后面的操作并無(wú)影響。

在列表頁(yè)中打開(kāi)詳情頁(yè),需要做額外的處理。由于按照我們?cè)O(shè)計(jì)的歷史記錄棧,第二層應(yīng)該為詳情頁(yè),而此時(shí)在初始化后,歷史記錄棧的當(dāng)前指針已指向棧頂元素,因此需要將當(dāng)前指針下移一位。這里就需要history.back來(lái)完成。

$('.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 請(qǐng)求路徑 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并不會(huì)立即出發(fā)popstate事件,以v8引擎為例,在執(zhí)行back之后
          // 的大概18us之后會(huì)觸發(fā)事件,而此時(shí)如果立即通過(guò)replaceState修改url則會(huì)造成失敗,修改的是
          // history stack棧頂?shù)膗rl.

          // 這里通過(guò)異步執(zhí)行replaceState兼容
          history.back();

        }

        // 異步觸發(fā)
        setTimeout(function(){
          history.replaceState(url, null, url);
        })

        // 針對(duì)推薦欄的商品,循環(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),通過(guò)isScrollXClick變量判斷是否點(diǎn)擊的是推薦商品,如果不是則需要執(zhí)行back操作,下移指針。此時(shí)指針是指在第二層,但是瀏覽器和第二層歷史記錄的url仍為初始化設(shè)定的url,因此需要修改,在這里異步修改當(dāng)前url。

之所以異步執(zhí)行replaceState,是由于webkit觸發(fā)popState事件決定的。在代碼中執(zhí)行history.back 或者h(yuǎn)istory.forward,并不會(huì)立即返回,也不會(huì)立即觸發(fā)popState事件。由于沒(méi)有閱讀webkit的源碼,因此無(wú)從推測(cè)執(zhí)行back或者forward后具體需要額外做什么操作,它們之間有著10us級(jí)別的間隔,因此此處必須使用setTimeout實(shí)現(xiàn)異步改變url。

在具體開(kāi)發(fā)過(guò)程中,這個(gè)問(wèn)題困擾著筆者好幾天,終于在一次調(diào)試過(guò)程中發(fā)現(xiàn)瀏覽器url的變動(dòng),才聯(lián)想到可能是由事件觸發(fā)的時(shí)間差導(dǎo)致。

對(duì)于圖片詳情和評(píng)論的邏輯處理,則和上文類似,無(wú)需多言。

最后一次后退需要回到列表頁(yè),而在初始化階段我們給列表頁(yè)設(shè)置了state為“abc”,特殊的標(biāo)示該路由,因此在popState事件處理中,我們就可以根據(jù)該項(xiàng)回到初始頁(yè):

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();
    }


  });

如果回到初始頁(yè),隱藏浮層,同時(shí)在執(zhí)行2次push操作。根據(jù)上節(jié)發(fā)現(xiàn)的規(guī)律,在初始頁(yè)執(zhí)行2次push操作,會(huì)在當(dāng)前指針位置重新添加2個(gè)歷史記錄,當(dāng)前指針指向棧頂元素,歷史記錄棧的數(shù)量不變,仍為3。這樣就完成了簡(jiǎn)單的由開(kāi)發(fā)者自定義維護(hù)歷史堆棧的spa系統(tǒng)。

回顧

之所以會(huì)寫(xiě)這篇文章完全是出于偶然,由于實(shí)際項(xiàng)目的各種需求我們不應(yīng)該僅僅將眼光停留在使用API的層面上。另外,在開(kāi)發(fā)過(guò)程中遇到難以解決的問(wèn)題,需要提出各種合理的設(shè)想并用詳實(shí)的實(shí)驗(yàn)證明,在得到相對(duì)應(yīng)的結(jié)論后需要利用該結(jié)論去例證其他場(chǎng)景,這樣才能確保解決方案的可靠性。目前網(wǎng)絡(luò)上或者書(shū)籍中并未提供任何手動(dòng)維護(hù)歷史記錄堆棧的方法,也未明確指出History API與瀏覽器歷史記錄之間如何影響,因此本文對(duì)于旨在利用History API實(shí)現(xiàn)spa的開(kāi)發(fā)者而言還是有些指導(dǎo)意義的。

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

推薦閱讀更多精彩內(nèi)容