一、背景
使用ajax,可以實現不需要刷新整個頁面就可以進行局部頁面的更新。這樣可以開發交互性很強的富客戶端程序,減少網絡傳輸的內容。但長期以來存在一個問題,就是無法利用浏覽器本身提供的前進和後退按鈕進行操作。比如在頁面執行某個動作,該動作利用ajax請求到服務器獲取數據,更新了當前頁面的某些內容,這時想回到操作前的界面,用戶就會習慣點擊浏覽器的後退按鈕,實際這裡是無效的(要麼頁面沒反應,要麼打開一個前面打開的過的頁面),或者想收藏當前頁面(以便於重新打開時直接顯示當前的信息),也是無法做到的。
這個問題因為html5的新特性而得以可以解決。但不是直接解決了。而是提供了一些新的api,需要程序員編寫代碼來實現。下面我們將詳細的來介紹。
如果你對此問題和html5的這些新特性已經有些了解,可以直接跳到最後的案例章節。
二、history對象分析
浏覽器是通過 window對象的 history對象來對浏覽器歷史訪問記錄,從而可以實現前進和後退。history對象可以理解其保存了一個有序的列表對象,每個對象都代表了一個頁面信息(包括頁面的url等信息),注意當前頁面也被保存在裡面。
這樣就可以通過浏覽器本身提供的前進和後退按鈕來操作,也可以利用javascript調用history對象的back(),forward(),和go()方法來實現頁面的切換。
我們先來理解下history的機制。history對象中記錄了浏覽器窗口訪問過的url,但出於安全考慮,無法通過程序獲取history對象中的具體信息,只能通過back、forward、go方法進行頁面跳轉,此外length屬性記錄了history中的記錄(url)條數。
我們設想下,當在浏覽器窗口打開第一個地址,比如 url1時, 這時history中就有了url1這個記錄,且length屬性值為1,history對象中有個當前頁面指針(從概念上可以這麼理解)指向url1;如果再打開一個url2頁面(無論是通過在地址欄直接輸入、或通過url1中的鏈接或js代碼打開),這時history中就有了url1和url2這兩個記錄,是一個有序的列表,這時length屬性值為2,history對象中的當前頁面指針指向url2,這時url2是最新的頁面,頁面不可以前進,但可以後退到url1,這時如果點擊浏覽器本身提供的後退按鈕(或用js調用back方法),這時url1頁面會被重新加載顯示,history對象的length仍然為2,url1和url2組成的列表仍然不變,但history對象中的當前頁面指針指向url1了,這時就不能後退但可以前進了。可以理解成一個數據結構中的雙向鏈表機制。
通過上面的描述我們可以看出,我們說的歷史記錄都是指一個完整的頁面請求url,而ajax並不是一個完整的頁面請求,因此浏覽器無法記錄ajax的操作信息。
三、history對象的新特性
HTML5引入了histtory.pushState()和history.replaceState()這兩個方法,它們會更新history對象的內容。同時,結合window.onpostate事件,就可以解決文章開頭提出的問題。
我們先來看pushState方法的含義,我們通過舉例子的方式來更好的說明,先給出一段代碼:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>測試</title> <script type="text/javascript" src="jquery.min.js"></script> </head> <body> <button onclick="doPushState()">pushState</button> <button onclick="count()">count</button> <script> var index=1; $(function(){ alert(location.href); }); function doPushState(){ history.pushState({}, "newtitle","test"+(index++)+".html"); } function count(){ alert(window.history.length); } </script> </body> </html>
上面是一個完整的html文件,文件名為demo.html。 把該文件放到web服務器上,從浏覽器訪問。如果是直接從本地磁盤打開,文件中的js代碼執行會報錯誤。
在一個新的浏覽器窗口訪問該demo.html。 首先會執行 $()方法,彈出代碼中的location.href信息。 這時執行count按鈕,顯示為1,注意如果在ie或chrome的新的浏覽器窗口打開,值可能為2,因為它們的窗口會加載系統默認的一個頁面,不是一個空白的窗口。
這時我們每點擊一下pushState按鈕,發現浏覽器的地址會發生變化,先後變為test1.html , test2.html, test3.html, .......,並且通過點擊count按鈕發現,彈出的值加1. 這說明每調用一次pushState方法,history中就會新增加一條url記錄。
我們先來解釋下pushState方法,該方法有三個參數:
1)第一個參數是個js對象,可以放任何的內容,可以在onpostate事件中(後面介紹)獲取到便於做相應的處理。
2)第二個參數是個字符串,目前好像沒有起作用,可以傳個空串。
3)第三個參數是個字符串,就是保存到history中的url。
結合例子的代碼和輸出可以看出,調用pushState方法的作用,就是相當於打開一個新頁面,把當前頁面作為歷史記錄,而當前的地址欄顯示的是pushState方法中的url(這裡是test.html)。但是與普通的打開一個新頁面不同。浏覽器將不會在調用pushState()方法後加載這個url,也就是說即使你寫一個錯誤的url,也不會不錯。
可以這麼理解,當我們在一個新的浏覽器窗口打開 demo.html後,點擊n次pushState按鈕後,history對象中存在這樣的一個ulr列表。
demo.html(第1個url)----> index1.html(第2個url).......->index?.html(第n個url)----->indexn.html(當前頁面的url)。
這時我們需要點擊浏覽器上的回退按鈕n次,才能將浏覽器上的地址退回到 demo.html。而且無論是在點擊pushState按鈕 或點擊回退按鈕的過程中發現,$()方法根本沒有被觸發,也就是說整個過程浏覽器的頁面內容都沒有發生變化,變化的只是地址信息。
這也進一步說明,pushState只是將當前頁面保存到history的歷史記錄中(並作為最近的一個記錄),並且將當前浏覽器的地址欄改為參數url的指定的值,但並不會加載它。這點與普通的通過鏈接打開或浏覽器地址輸入url完全不一樣。
到了這裡我們可以想象一下文章開頭提出的問題了,如果我們在頁面中執行一個ajax操作,當操作成功(如更新頁面的局部內容)後,我們通過代碼調用pushState方法,設置一個新的url,這樣看上去就像發起了一個全新的請求,實際上只是個ajax操作。這時回退按鈕也能用了,問題僅僅這樣,回退按鈕點了也沒有任何反應。如果我們能通過代碼,來響應這個回退按鈕觸發的事件,在事件中讓界面恢復到ajax請求之前的界面,問題不就解決了嗎?
得確如此,解決思路就是上面說的。下面我們來通過一個實際的例子看如何實現。在介紹例子之前,我們先來解釋下html5中 history新增的另一個方法replaceState方法。
replaceState方法與pushState類似,同樣有三個參數。區別在於,replaceState()是用來修改history對象中記錄的當前頁面的信息,它不是新建一個記錄。如果將上面例子中的 代碼 history.pushState({}, "newtitle","test"+(index++)+".html"); 中的pushState改為replaceState,其它代碼都不動。這時我們點擊pushState按鈕後,看到的現象是一樣的,地址欄的地址不斷變化,頁面內容不變。但我們點擊count按鈕,發現history中的記錄數不變。這說明replaceState只是改變當前頁面在history對象中的記錄信息;而pushState是會產生一個新記錄作為當前記錄,把當前頁面作為歷史的記錄保存。
我們再來看下window對象的popstate事件,當進行頁面的前進或回退時,會觸發該事件,並且在事件響應函數中通過 history.state 可以獲取到 pushState方法和replaceState方法對一個參數指定的對象。
解釋了這幾個api後,我們來一個具體的例子。
四、具體案例
我們來設想這樣一個應用。一個頁面來顯示一篇長文章,該文章內容很長,分為很多章節。我們希望頁面不會一次把所有章節的內容都加載起來,而是有一個章節導航,點擊每個章節鏈接,通過jax加載具體章節的內容,而其它頁面部分不需要要變化。
我們先看下傳統的實現代碼(注意,這裡只注意核心邏輯代碼的實現,其它的頁面布局等盡量簡化):
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>test2</title> <style type="text/css"> div { padding-bottom:100px; } </style> <script type="text/javascript" src="jquery.min.js"></script> </head> <body> <div style="float:left;border:1px solid red;margin:20px"> <p><a href="javascript:;" id="section1">第1章</a></p> <p><a href="javascript:;" id="section2">第2章</a></p> <p><a href="javascript:;" id="section3">第3章</a></p> </div> <div style="float:left;border:1px solid red;margin:20px" id="content"> </div> <script> $(function(){ //添加鏈接的處理事件 $("a").click(ajax); //加載默認的章節,默認顯示第1章 $("#section1").trigger("click"); }); function ajax(event){ //實際的流程是發起ajax請求,獲取內容並顯示。這裡為了簡化,沒有寫實際的ajax請求。 //這段代碼應該在ajax的請求響應中編寫。 $("#content").html(this.id+"的內容"); var title = this.id; document.title = title; } </script> </body> </html>
在浏覽器加載該頁面,當我們點擊不同的章節鏈接時,內容會跟著變化,浏覽器的標題也跟著變化。但是:
1)回退、前進按鈕用不了
2)當我們刷新頁面時,不管當前在哪個章節,都會重新回到第一個章節。
3)地址欄的url沒有變化,也意味著我們沒法把某個章節的地址保存下來,以後再次打開直接顯示該章節內容。
上面就是傳統ajax應用的一些弊端。下面我們就來解決這些問題。
我們先給出解決代碼:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <title>test2</title> <style type="text/css"> div { padding-bottom:100px; } </style> <script type="text/javascript" src="jquery.min.js"></script> </head> <body> <div style="float:left;border:1px solid red;margin:20px"> <p><a href="javascript:;" id="section1">第1章</a></p> <p><a href="javascript:;" id="section2">第2章</a></p> <p><a href="javascript:;" id="section3">第3章</a></p> </div> <div style="float:left;border:1px solid red;margin:20px" id="content"> </div> <script> $(function(){ //添加鏈接的處理事件 $("a").click(ajax); //加載默認的章節 changeContent(); //添加popstate事件 $(window).on("popstate",function(){ changeContent(); }); }); function changeContent(){ var query = location.href.split("?")[1]; if (!query) { // 如果沒有查詢條件,則顯示默認第1個章節 history.replaceState(null, "", location.href + "?name=" + $("#section1")[0].id); changeContent(); } else { //觸發按鈕click事件,加載內容, //注意不要漏了true參數,這樣可以和用戶直接點擊觸發的頁面變化區別開來 $("#"+query.split("=")[1]).trigger("click",true); } } function ajax(event,isPopstate){ $("#content").html(this.id+"的內容"); var title = this.id; document.title = title; if(!isPopstate){ history.pushState(null, "", location.href.split("?")[0] + "?name=" + title); } } </script> </body> </html>
加載上面頁面,測試下,所有的問題都解決了。下面我們來解釋下上述代碼。
我們先看changeContent方法,該方法首先獲取頁面的url地址,判斷該地址是否有查詢條件(是否帶章節信息),如果沒有,認為要顯示第一章節。我們利用history的replaceState方法來改變當前的url,加上 name=section1的查詢條件,表示是第1章。因為replaceState方法不會改變頁面內容,因此還需要接著再調用changeContent方法。如果地址帶了查詢條件,認為已經指定顯示某個章節內容,這時觸發章節鏈接的click事件。
我們再看ajax方法,就是章節鏈接的click事件響應函數,為了簡化,該函數沒有發起實際的ajax請求,而是相當於直接處理ajax返回的結果。首先是用得到的結果更新頁面(這裡是直接寫死的),然後更新標題,這與傳統的ajax做法一樣。關鍵的區別是,判斷該方法如果是用戶點擊的(不是onpopstate事件處理的),就會調用history對象的pushState方法來將當前頁面信息保存到history對象中,並新增一個記錄信息代表ajax請求後的頁面。
changeContent方法同樣是onpopstate事件的處理函數,其功能就是利用獲取到的url信息(保存在history記錄)中,來通過ajax獲取到對應的內容,讓頁面顯示相應的信息。 從用戶感知上看,就跟正常的回退、前進導致的頁面切換一樣。用戶感覺不到是ajax請求,還以為就是多個獨立的頁面在切換。
五、小結
本文詳細的介紹了如何利用html5的新特性來解決傳統ajax請求導致的一些缺陷。通過上面的介紹可以看出,為了解決問題,還是需要程序員做不少的事情,對於一個實際的項目來說,最好能在框架層面進行封裝解決,而不是要讓每個具體頁面的實現者都來處理。這個可以是下一步要考慮的內容。