想法與目标
另外,我也曾經見過把後退按鈕禁用的做法(其實這樣對于解決問題的确不錯),不過這些都似乎隻是一個workaround,設法避開這個AJAX應用普 遍存在的問題。似乎Gmail能夠支援Back按鈕,但是我驚奇的發現,在點選Back後,卻不能使用Forward,是以這還不算成功的解決這個問題。 那麼能否解決?似乎已經有了一定的實作。
HistoryControl是一個Server控件,必須配合UpdatePanel使用,并沒有對于Atlas的用戶端應用甚至普通的AJAX應用提供基本的支援。
不支援FireFox(不知為何,我在自己嘗試之後覺得支援FireFox比IE容易實作)。
在IE裡使用時,從Back和Forward的下拉框裡可以看出,那些Title都成為了“Empty Page”。
不支援在Back和Forward下拉框中選擇一項History跳轉。
如果通路了别的站點再Back,則在IE下不支援多次回退。
部署麻煩。事實上我覺得很奇怪,我除了直接在他的項目中成功運作之外。部署到别的項目或者是我的空間都有問題,怎麼也找不出原因,估計是檔案路徑問題,需要仔細讀一下他的代碼。 總之,這個解決方案還很不成熟,但是我們要對Nikhil,Atlas和微軟有信心,對于Back/Forward的内置支援應該會出現在Atlas的後續版本中。
于是我想,不如我來實作一個自己的吧,雖然我一直提倡軟體複用,但是如果找不到成熟的解決方案,那麼就該發揮程式員的主觀能動性了。對于我最後的實作,它有以下特點:
一個輕量級的JS解決方案。雖然我是在Atlas的基礎上寫的,但是隻是使用了Atlas中的Sys.Timer類,很容易修改成獨立于任何庫的JS代碼。
支援IE和FireFox。
簡 單的支援Back和Forward的下拉框裡的Title文字,在大多數情況下不會出錯。産生Nikhil的這個問題的原因在闡述我的實作時會提及。我簡 單地解決了這個問題,但是沒有設計出完整支援title問題的完美實作。我有一些想法,似乎十分複雜,在嘗試時都宣告失敗。
支援在Back和Forword下拉框中選擇一項History跳轉。
支援Bookmark,使用者可以輕松将頁面加入收藏夾。
易于使用,部署簡單。 對于我列出Nikhil的實作裡的第5個問題,我想了一些辦法,卻依舊沒有解決。現在雖然腦子裡有想法,但還需要繼續嘗試。
思路與設計
AJAX是個神奇的東西。因為有了XMLHttpRequest對象,我們能夠“不知不覺”地與伺服器端交換資料,在改變頁面顯示和行為的同時,讓使用者 感覺不到頁面的Reload。雖然在上個世紀微軟已經在早期IE裡就以ActiveX的形式提供了這個對象,并且在OWA中将其進行大量使用,但是我對于 這個對象的了解卻在AJAX大行其道之後。在這之前,為了達到類似AJAX的良好使用者體驗,往往會在頁面中放一個隐藏的IFrame,然後通過Form向 其中POST/GET資料,或者直接修改IFrame的src屬性以達到傳輸資料的效果。如果在IFrame中的頁面裡寫JS代碼,就能通過 window.parent.XXX來調用父級頁面的對象或方法,并可以通路整個DOM(當然跨Frame操作的話需要兩張頁面在同一個Domain中, 這個就是IFrame sandbox,如果了解Windows Live Gadget和Windows Live Spaces Gadget的人就能體會到在安全性友善IFrame起的重要作用)。
當時發現,隻要改變IFrame裡的位址,不論是 POST/GET還是改變其src屬性,大都會在浏覽器的History裡留下痕迹。這是如果使用者點選浏覽器的Back按鈕,則會從IFrame裡的 History裡Load以前的頁面,當然也會按照那張頁面的邏輯解釋執行其中的JS代碼。善于利用這點的話,就會産生父頁面Back/Forward的 效果。可惜當時沒有去想這一點,而且當時因為某些問題,使用者點選Back/Forward時反而會産生異常的行為,甚是麻煩。
與 POST/GET相比,改變IFrame的src屬性相對簡單,也容易操作。但是在IE重要注意的是,并不是任意修改src時都會使IFrame被加入 History。修改src的時候其實改變了IFrame裡的location。location是window的一個屬性,它分幾個部分,這裡需要提到 的就是它的href,search和hash。舉個例子,對于一個location“[url]http://www.sample.com?a=b&c=[/url] d#hello”來說,location.href是“[url]http://www.sample.com[/url]”,location.search是“?a= b&c=d”(可以看出,search其實就是Query String),location.hash是“#hello”。在IE中改變location.hash是不會影響History的,是以隻有改變 href與search才行。在FireFox中,改變hash值是可以影響浏覽器的History,但是點選Back/Forward并不會使浏覽器重 新執行頁面中的JS代碼。
解決Back/Forward的大緻方向有了,那麼Bookmark呢?應該很容易将問題變成,如何要改變浏覽器位址欄的值,但是不重新整理頁面。還好我們有hash。所有的辨別都要通過hash值來傳遞。
到現在為止,應該已經能夠實作了在不重新整理頁面時改變浏覽器的History紀錄,但是如何在使用者點選Back/Forward的時候也改變頁面内容呢?我們将這個問題分為兩部分考慮,依次解決:
在使用者點選浏覽器的Back/Forward或者選擇History中某一項時改變浏覽器的位址欄資訊。
根據位址欄資訊的改變得到資訊,然後修改頁面的内容。 對于第1個問題,在FireFox下很容易解決,因為其實這是浏覽器已經支援的功能。如果是IE,我們隻能通過IFrame裡的頁面來改變父視窗的 hash了。第2個問題比較麻煩,因為改變浏覽器的hash值并不會觸發一個事件,是以迄今為止似乎所有的解決方案都是使用timer來不停地查詢 hash值有沒有改變。我的解決方案也不例外。
解釋完了思路與設計,就該轉向真正的實作了吧。
實作與分析
Default.aspx
1

<%@ Page Language="C#" %>
2
3
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
4
5

<script runat="server">
6
7
</script>
8
9
<html xmlns="http://www.w3.org/1999/xhtml" >
10
<head runat="server">
11
<title>Nav Fix</title>
12

<script language="javascript">
13
var historyObj = null;
14
var previousIndex = 0;
15
16
function onChange(select)
17
{
18
historyObj.addHistory(select.selectedIndex);
19
}
20
21
function setPageData(context)
22
23
var select = document.getElementById("select");
24
if (context)
25
{
26
select.selectedIndex = context;
27
var request = new Sys.Net.WebRequest();
28
request.set_url("Selection.ashx?s=" + select.selectedIndex);
29
request.completed.add(onComplete);
30
request.invoke();
31
}
32
else
33
34
select.selectedIndex = 0;
35
document.getElementById("message").innerHTML = "";
36
37
38
39
function onComplete(sender)
40
41
document.getElementById("message").innerHTML = sender.get_data();
42
43
44
function init()
45
46
historyObj = new Jeffz.Framework.History(setPageData, "NavFixHelper.htm");
47
historyObj.start();
48
49
</script>
50
</head>
51
<body style="font-family: Arial;">
52
<form id="form1" runat="server">
53
<div>
54
<atlas:ScriptManager ID="ScriptManager1" runat="server" EnableScriptComponents="true">
55

<Scripts>
56
<atlas:ScriptReference Path="js/History.js" />
57
</Scripts>
58
</atlas:ScriptManager>
59
60

<script type="text/xml-script">
61
<page xmlns:script="[url]http://schemas.microsoft.com/xml-script/2005[/url]">
62
<components>
63
<application load="init" />
64
</components>
65
</page>
66
</script>
67
68
<select onchange="onChange(this)" id="select">
69
<option></option>
70
<option value="1">selection 1</option>
71
<option value="2">selection 2</option>
72
<option value="3">selection 3</option>
73
</select>
74
<div id="message" style="font-size: 32px;"></div>
75
</div>
76
</form>
77
</body>
78
</html>
79
Selection.ashx
<%@ WebHandler Language="C#" Class="Selection" %>
using System;
using System.Web;

public class Selection : IHttpHandler {
public void ProcessRequest (HttpContext context) {
string value = String.Format(
"You select: <strong>selection {0}</strong>",
context.Request.QueryString["s"]);
context.Response.Write(value);
context.Response.End();
}
public bool IsReusable {
get {
return true;
}
首先Application在Load之後會立即調用init方法,構造一個Jeffz.Framework.History對象 historyObj,需要傳入更新資料的回調函數,還有為了IE單獨提供的NavFixHelper.htm檔案的路徑。然後調用對象的start方法 開啟對于hash值的監聽。對象還提供了一個stop來停止監聽。我提供這兩個方法的目的是為了能夠在需要時停止timer,友善調試。需要更新頁面内容 時,如果要保留History,那麼必須通過historyObj的addHistory來提供修改所需要使用的參數context。context可以 為任意對象,将會被序列化之後被放置在位址欄的hash中,然後在構造historyObj時傳入的回調函數(setPageData)會被執行, context會被作為參數傳入回調函數。使用historyObj時,對于頁面的修改都應該放在setPageData中。在使用者通過點選 Back/Forward Button或者直接選擇History的某一項時,位址欄中的hash會改變,回調函數會獲得從目前hash得到的context作為參數,将頁面更新 至之前的狀态。
在setPageData被調用時,<select />的選項會被更改,然後會使用Sys.Net.WebRequest向Selection.ashx發送請求。Selection.ashx根據 Query String的值來傳回資訊。然後Sys.Net.WebRequest對象在收到response後修改message的資訊。
在使用上就是這麼簡單。
History.js - Constructor
Jeffz.Framework.History = function(setDataCallback, helperPageUrl)

{
if (!setDataCallback || (typeof setDataCallback != "function"))
{
throw new Error("Please provide a callback function");
this.__setDataCallback = setDataCallback;
this.__currentHash = null;
this.__helperIFrame = null;
this.__helperPageUrl = helperPageUrl;
this.__runtimeTimer = null;
if (Sys.Runtime.get_hostType() == Sys.HostType.InternetExplorer)
if (!helperPageUrl)
throw new Error("Please provide the helper page for IE.");
else
var helperIFrame = document.createElement("iframe");
helperIFrame.style.display = "none";
document.body.appendChild(helperIFrame);
this.__helperIFrame = helperIFrame;
this.__reloadHelperIFrame(location.hash);
else
this.__currentHash = location.hash;
this.__execute(location.hash);
對于所有的私有成員,我都使用成員名前加上“__”的方法,加以區分。
首先,傳入的第一個參數setDataCallback必須是一個函數,否則将抛出異常。緊接着判斷浏覽器類型,如果是IE,則檢測必須提供 helperPageUrl,并且構造一個隐藏的IFrame來輔助實作history功能,最後通過 this.__reloadHelperIFrame方法來修改位址欄裡的hash。如果不是IE,那麼就直接将this.__currentHash設 成目前的hash加以記錄,并調用this.__execute函數将hash值構造成context對象,并執行回調函數 this.__setDataCallback。
History.js - __reloadHelperIFrame
Jeffz.Framework.History.prototype.__reloadHelperIFrame = function(hash)

this.__helperIFrame.document.title = document.title;
if (this.__helperIFrame.src && this.__helperIFrame.src.indexOf("?true") >= 0)
this.__helperIFrame.src = this.__helperPageUrl + "?false&" + document.title + hash;
this.__helperIFrame.src = this.__helperPageUrl + "?true&" + document.title + hash;
NavFixHelper.htm
<head>
<title>Untitled Page</title>
<body>

window.parent.location.hash = location.hash;
var queryString = location.search;
var index = queryString.indexOf("&");
document.title = queryString.substring(index + 1);
把__reloadHelperIFrame函數和NavFixHelper.htm頁面同時分析,是因為它們兩個有密不可分的關系。它們都是僅僅為了IE服務的。
在前面的解釋裡,我已經談到了改變iframe的Query String(search)可以将狀态添加至浏覽器的History中。于是__reloadHelperIFrame函數保證了新加載的Query String,和目前IFrame裡頁面的Query String不同(如果目前src包含了“&true”字元,那麼就加載包含“&false”的位址,否則就加載包含“& false”的位址)。接着在新加載的NavFixHelper.htm頁面中,會将父視窗的hash變成自己的hash。這樣,浏覽器位址欄裡的值就會 改變了。這樣,當浏覽器的Back/Forward Button被點選,或者使用者直接從History清單裡選擇一項時,IFrame裡的頁面會被重新加載,并且會使用特定的位址,頁面的JS代碼會被重新 執行,最終又将影響浏覽器位址欄裡的hash值。不過由于如果通路了其他頁面,History隻能記住最後一次IFrame裡的位址,是以無法使用 Back多次回退了。
每次加入History項,或者重新通路History時,都須要重新加載一次 NavFixHelper.htm,那麼會不會影響性能?答案是否定的。首先在加入History時,使用的href+search值隻會用兩種,是以浏 覽器會幫助cache檔案内容,第二次通路具有相同href+search的頁面時浏覽器将不再通路Server。而且在伺服器端,對于這樣的靜态檔案, 也會盡可能的進行cache,是以即使通路了Server也會獲得304(未改變,表示從緩存讀取)資訊,而不會從伺服器端重新下載下傳。再退一步,重新下載下傳 一個如此之小的檔案,也不會對性能有多少影響。
可以發現,在加載IFrame的頁面時,還将目前頁面的Title傳入了Query String。其作用就是避免Nikhil的解決方案中我舉出的第三個問題。NavFixHelper.htm會根據位址欄資訊修改自身的titile, 這樣在IE添加History項時,會紀錄IFrame裡頁面的title,這樣就保證了浏覽器History清單裡的title與浏覽器頁面相同。可是 這樣還沒有完美的解決Title問題,如果使用者想要修改頁面的Title,IE裡我有了解決方案,卻始終還沒有找出在FireFox下正确紀錄的方法,總 是在Back/Forward之中History裡的title就混亂了,似乎在FireFox裡Forword/Back記錄title的行為和IE不 一樣,讓我百思不得其解。
History.js - addHistory
Jeffz.Framework.History.prototype.addHistory = function(context)

historyEntry = { __p__ : context };
this.__reloadHelperIFrame("#" + encodeURI(Sys.Serialization.JSON.serialize(historyEntry)));
location.hash = encodeURI(Sys.Serialization.JSON.serialize(historyEntry));
雖然可以說addHistory方法是關鍵,但是其實卻非常的簡單,在IE下則使用this.__reloadHelperIFrame來加載頁面,否則就直接修改hash。hash的值使用的就是使用者傳入的context,序列化後,并經過encodeURI處理。
History.js - start & stop
Jeffz.Framework.History.prototype.start = function()

if (!this.__runtimeTimer)
var timer = new Sys.Timer();
timer.set_interval(300);
timer.tick.add(this.__onTimerTick);
timer.historyObj = this;
this.__runtimeTimer = timer;
if (this.__runtimeTimer.get_enabled())
throw new Error("The history object has been started.");
this.__runtimeTimer.set_enabled(true);
Jeffz.Framework.History.prototype.stop = function()

if (!this.__runtimeTimer || !this.__runtimerTimer.get_enabled())
throw new Error("The history object has not been started.");
this.__runtimeTimer.set_enabled(false);
這兩個函數非常簡單,本來不用解釋。唯一需要注意的是代碼的第8行,由于Sys.Timer使用的是使用setTimerout來回調執行 this.__onTimerTick,是以在this.__onTimerTick函數執行時,this并不是History對象本身,而是 window!還好Sys.Timer會通過sender傳遞自身給回調函數,是以我在構造Sys.Timer時将自身對象放進了Timer對象的 historyObj屬性中,則在this.__onTimerTick函數中可以從sender.historyObj中得到History對象。
History.js - __onTimerTick
Jeffz.Framework.History.prototype.__onTimerTick = function(sender, eventArgs)

try
if (location.hash != sender.historyObj.__currentHash)
sender.historyObj.__currentHash = location.hash;
sender.historyObj.__execute(location.hash);
catch(e)
sender.set_enabled(false);
sender.set_enabled(true);
throw e;
其實這個方法就是比較hash值有沒有改變,如果改變了,則保留新的hash,并加以執行回調函數(在__execute函數中)。可以發現,事實上回 調函數this.__setDataCallbak總是在在onTimerTick裡被執行的(除了在FireFox下構造History對象時),這樣 保證了this.__currentHash和目前頁面hash的統一。
值得注意的是,我使用了try...catch來保護了代 碼,因為__execute函數會調用使用者提供的回調函數,是以可能會抛出異常。如果__onTimerTick函數僅僅隻使用了現在的第5到第9行,那 麼等異常抛出時,Timer将會被終止,将不會繼續監視hash的改變。現在的做法是在遇到異常時停止Timer并重新啟動,然後重新抛出異常。這樣既保 證了Timer的正常執行,又能讓使用者發現自己代碼中的異常情況。
History.js - __execute
Jeffz.Framework.History.prototype.__execute = function(hash)

var historyEntry = null;
var historyEntry = Sys.Serialization.JSON.deserialize(decodeURI(hash.substring(1)));
this.__setDataCallback(null);
return;
if (historyEntry)
this.__setDataCallback(historyEntry.__p__);
這段我想就不用多加解釋了,似乎将一個簡單的調用寫得有些複雜。這樣做的目的是在hash值是錯誤的情況下,會将null作為參數,保證了使用者提供的回調函數被正确的使用。
問題與其他
就此,整個類分析完了,總共隻有短短一百多行代碼,是我參考了一些實作,并通過自己的經驗與思考得出的解決方案。目前似乎關于解決AJAX這個問題的解 決方案都有差不多的模式,也有差不多的障礙。這依舊不是一個完美的解決方案,依舊有改進的餘地。其實我這篇東西的作用隻是抛磚引玉,希望能引出更多優秀的 見解。我仍然在鑽研和實踐中,如果有什麼進展,我會第一時間的更新這裡的blog。
本文轉自 jeffz 51CTO部落格,原文連結:http://blog.51cto.com/jeffz/60959,如需轉載請自行聯系原作者