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

尚妝產品技術刊讀發表於2017-03-01

本文由尚妝前端開發工程師欲休撰寫

本文發表於尚妝部落格,歡迎訂閱!

移動端開發在某些場景中有著特殊需求,如為了提高使用者體驗和加快響應速度,常常在部分工程採用SPA架構。傳統的單頁應用基於url的hash值進行路由,這種實現不存在相容性問題,但是缺點也有--針對不支援onhashchange屬性的IE6-7需要設定定時器不斷檢查hash值改變,效能上並不是很友好。

而如今,在移動端開發中HTML5規範給我們提供了一個History介面,使用該介面可以自由操縱歷史記錄。本文並不詳細介紹History介面,而是探究History介面如何影響瀏覽器歷史堆疊,並且利用這個規律應用到具體的實際業務中,提出兩種歷史記錄儲存策略,使路由邏輯更清晰,讓SPA更容易。

History API回顧

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

pushState

history.pushState(stateObject, title, url),包括三個引數。

第一個引數用於儲存該url對應的狀態物件,該物件可在onpopstate事件中獲取,也可在history物件中獲取。

第二個引數是標題,目前瀏覽器並未實現。

第三個引數則是設定的url。一般設定為相對路徑,如果設定為絕對路徑時需要保證同源。

pushState函式向瀏覽器的歷史堆疊壓入一個url為設定值的記錄,並改變歷史堆疊的當前指標至棧頂。

在這裡筆者使用歷史堆疊和當前指標,用以說明瀏覽器對歷史記錄的管理策略。文件中並沒有使用這樣的詞彙,筆者為了更形象的介紹介面對瀏覽器歷史記錄的影響,使用這樣的描述,如有不當之處請及時指出(不過目前以這套模型為基礎的邏輯實現中並未出現悖論)。

replaceState

該介面與pushState引數相同,含義也相同。唯一的區別在於replaceState是替換瀏覽器歷史堆疊的當前歷史記錄為設定的url。需要注意的是,replaceState不會改動瀏覽器歷史堆疊的當前指標。

onpopstate

該事件是window的屬性。該事件會在呼叫瀏覽器的前進、後退以及執行history.forward、history.back、和history.go觸發,因為這些操作有一個共性,即修改了歷史堆疊的當前指標。在不改變document的前提下,一旦當前指標改變則會觸發onpopstate事件。

History API與業務實踐

最常見的單頁應用場景:列表頁、商品詳情頁以及其內部的其他連結入口如圖片頁、評論頁及其推薦其他商品詳情頁。以上提到的已經涉及到了4個單獨業務邏輯頁面(推薦的商品可複用商品詳情頁邏輯),分別是:列表、詳情、圖片詳情和評論。將這4個頁面合併到一個頁面中,這就是最簡單的SPA。為了使用者的良好體驗,必須設計合理的互動邏輯,最直觀的就是瀏覽器(或手機app、微信公眾號)的後退前進必須合乎業務邏輯特點。因此,這就涉及到了History API的使用,也牽扯到瀏覽器的歷史記錄管理。

「前端」History API與瀏覽器歷史堆疊管理
業務邏輯例項

上圖為具體的邏輯示意圖。在列表頁,點選其中一個商品,這裡是商品1,進入詳情頁。詳情頁包括了該商品的輪播圖、商品的圖片詳情入口、評論入口和推薦的其他商品入口。接下來進行如下操作:進入圖片詳情頁,後退至詳情頁再進入評論頁;後退至商品1詳情頁再由推薦商品入口進入商品9詳情頁,同樣在商品9詳情頁進入圖片詳情頁和評論頁,再後退至商品9詳情頁;由推薦商品入口進入商品34詳情頁,再進行類似操作。最後保證在商品34圖片詳情頁或評論頁可以順利後退至最初的商品列表頁。

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

這樣一個很細小的需求,但是一旦真正放手去做卻不是那麼容易。僅僅根據History API的2個函式和1個事件去盲目的嘗試實現,這屬於盲人摸象,魯棒性不高。不清楚瀏覽器的歷史記錄管理策略,不瞭解當前頁面的歷史記錄數量,此種情況若要實現上述場景就有些麻煩。所以在具體動手寫業務程式碼之前,需要搞懂History的pushState和replaceState具體如何影響歷史記錄棧。

探究瀏覽器歷史記錄策略與History API的關係

由於瀏覽器並未針對每個頁面的歷史記錄提供具體訪問的介面,因此所有的測試都是黑盒。但是在移動端的中,大都是webkit核心,其webcore的具體實現也都相近,因此該節得出的結論完全可以在移動端使用。

儘管無法訪問當前頁的歷史記錄棧,但是瀏覽器卻提供了history.length屬性,它標明瞭當前歷史記錄棧的個數。該值會幫助我們更好地分析History API對歷史記錄棧的影響。

「前端」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的開發者而言還是有些指導意義的。


本文對你有幫助?歡迎掃碼加入前端學習小組微信群:

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

相關文章