凡走過請留下痕跡:AJAX網頁的狀態與瀏覽記錄

網頁以及使用者的習慣

使用者瀏覽網頁時,重新整理&上一頁/下一頁是很重要的功能鍵。重新整理是表示重新載入我目前在看的網頁,通常是想看看有沒有新的資訊。而上一頁/下一頁則是帶領使用者前往他上一個/下一個使用過的頁面。很簡單的概念,不是嗎?但這一切在ajax廣泛使用之後有了新的挑戰。

AJAX所帶來的挑戰

現今的前端工程師應該沒人不知道AJAX是什麼,簡單來說就是不用重新整理頁面逕向伺服器取得資料的動作。這帶來了什麼樣的問題?我們從AJAX的本質來想大概就可以略知一二:既然不用重新整理頁面又需要取得資料,通常是只需要更新部分的頁面,比如Google Map(最早開始使用AJAX的Web app之一),當我們放大縮小地圖時,其實只有地圖的頁面範圍有更新,其他部分的頁面都是保持不變的,甚至,連URL都還是一樣。

發現問題在哪了嗎?對,就是"URL都還是一樣"。因為原本HTML頁面的設計是一個http request就一個單獨的頁面,自然也就是一個單獨的URL,但是AJAX技術的出現使得我們能在同一個頁面內變出不同的把戲(把這技術發揮到極致的當然就是現在也很常見的Single Page Application,所有內容都在同一頁裡),改變了一個頁面對應一個URL的思維。

頁面的設計及URL如何對應是一個衍生的問題,剛剛舉到的Google Map對於URL的對應有他另外一種解決辦法,但這是設計面的問題。本文來講些技術面的:要如何創建一個能記錄狀態的ajax頁面?

老派作風 (按:HTML5之前的做法)

HTML5出來之前,要記錄頁面狀態是透過兩種方式:URL Hash跟IFrame。兩者的差異,簡單的說,IFrame是IE的解決方式。

URL Hash

要讓瀏覽器能記錄、書籤,就只能從URL下手了,但是直接用window.location.href修改URL是不行的,這會直接載入頁面。但有個東西叫做fragment identifier,也就是跟隨在hash(井字符號#)後面的字串,因此大家也簡稱他為hash。當我們用javascript去修改頁面的hash時,是不會造成頁面重新載入的,所以我們的ajax function可以像這樣:

function demo() {
    // ... 執行ajax
    window.location.hash = "#demo";
}

但是事情沒有這麼容易解決,我們改變hash的確是可以在瀏覽器的裡面增加一筆瀏覽的紀錄,但當我們回到那一頁時,瀏覽器是以URL來載入,假設是 http://rettamkrad.blogspot.com/index.htm#demo 對於瀏覽器來說這是跳到index.htm的demo區段的意思(fragment identifier最初的用法),而不會去執行前述的demo函式。所以,我們必須自己抓取hash來決定頁面的狀態:

window.onload = function() {
    initialiseStateFromURL();
}

function initialiseStateFromURL() {
    var hash = window.location.hash;
    // ...判斷state並執行對應(ajax)動作
}

但這樣還不夠,如果我們在上一步/下一步時是從同個頁面比如 index.html#demo 跳到 index.html#source的話,由於是同一個頁面不會觸發頁面重新載入,剛剛的window.onload方式就沒有效用。但這邊基本上在舊的瀏覽器沒什麼辦法,我們只能寫一個小小的函式固定的去檢查目前URL是否有變化(polling),一旦有變化就執行狀態變更的相應動作。

window.onload = function() {
    initialiseStateFromURL();
    setInterval(pollHash, 1000);
}

var recentHash;
function pollHash() {
    if (window.location.hash == recentHash) {
        return; //hash沒有變化,不做任何事情
    }
    recentHash = window.location.hash;
  
    // URL has changed, update the UI accordingly.
    initialiseStateFromURL();
}

function initialiseStateFromURL() {
    var hash = window.location.hash;
    // ...判斷state並執行對應(ajax)動作
}

不過在稍微新一點的瀏覽器支援 onhashchange(IE8/FF3.6/Opera10.6/safari5/chrome5.0)事件,這個事件只要頁面的hash有變化就會觸發,因此polling的動作可以省去,讓我們可以把的架構變成這樣:

window.onload = function() {
    initialiseStateFromURL();

    if ("onhashchange" in window) {
        window.onhashchange = initialiseStateFromURL;
    } else {
        setInterval(pollHash, 1000);
    }
}

var recentHash;
function pollHash() {
    if (window.location.hash == recentHash) {
        return; //hash沒有變化,不做任何事情
    }
    recentHash = window.location.hash;
  
    // URL has changed, update the UI accordingly.
    initialiseStateFromURL();
}

function initialiseStateFromURL() {
    var hash = window.location.hash;
    // ...判斷state並執行對應(ajax)動作
}

IFrame

前述的URL Hash方式,在IE是無法作用的,因為IE不會將Hash的變化記錄到瀏覽記錄內,也就是說只要是在同個頁面,就只會有一筆記錄。但IFrame是個例外,因為IFrame會有自己的URL,所以在IE的邏輯裡,IFrame的URL變化跟頁面的URL變化一樣都會被記錄在瀏覽記錄之內。

因此,我們可以產生一個隱藏的IFrame,並利用IFrame的URL變化來解決這個問題。但這只解決了瀏覽記錄的問題,IE的書簽仍然只記錄URL,所以我們還是需要之前的URL Hash所提到的方法。

var curHash;
window.onload = function() {

    curHash = window.location.hash;

    $("body").prepend('<iframe id="history_iframe " style="display: none;" src="javascript:void(0);"></iframe>');
    var iframe = $("#history_iframe")[0].contentWindow.document;
    iframe.open();
    iframe.close();
    iframe.location.hash = curHash;
    
    initialiseStateFromURL();
    setInterval(pollHash, 1000);
}


function pollHash() {
    // On IE, check for location.hash of iframe
    var ihistory = $("#history_iframe")[0];
    var iframe = ihistory.contentDocument || ihistory.contentWindow.document;
    var current_hash = iframe.location.hash;
    if(current_hash != curHash) {
        location.hash = current_hash;
        curHash = current_hash;
    }

    initialiseStateFromURL();
}

function initialiseStateFromURL() {
    var hash = window.location.hash;
    // ...判斷state並執行對應(ajax)動作
}

流程基本上跟URL Hash差不多,只是另外增加了修改iframe.location.hash的部分,透過改變iframe的src這個動作 來把紀錄塞到IE的歷史記錄裡面去。

HTML5 History

現在就進入正題吧(按:所以前面是? 鳥:講古嘛...),這個問題在HTML5規範中獲得了良好的解決:History API。

History API顧名思義,讓我們有辦法直接操縱瀏覽器的URL及歷史紀錄,主要有下列兩個function及一個event:

  • pushState(state, title, url): 把 url 塞到歷史記錄裡,名稱為 title,且此頁面保有 state物件
  • replaceState(state, title, url): 跟pushState用法一樣,只是此function功能是取代目前的記錄,而非新增一筆
  • window.onpopstate:當我們在同一個頁面的歷史紀錄中來回時會產生的event。下面有範例:
    window.onpopstate = function(event) {
        alert("location: " + document.location + ", state: " + JSON.stringify(event.state));
    };
    history.pushState({page: 1}, "title 1", "?page=1");
    history.pushState({page: 2}, "title 2", "?page=2");
    history.replaceState({page: 3}, "title 3", "?page=3");
    history.back(); // alerts "location: http://example.com/example.html?page=1, state: {"page":1}"
    history.back(); // alerts "location: http://example.com/example.html, state: null
    history.go(2);  // alerts "location: http://example.com/example.html?page=3, state: {"page":3}
                
最新支援HTML5的瀏覽器已經都支援History API了(Chrome 18, FF 4, IE 10, Opera 11.5, safari 6),

跨瀏覽器的利器—History.js

前端工程師的麻煩就是常常需要能在各種瀏覽器上正常運作,History.js就是這樣的一個js lib,一個lib讓你可以相容各式瀏覽器(按:那你幹嘛不早點講就好? 鳥:那這篇就沒幾個字可以寫了啊...),更棒的是用法跟HTML5 History API幾乎一模一樣,省去你的許多時間。

History.pushState({state:1}, "State 1", "?state=1");
History.replaceState({state:3}, "State 3", "?state=3");
History.back();
History.go(2);

伺服器端的支援

另一個問題是伺服器端的問題,因為history api的關係,會出現以下的情境:當我們在github瀏覽各階層的檔案的時候,假設從History.js的首頁 https://github.com/balupton/History.js 移動到他的demo code裡 https://github.com/balupton/history.js/tree/master/demo,直接在畫面上點擊是使用ajax載入資料,所以伺服器端回傳的只有中間檔案列表的部分,但注意到URL是整個換掉,也就是說同樣的url,以ajax方式發出跟直接打url發出是會得到不同的回應的。這個要怎麼做呢?

其實很簡單,因為ajax request的http header會有一個 X-Requested-With欄位為 XMLHttpRequest,我們只要判斷這個欄位就可以知道是否為ajax request。如果有用Node.js的expressjs就更簡單了

app.get('/path', function(req, res) {
    var is_ajax_request = req.xhr;
    ...
});

透過對xhr的判斷知道是否為ajax request,在回傳相對應資料,大功告成!

參考資料

4 則留言: