ASP.NET AJAX UpdatePanel 控件实现剖析
使用ASP.NET AJAX框架我们可以搭建快速响应、具有丰富的用户体验的AJAX Web应用程序,而该框架的UpdatePanel控件则提供了一种非常简单的方式来实现Web页面的局部更新,我们不需要在每次回发的时候都加载整个页面。
那这个控件是如何实现这种局部刷新的哪,透过其实现机制我们可以更清楚其优缺点,便于我们确定其使用场合。本文将重点阐述ASP.NET AJAX控件UpdatePanel的实现机制。
1. ASP.NET AJAX 简介 ASP.NET AJAX是微软在ASP.NET 2.0之上对AJAX技术的一个封装,为Web应用程序提供完整的AJAX解决方案。ASP.NET AJAX有两种编程模型:部分更新和远程服务。
部分更新使得用户可以用传统的ASP.NET 2.0应用程序的方式来搭建AJAX应用,具体就是使用UpdatePanel控件来实现无闪烁页面更新。而远程服务则是直接通过前端JavaScript来调用的服务器端服务,前段获取数据后,进行页面更新,这就要求服务器端代码必须分解为特定于应用程序的服务,这是与传统的ASP.NET应用程序完全不同的体系结构。
部分更新着重于对现有应用程序进行渐进式增强,帮助用户逐渐转换到纯粹的AJAX应用。本文主要对部分更新编程模型中核心控件UpdatePanel的实现进行剖析,讲述其背后的故事。
ASP.NET AJAX框架分为客户端以及服务器端两个部分,基于客户端的 Microsoft AJAX Library包含了对浏览器兼容性、网络访问以及客户端控件组件等支持, 而服务器端则包括了服务器控件,Web 服务等等。
见下图:
Microsoft Ajax Library就是ASP.NET AJAX的客户端脚本库,其中MicrosoftAjax.js包含了ASP.NET AJAX的核心内容,包括跨浏览器的支持、基于面向对象对JavaScript的扩展以及网络访问等等。MicrosoftAjaxWebForm.js文件则是完全服务于ASP.NET AJAX页面局部更新这样一个功能的,在该文件中定义了一个客户端对象PageRequestManager,该对象将会负责客户端异步回送的全过程。
2. ScriptManager 和 UpdatePanel ScriptManager和UpdatePanel是ASP.NET AJAX服务器端中最重要的两个控件,ScriptManager控件用来管理ASP.NET页面中的客户端脚本,生成及注册所需要的客户端脚本,通过UpdatePanel控件可以更新页面的指定部分而无需加载整个页面。
看个例子:
<form id="form1" runat="server"> <asp:ScriptManager ID="ScriptManager1" runat="server"> </asp:ScriptManager> <div> <asp:UpdatePanel ID="UpdatePanel1" runat="server"> <ContentTemplate> <%= DateTime.Now %> <br /> <asp:Button ID="Button1" runat="server" Text="Button" /> </ContentTemplate> </asp:UpdatePanel> </div> </form>
构建如上代码所示的页面,在Runtime点击UpdatePanel中的Button控件,则不会引起整个页面刷新,只是用来显示当前时间的Label得到更新。
这是如何实现的哪?
3. ASP.NET AJAX部分呈现剖析
3.1 先从客户端讲起
看一下上面的示例代码在客户端的HTML代码, 这里只列出核心部分,其他全部隐去。
<script type="text/javascript"> //<![CDATA[ Sys.WebForms.PageRequestManager._initialize('ScriptManager1', document.getElementById('form1')); Sys.WebForms.PageRequestManager.getInstance()._updateControls(['tUpdatePanel1'], [], [], 90); //]]> </script> <div id="UpdatePanel1"> 7/25/2008 4:54:36 PM <br /> <input type="submit" name="Button1" value="Button" id="Button1" /> </div>
看一下上面的两句JavaScript代码,第一句代码中的_initialize 方法是客户端PageRequestManager对象上的静态方法,它会创建一个 PageRequestManager 类的全局实例,并将其初始化。在这个初始化函数中,ageRequestManager对象注册了当前表单对象的submit事件,以及window对象的load和unload事件。
而第二句代码则是通过PageRequestManager的getInstance方法来检索其唯一实例, 得到该实例后调用_updateControls方法来注册UpdatePanel以及其Trigger控件。
我们可以从MicrosoftAjaxWebForm.js文件中得到_updateControls方法的声明:
function Sys$WebForms$PageRequestManager$_updateControls(updatePanelIDs, asyncPostBackControlIDs, postBackControlIDs, asyncPostBackTimeout) {}
由其中第一个参数代表了当前页面上所有的UpdatePanel控件的ID集合,如果该UpdatePanel的ChildrenAsTrigger为True的话,应在ID前添加字符't',否则添加字符'f';而第二个参数是所有引发异步回送的控件ID;第三个参数是所有引发同步回送的控件ID;第四个参数设定了异步回送的Timeout时间,单位为秒。于PageRequestManager对象注册了当前表单的submit时间,所以每当页面有提交动作的时候,PageRequestManager对象就会介入,看一下PageRequestManager对象页面提交处理函数_onFormSubmit(evt)。
如果需要执行一次异步回送的话,会中止原有的普通浏览器会回发,代之使用XMLHttpRequest进行AJAX回发。在封装这个请求的时候,当前页面的所有字段以及视图状态都会被打包在请求中,另外还设置了这次Request的HTTP头:request.get_headers()['X-MicrosoftAjax'] = 'Delta=true';
在服务器端将会根据这个HTTP头标记来判定是否为一次AJAX异步回发。
_onFormSubmit(evt)函数代码:
function Sys$WebForms$PageRequestManager$_onFormSubmit(evt) { var continueSubmit = true; var isCrossPost = this._isCrossPost; this._isCrossPost = false; if (this._onsubmit) { continueSubmit = this._onsubmit(); } if (continueSubmit) { for (var i = 0; i < this._onSubmitStatements.length; i++) { if (!this._onSubmitStatements[i]()) { continueSubmit = false; break; } } } if (!continueSubmit) { if (evt) { evt.preventDefault(); } return; } var form = this._form; if (isCrossPost) { return; } if (this._activeDefaultButton && !this._activeDefaultButtonClicked) { this._onFormElementActive(this._activeDefaultButton, 0, 0); } if (!this._postBackSettings.async) { return; } var formBody = new Sys.StringBuilder(); formBody.append(encodeURIComponent(this._scriptManagerID) + '=' + encodeURIComponent(this._postBackSettings.panelID) + '&'); var count = form.elements.length; for (var i = 0; i < count; i++) { var element = form.elements[i]; var name = element.name; if (typeof(name) === "undefined" || (name === null) || (name.length === 0)) { continue; } var tagName = element.tagName; if (tagName === 'INPUT') { var type = element.type; if ((type === 'text') || (type === 'password') || (type === 'hidden') || (((type === 'checkbox') || (type === 'radio')) && element.checked)) { formBody.append(encodeURIComponent(name)); formBody.append('='); formBody.append(encodeURIComponent(element.value)); formBody.append('&'); } } else if (tagName === 'SELECT') { var optionCount = element.options.length; for (var j = 0; j < optionCount; j++) { var option = element.options[j]; if (option.selected) { formBody.append(encodeURIComponent(name)); formBody.append('='); formBody.append(encodeURIComponent(option.value)); formBody.append('&'); } } } else if (tagName === 'TEXTAREA') { formBody.append(encodeURIComponent(name)); formBody.append('='); formBody.append(encodeURIComponent(element.value)); formBody.append('&'); } } if (this._additionalInput) { formBody.append(this._additionalInput); this._additionalInput = null; } var request = new Sys.Net.WebRequest(); var action = form.action; if (Sys.Browser.agent === Sys.Browser.InternetExplorer) { var queryIndex = action.indexOf('?'); if (queryIndex !== -1) { var path = action.substr(0, queryIndex); if (path.indexOf("%") === -1) { action = encodeURI(path) + action.substr(queryIndex); } } else if (action.indexOf("%") === -1) { action = encodeURI(action); } } request.set_url(action); request.get_headers()['X-MicrosoftAjax'] = 'Delta=true'; request.get_headers()['Cache-Control'] = 'no-cache'; request.set_timeout(this._asyncPostBackTimeout); request.add_completed(Function.createDelegate(this, this._onFormSubmitCompleted)); request.set_body(formBody.toString()); var handler = this._get_eventHandlerList().getHandler("initializeRequest"); if (handler) { var eventArgs = new Sys.WebForms.InitializeRequestEventArgs(request, this._postBackSettings.sourceElement); handler(this, eventArgs); continueSubmit = !eventArgs.get_cancel(); } if (!continueSubmit) { if (evt) { evt.preventDefault(); } return; } this._scrollPosition = this._getScrollPosition(); this.abortPostBack(); handler = this._get_eventHandlerList().getHandler("beginRequest"); if (handler) { var eventArgs = new Sys.WebForms.BeginRequestEventArgs(request, this._postBackSettings.sourceElement); handler(this, eventArgs); } if (this._originalDoCallback) { this._cancelPendingCallbacks(); } this._request = request; request.invoke(); if (evt) { evt.preventDefault(); } }
我们可以发现AJAX回发所提交的数据量其实跟普通回发过程中提交的数据量是一样的,并且还附加了一些额外信息。
3.2 服务器端的处理
AJAX回发请求到达服务器之后,当前页面的生命周期跟普通回发引起的请求是一样的,页面的Init、Load和Render等等事件都会被触发,差别只是在于AJAX回发使用了不同的呈现画法。
AJAX回发引起的请求生命周期:
从上图我们可以看到,页面的生命周期与普通回发是一样的,同样页面上的控件也会经历相应的生命周期。
先了解一下ScriptManager控件在服务器端的处理:
- OnInit:在Init事件中,ScriptManager控件会注册页面的InitComplete, PreRenderComplete以及PreRender事件,另外还会根据本次请求的HTTP头来设定一个标记以确定本次回发是否为Ajax异步更新所引起的回发。
见下面的代码:
protected internal override void OnInit(EventArgs e) { base.OnInit(e); if (this.EnableHistory) { this.RegisterAsyncPostBackControl(this); } if (!base.DesignMode) { IPage iPage = this.IPage; if (GetCurrent(this.Page) != null) { throw new InvalidOperationException(AtlasWeb.ScriptManager_OnlyOneScriptManager); } iPage.Items[typeof(IScriptManager)] = this; iPage.Items[typeof(ScriptManager)] = this; iPage.InitComplete += new EventHandler(this.OnPageInitComplete); iPage.PreRenderComplete += new EventHandler(this.OnPagePreRenderComplete); if (iPage.IsPostBack) { this._isInAsyncPostBack = PageRequestManager.IsAsyncPostBackRequest(iPage.Request.Headers); if (this.EnableHistory) { this._isNavigating = iPage.Request["__EVENTTARGET"] == this.UniqueID; } } this.PageRequestManager.OnInit(); iPage.PreRender += new EventHandler(this.ScriptControlManager.OnPagePreRender); } }
- OnPagePreRenderComplete,在PagePreRenderComplete事件中,ScriptManager控件会注册脚本文件以及Services代理脚本,MicrosoftAjax.js和MicrosoftAjaxWebForm.js就是在这个阶段被注册到客户端的。
见下面的代码:
private void OnPagePreRenderComplete(object sender, EventArgs e) { if (!this.IsInAsyncPostBack) { if (this.SupportsPartialRendering) { this.IPage.ClientScript.GetPostBackEventReference(new PostBackOptions(this, null, null, false, false, false, false, true, null)); } this.RegisterGlobalizationScriptBlock(); this.RegisterScripts(); this.RegisterServices(); if (this.EnableHistory) { JavaScriptSerializer serializer = JavaScriptSerializer.CreateInstance(); string[] strArray = new string[] { "\r\nSys.Application.setServerId(", serializer.Serialize(this.ClientID), ", ", serializer.Serialize(this.UniqueID), ");\r\n", ((this._initialState != null) && (this._initialState.Count != 0)) ? (" Sys.Application.setServerState('" + this.GetStateString() + "');\r\n") : "\r\n" }; string script = string.Concat(strArray); RegisterStartupScript(this, typeof(ScriptManager), "HistoryStartup", script, true); } } else { this.RegisterScripts(); if (this.EnableHistory) { if ((this._initialState != null) && (this._initialState.Count == 0)) { this._initialState = null; } if (this._newPointCreated) { this.RegisterDataItem(this, "'" + this.GetStateString() + "'", true); } } } }
- OnPreRender,在PreRender事件中如果判定本次回发为AJAX回发,则会调用PageRequestManager对象的OnPreRender方法。而PageRequestManager对象则会调用Page对象的SetRenderMethodDelegate方法来代理Page的画法,PageRequestManager对象会真正负责本次AJAX回发最终的HTML代码。
见下面的代码:
public class ScriptManager : Control, { protected internal override void OnPreRender(EventArgs e) { base.OnPreRender(e); if (this.IsInAsyncPostBack) { this.PageRequestManager.OnPreRender(); } } } internal sealed class PageRequestManager { internal void OnPreRender() { this._owner.IPage.SetRenderMethodDelegate(new RenderMethod(this.RenderPageCallback)); } }
PageRequestManager的RenderPageCallback方法最终处理了AJAX回发所需要的HTML代码,在这个方法中会遍历页面上所有涉及到的UpdatePanel控件,得到其更新后的HTML代码后,与隐藏字段还有一些额外信息一起打包,然后传递给客户端。
见下面的代码:
private void RenderPageCallback(HtmlTextWriter writer, Control pageControl) { this.ProcessUpdatePanels(); IHttpResponse response = this._owner.IPage.Response; response.ContentType = "text/plain"; response.Cache.SetNoServerCaching(); IHtmlForm form = this._owner.IPage.Form; form.SetRenderMethodDelegate(new RenderMethod(this.RenderFormCallback)); this._updatePanelWriter = writer; ParserStringWriter writer2 = new ParserStringWriter(); ParserHtmlTextWriter writer3 = new ParserHtmlTextWriter(writer2); writer2.ParseWrites = true; form.RenderControl(writer3); writer2.ParseWrites = false; foreach (KeyValuePair<string, string> pair in writer2.HiddenFields) { if (ControlUtil.IsBuiltInHiddenField(pair.Key)) { EncodeString(writer, "hiddenField", pair.Key, pair.Value); } } EncodeString(writer, "asyncPostBackControlIDs", string.Empty, this.GetAsyncPostBackControlIDs(false)); EncodeString(writer, "postBackControlIDs", string.Empty, this.GetPostBackControlIDs(false)); EncodeString(writer, "updatePanelIDs", string.Empty, this.GetAllUpdatePanelIDs()); EncodeString(writer, "childUpdatePanelIDs", string.Empty, this.GetChildUpdatePanelIDs()); EncodeString(writer, "panelsToRefreshIDs", string.Empty, this.GetRefreshingUpdatePanelIDs()); EncodeString(writer, "asyncPostBackTimeout", string.Empty, this._owner.AsyncPostBackTimeout.ToString(CultureInfo.InvariantCulture)); if (writer3.FormAction != null) { EncodeString(writer, "formAction", string.Empty, writer3.FormAction); } if (this._owner.IPage.Header != null) { string title = this._owner.IPage.Title; if (!string.IsNullOrEmpty(title)) { EncodeString(writer, "pageTitle", string.Empty, title); } } this.RenderDataItems(writer); this.ProcessScriptRegistration(writer); this.ProcessFocus(writer); }
3.3 客户端更新
当服务器端相应完毕后,客户端会得到响应信息,然后调用客户端对象PageRequestManager的_onFormSubmitCompleted方法来进行页面局部更新,最终会调用_updatePanel方法来更新UpdatePanel控件。
参见_onFormSubmitCompleted方法的代码:
function Sys$WebForms$PageRequestManager$_onFormSubmitCompleted(sender, eventArgs) { this._processingRequest = true; var delimitByLengthDelimiter = '|'; if (sender.get_timedOut()) { this._endPostBack(this._createPageRequestManagerTimeoutError(), sender); return; } if (sender.get_aborted()) { this._endPostBack(null, sender); return; } if (!this._request || sender.get_webRequest() !== this._request) { return; } var errorMessage; var delta = []; if (sender.get_statusCode() !== 200) { this._endPostBack(this._createPageRequestManagerServerError(sender.get_statusCode()), sender); return; } var reply = sender.get_responseData(); var delimiterIndex, len, type, id, content; var replyIndex = 0; var parserErrorDetails = null; while (replyIndex < reply.length) { delimiterIndex = reply.indexOf(delimitByLengthDelimiter, replyIndex); if (delimiterIndex === -1) { parserErrorDetails = this._findText(reply, replyIndex); break; } len = parseInt(reply.substring(replyIndex, delimiterIndex), 10); if ((len % 1) !== 0) { parserErrorDetails = this._findText(reply, replyIndex); break; } replyIndex = delimiterIndex + 1; delimiterIndex = reply.indexOf(delimitByLengthDelimiter, replyIndex); if (delimiterIndex === -1) { parserErrorDetails = this._findText(reply, replyIndex); break; } type = reply.substring(replyIndex, delimiterIndex); replyIndex = delimiterIndex + 1; delimiterIndex = reply.indexOf(delimitByLengthDelimiter, replyIndex); if (delimiterIndex === -1) { parserErrorDetails = this._findText(reply, replyIndex); break; } id = reply.substring(replyIndex, delimiterIndex); replyIndex = delimiterIndex + 1; if ((replyIndex + len) >= reply.length) { parserErrorDetails = this._findText(reply, reply.length); break; } content = reply.substr(replyIndex, len); replyIndex += len; if (reply.charAt(replyIndex) !== delimitByLengthDelimiter) { parserErrorDetails = this._findText(reply, replyIndex); break; } replyIndex++; Array.add(delta, {type: type, id: id, content: content}); } if (parserErrorDetails) { this._endPostBack(this._createPageRequestManagerParserError(String.format(Sys.WebForms.Res.PRM_ParserErrorDetails, parserErrorDetails)), sender); return; } var updatePanelNodes = []; var hiddenFieldNodes = []; var arrayDeclarationNodes = []; var scriptBlockNodes = []; var scriptStartupNodes = []; var expandoNodes = []; var onSubmitNodes = []; var dataItemNodes = []; var dataItemJsonNodes = []; var scriptDisposeNodes = []; var asyncPostBackControlIDsNode, postBackControlIDsNode, updatePanelIDsNode, asyncPostBackTimeoutNode, childUpdatePanelIDsNode, panelsToRefreshNode, formActionNode; for (var i = 0; i < delta.length; i++) { var deltaNode = delta[i]; switch (deltaNode.type) { case "updatePanel": Array.add(updatePanelNodes, deltaNode); break; case "hiddenField": Array.add(hiddenFieldNodes, deltaNode); break; case "arrayDeclaration": Array.add(arrayDeclarationNodes, deltaNode); break; case "scriptBlock": Array.add(scriptBlockNodes, deltaNode); break; case "scriptStartupBlock": Array.add(scriptStartupNodes, deltaNode); break; case "expando": Array.add(expandoNodes, deltaNode); break; case "onSubmit": Array.add(onSubmitNodes, deltaNode); break; case "asyncPostBackControlIDs": asyncPostBackControlIDsNode = deltaNode; break; case "postBackControlIDs": postBackControlIDsNode = deltaNode; break; case "updatePanelIDs": updatePanelIDsNode = deltaNode; break; case "asyncPostBackTimeout": asyncPostBackTimeoutNode = deltaNode; break; case "childUpdatePanelIDs": childUpdatePanelIDsNode = deltaNode; break; case "panelsToRefreshIDs": panelsToRefreshNode = deltaNode; break; case "formAction": formActionNode = deltaNode; break; case "dataItem": Array.add(dataItemNodes, deltaNode); break; case "dataItemJson": Array.add(dataItemJsonNodes, deltaNode); break; case "scriptDispose": Array.add(scriptDisposeNodes, deltaNode); break; case "pageRedirect": if (Sys.Browser.agent === Sys.Browser.InternetExplorer) { var anchor = document.createElement("a"); anchor.style.display = 'none'; anchor.attachEvent("onclick", cancelBubble); anchor.href = deltaNode.content; document.body.appendChild(anchor); anchor.click(); anchor.detachEvent("onclick", cancelBubble); document.body.removeChild(anchor); function cancelBubble(e) { e.cancelBubble = true; } } else { window.location.href = deltaNode.content; } return; case "error": this._endPostBack(this._createPageRequestManagerServerError(Number.parseInvariant(deltaNode.id), deltaNode.content), sender); return; case "pageTitle": document.title = deltaNode.content; break; case "focus": this._controlIDToFocus = deltaNode.content; break; default: this._endPostBack(this._createPageRequestManagerParserError(String.format(Sys.WebForms.Res.PRM_UnknownToken, deltaNode.type)), sender); return; } } var i; if (asyncPostBackControlIDsNode && postBackControlIDsNode && updatePanelIDsNode && panelsToRefreshNode && asyncPostBackTimeoutNode && childUpdatePanelIDsNode) { this._oldUpdatePanelIDs = this._updatePanelIDs; var childUpdatePanelIDsString = childUpdatePanelIDsNode.content; this._childUpdatePanelIDs = childUpdatePanelIDsString.length ? childUpdatePanelIDsString.split(',') : []; var asyncPostBackControlIDsArray = this._splitNodeIntoArray(asyncPostBackControlIDsNode); var postBackControlIDsArray = this._splitNodeIntoArray(postBackControlIDsNode); var updatePanelIDsArray = this._splitNodeIntoArray(updatePanelIDsNode); this._panelsToRefreshIDs = this._splitNodeIntoArray(panelsToRefreshNode); for (i = 0; i < this._panelsToRefreshIDs.length; i++) { var panelClientID = this._uniqueIDToClientID(this._panelsToRefreshIDs[i]); if (!document.getElementById(panelClientID)) { this._endPostBack(Error.invalidOperation(String.format(Sys.WebForms.Res.PRM_MissingPanel, panelClientID)), sender); return; } } var asyncPostBackTimeout = asyncPostBackTimeoutNode.content; this._updateControls(updatePanelIDsArray, asyncPostBackControlIDsArray, postBackControlIDsArray, asyncPostBackTimeout); } this._dataItems = {}; for (i = 0; i < dataItemNodes.length; i++) { var dataItemNode = dataItemNodes[i]; this._dataItems[dataItemNode.id] = dataItemNode.content; } for (i = 0; i < dataItemJsonNodes.length; i++) { var dataItemJsonNode = dataItemJsonNodes[i]; this._dataItems[dataItemJsonNode.id] = Sys.Serialization.JavaScriptSerializer.deserialize(dataItemJsonNode.content); } var handler = this._get_eventHandlerList().getHandler("pageLoading"); if (handler) { handler(this, this._getPageLoadingEventArgs()); } if (formActionNode) { this._form.action = formActionNode.content; } Sys._ScriptLoader.readLoadedScripts(); Sys.Application.beginCreateComponents(); var scriptLoader = Sys._ScriptLoader.getInstance(); this._queueScripts(scriptLoader, scriptBlockNodes, true, false); this._updateContext = { response: sender, updatePanelNodes: updatePanelNodes, scriptBlockNodes: scriptBlockNodes, scriptDisposeNodes: scriptDisposeNodes, hiddenFieldNodes: hiddenFieldNodes, arrayDeclarationNodes: arrayDeclarationNodes, expandoNodes: expandoNodes, scriptStartupNodes: scriptStartupNodes, onSubmitNodes: onSubmitNodes }; scriptLoader.loadScripts(0, Function.createDelegate(this, this._scriptIncludesLoadComplete), Function.createDelegate(this, this._scriptIncludesLoadFailed), null); }
4.结语
使用UpdatePanel是给已经存在的ASP.NET应用程序添加AJAX体验的最快捷方式,对于应用程序的架构也不会有影响,我们可以使用它来逐步的提高应用程序的用户体验。但是其性能与纯粹的AJAX方式相比较,还是比较差的。