Atlas 实现机制浅析

时间:2023-01-19 04:48:58
 

Atlas 实现机制浅析 [1]

[0] 概述

上周 MS 发布了面向 ASP.NET 的 AJAX 框架 —— Atlas 最新 CTP 2006.1 预览版,  ScottGu 在其 blog 上做了较为详细的介绍。

New Atlas Build Available for Download with ASP.NET 2.0

更详细的更新说明,可以参考  nikhilk 的一篇 blog

Atlas M1 Refresh - Some More Goodies

关于 Altas 的概况介绍和简单实例,可以参考  nikhilk 在 PDC05 上的一个讲义和实例

Atlas Presentation Slides and Demos

详细的介绍和使用方法,可以参考 atlas.asp.net 网站,以及相关的  quickstart 教程,这里就不再罗嗦了。

有兴趣做试验的朋友,可以下载  ScottGu 以前写的一个例子,后面的很多分析也将在这个例子的基础上进行。

Making a List, Checking it Twice (Cool Ajax Sample App with ASP.NET 2.0 and Atlas)

与 .NET 和 Java 平台下其它 AJAX 框架相比,Altas 最大的亮点就在于与 ASP.NET 现有机制的无缝融合。通过 VS.NET 集成开发环境,使用者可以在对 js 和 AJAX 不甚了解的情况下,以非常自然的方式使用到最先进的技术。此外直接在 js 一级提供 WebService 的调用支持,也大大降低了对 ws 技术的使用门槛。而 ASP.NET 中一直引以为豪的数据绑定等技术,也可以在 Altas 中无缝得到支持,让现有投资能够最大限度得到保护。从这些意义上来说,虽然 Altas 在 AJAX 理念上没有太多突破,但不失为一个强大且实用的 AJAX 框架,非常符合 MS 在技术运用上的一贯原则。

Altas 与 .NET 下其它 AJAX 框架的横行比较,可以参考这篇文章

Welcome to my comparison of AJAX frameworks for ASP.NET

[1] 整体结构

从整体结构上来看,Altas 的核心在于 <atlas:ScriptManager .../> 这个标签,所有支持 Altas 的页面都必须有且只有一个此标签,以引入 Altas 的基础架构支持。在此基础上,通过 <altas:UpdatePanel .../> 标签定义需要异步更新的范围,避免传统 Post Back 模式下的全页面刷新。而需要支持 AJAX 模式获取数据的控件,则可以通过 js 脚本和 xml 脚本两种方式定义,并由 Altas 框架进行动态 patch 以实现标准 web 控件的 AJAX 支持。此外就是 WebService 调用和数据绑定的支持机制,也是利用 Altas 框架的基础架构实现的。

1.1 ScriptManager

首先,ScriptManager 是一个容器,用户可以在 ScriptManager 标签下定义期望引用的其它 js 库,以及希望通过 js 直接调用的 WebService 服务。
例如在如下的定义中,ScriptManager 控件将保存对两个客户端 js 库和 ComplexService 服务的引用,并在页面 Render 的时候写入适当的支持代码。我们可以通过 ScriptManager.Scripts 和 ScriptManager.Services 属性访问类似定义。
以下内容为程序代码:

<atlas:ScriptManager runat="server" ID="UpdatePanel2" 
  EnableScriptComponents="True" EnablePartialRendering="True">
  <Scripts>
    <atlas:ScriptReference ScriptName="AtlasUIMap" />
    <atlas:ScriptReference Path="~/MyScripts/MyScript.js" />
  </Scripts>
  <Services>
    <atlas:ServiceReference Path="ComplexService.asmx" />
  </Services>
</atlas:ScriptManager>

其中 ScriptReference 非常简单,支持通过 ScriptName 或 Path 属性指定脚本。
ScriptName 指定 Altas 内建的库名称,在 FrameworkScript 类型中有具体定义。这个属性在有的文档和例子中,也直接称为 Name 属性,但最新的 Altas M1 中已改为 ScriptName。这个脚本类型将被通过 ScriptManager.ConvertFrameworkScriptToFileName 函数转换为对应的 js 文件名。
以下内容为程序代码:

public enum FrameworkScript
{
      Custom,
      AtlasUIDragDrop, // "AtlasUIDragDrop.js";
      AtlasUIGlitz, // "AtlasUIGlitz.js";
      AtlasUIMap // "AtlasUIMap.js";
}

如果直接使用 Path 则可以指定任意的用户自定义库。

此外还可以通过 ScriptReference.Browser 属性指定脚本适用于的浏览器,Altas 将根据客户端浏览器类型,自动选择加载合适的脚本。

而 ServiceReference 也非常类似,可以通过 Path 和 Type 属性指定 WebService 的 .asmx 路径和相关类型。如果 GenerateProxy 属性为 true 的话(缺省),则 ScriptManager 会为此服务自动生成 proxy 包装脚本;否则将依赖于后台的自动处理机制提供支持。具体的 WebService 实现原理,等后面进行分析时在详细解释。目前需要知道的是,如果打开 GenerateProxy 模式,则 Altas 会自动生成 proxy 包装脚本,并与 Scripts 中脚本一同在合适的时候写到页面。

除了 Scripts 和 Services 两类显式的元素外,ScriptManager 还提供 IScriptService 和 IScriptControl 两类接口实现对象的管理。
前者提供 Altas 自身的服务支持,例如用于提供诊断 API 的 ProfileScriptService 组件。
后者提供 Altas 服务端控件支持,例如用于服务端定时器的 TimerControl 控件。

所有这些涉及脚本的引用,都会在 ScriptManager.OnPagePreRenderComplete 事件中,调用 RenderXmlScript 方法写入到一个 xml 脚本中。
以下内容为程序代码:

<script src="ScriptLibrary/Atlas/Debug/Atlas.js" type="text/javascript">
...
<script type="text/xml-script">
<page xmlns:script="http://schemas.microsoft.com/xml-script/2005">
  <references>
    <add src="ScriptLibrary/Atlas/Debug/AtlasUIMap.js" />
    <add src="MyScripts/MyScript.js" />
    <add src="ComplexService.asmx/js" />
  </references>
  ...
</page>
  

值得注意的是,Altas 会自动根据 web.config 中 system.web/compilation 的配置,选择 Debug 或 Release 模式的脚本。Release 模式脚本删去了多余的空格等修饰负荷,少了一些调试方面的支持。如果希望对 Altas 的脚本直接进行修改,别忘了两个版本的代码进行同步。

此外,ScriptManager 是一个协调者,它自身维护了一些常用的状态,并会根据状态来切换 Altas 引擎的工作机制。
最常使用的是 EnablePartialRendering 和 EnableScriptComponents 属性。

EnablePartialRendering 属性决定是否启用局部重绘的模式。
传统的 Post Back 模式页面,在用户 submit 时会重绘整个页面,并导致浏览器显式的闪烁。而在基于 AJAX 技术的 Altas 框架中,可以通过 UpdatePanel 标签指定需要重绘的局部。这样一来页面在处理请求时,会首先根据 ScriptManager.IsInPartialRenderingMode 属性判断是否在重绘模式中。如果在重绘模式,则仅仅将需要重绘的 UpdatePanel 内容,返回给客户端浏览器,并由 Altas 自动进行内容的更新。通过这种模式,使用者可以在对代码几乎无需修改的情况下,直接享受到 AJAX 带来的客户端用户体验的提升。

EnableScriptComponents 属性决定是否启用 XML 脚本模式。
XML 脚本模式是 Altas 引入的基于 XML 的描述性组件定义模型,可以通过一组 XML 标签,定义页面中已有 Web 组件的 AJAX 行为,而无需对现有组件进行修改和调整。而且因为所有的行为都是由 Altas 引擎在客户端动态绑定,所以组件的目标也可不仅仅限于现有的 Web 组件。具体的介绍可以参考  Atlas XML Script。而对于某些特殊情况,例如 ASP.NET 2.0 中的 master 页面,可以通过此属性关闭 XML 脚本支持,以大幅度简化页面的功能,此时 Altas 会自动使用 AtlasRuntime.js (57K) 替换完整的 Atlas.js (174K) 脚本。

最后,在了解了 ScriptManager 的基本职责后,我们来看看它的实现。
以下内容为程序代码:

public interface IScriptControlContainer
{
  IScriptControl RegisterControl(Control control);


public class ScriptManager : System.Web.UI.Control, IScriptControlContainer, IPostBackDataHandler {...}

ScriptManager 是一个 Web 界面控件,可以直接在 VS.NET 的设计界面中进行调整;它实现了 IScriptControlContainer 接口以作为 IScriptControl 的容器,对注册到容器的不支持 IScriptControl 接口的类型,将自动建立 GenericScriptControl 进行包装;实现 IPostBackDataHandler 接口则是用于在 Post Back 时处理局部重绘的支持。

在重载的 Control.OnInit 方法中,将根据页面请求头中 delta 属性是否为 true 来判断,当前控件是否处于局部重绘中。如果是局部重绘模式,则关闭页面的 trace 模式,并接管页面 LoadComplete 事件,并最终根据每个 UpdatePanel 的重绘状态,返回实际的重绘结果。此外无论是否局部重绘,都会接管页面的 PreRenderComplete 事件,以便完成前面提到的 Altas.js 和 XML 脚本的输出。伪代码如下:
以下内容为程序代码:

protected override void OnInit(EventArgs e)
{
// 当不处于设计模式,且控件属于某个页面时
if (!DesignMode && (_page != null))
{
// 判断页面中是否只有一个 ScriptManager 实例,否则抛出异常

// 如果页面请求中 delta 属性为 true 则处于重绘模式
if (_page.Request.Headers["delta"] == "true"[img]/images/wink.gif[/img]
{
_inPartialRenderingMode = true; // 处于重绘模式
      _page.TraceEnabled = false; // 关闭 trace 支持
      
      // 根据每个 UpdatePanel 的重绘状态,返回实际的重绘结果
      _page.LoadComplete += new EventHandler(this.OnPageLoadComplete); 
}

// 完成前面提到的 Altas.js 和 XML 脚本的输出
_page.PreRenderComplete += new EventHandler(this.OnPagePreRenderComplete);
}
}

在重载的 Control.OnPreRender 方法中,将针对一系列约束条件进行检查。
首先,页面必须有 <form runat="server"> 标签,否则 ASP.NET 无法建立* form 供 Altas 接管相应 submit 事件。
其次,如果在局部重绘模式中,则不对页面提供 Post Back 后滚动位置的维护支持,打开此模式则抛出异常。
然后,如果在局部重绘模式中,则页面必须有 <head runat="server"> 标签,以便将名为 .atlas__delta 的 CSS style 挂靠在上面。不过这个限制似乎牵强了一点,目前还不知道为什么必须如此,待进一步分析。
实现的伪代码如下:
以下内容为程序代码:

protected override void OnPreRender(EventArgs e)
{
// 页面必须有 <form runat="server"> 标签,否则 ASP.NET 无法建立* form 供 Altas 接管相应 submit 事件
if (Page.Form == null)
throw new InvalidOperationException("Must have a <form runat=/"server/">"[img]/images/wink.gif[/img];

// 是否在局部重绘模式中
  if (EnablePartialRendering)
  {
   // 不对页面提供 Post Back 后滚动位置的维护支持,打开此模式则抛出异常
if (Page.MaintainScrollPositionOnPostBack)
throw new InvalidOperationException("MaintainScrollPostition is not supported on pages with partial rendering turned on."[img]/images/wink.gif[/img];

// 页面必须有 <head runat="server"> 标签,以便将名为 .atlas__delta 的 CSS style 挂靠在上面
if (Page.Header == null)
throw new InvalidOperationException("Must have a <head runat=/"server/">"[img]/images/wink.gif[/img];

// 名为 .atlas__delta 的 CSS style
    Style style = new Style();
style.Font.Name = "Lucida Console";
    Page.Header.StyleSheet.CreateStyleRule(style, null, ".atlas__delta"[img]/images/wink.gif[/img];
}
}

在页面重绘的准备工作完成后,OnPagePreRenderComplete 方法会被调用。

如果是在局部重绘模式中,则直接接管 Page 的 Render 方法。
否则将根据浏览器类型,以及是否启用 XML 脚本模式来选择加载合适的 Altas 核心脚本。
最后,如果存在任意一种脚本服务、控件或引用,则调用 RenderXmlScript 函数输出 XML 脚本。然后会输出客户端代理脚本或局部重绘模式的初始化脚本。
实现的伪代码如下:
以下内容为程序代码:

private void OnPagePreRenderComplete(object sender, EventArgs e)
{
// 是否在局部重绘模式中
if (_inPartialRenderingMode)
{  
// 接管 Page 的 Render 方法
    Page.SetRenderMethodDelegate(new RenderMethod(RenderPageCallback));
    return;
  }

// 获取客户端浏览器类型
string browser = _page.Request.Browser.Browser;

if (browser == "IE"[img]/images/wink.gif[/img]
RegisterFrameworkScript("AtlasCompat.js"[img]/images/wink.gif[/img];
else if(browser == "AppleMAC-Safari"[img]/images/wink.gif[/img]
RegisterFrameworkScript("AtlasCompat2.js"[img]/images/wink.gif[/img];

// 是否启用 XML 脚本模式
if (_effectiveEnableScriptComponents)
RegisterFrameworkScript("Atlas.js"[img]/images/wink.gif[/img];
else
RegisterFrameworkScript("AtlasRuntime.js"[img]/images/wink.gif[/img];

if (存在任意一种脚本服务、控件或引用)
{
StringWriter writer = new StringWriter(CultureInfo.InvariantCulture);

// 输出 XML 脚本
    writer.Write("<script type=/"text/xml-script/">"[img]/images/wink.gif[/img];
    RenderXmlScript(writer);
    writer.Write(""[img]/images/wink.gif[/img];

// 是否需要输出初始化脚本
string proxyScript = GetClientProxyScript();

if (proxyScript != null || _enablePartialRendering)
{
writer.Write("<script type=/"text/javascript/">"[img]/images/wink.gif[/img];

// 输出客户端代理脚本
if (proxyScript != null)
writer.Write(proxyScript);

// 输出局部重绘模式初始化脚本
writer.WriteLine("Web.WebForms._PageRequest._setupAsyncPostBacks(document.getElementById('" + _page.Form.ClientID + "'), '" + UniqueID + "');"[img]/images/wink.gif[/img];

writer.Write(""[img]/images/wink.gif[/img];
}

// 将上述脚本注册到页面的客户端脚本管理器
_page.ClientScript.RegisterStartupScript(typeof(ScriptManager), "ScriptManager", writer.ToString(), false);  
}
}

至此,我们对 Altas 的核心组件 ScriptManager 的大致结构已经有了初步的了解,接下来会针对基于 UpdatePanel 的局部重绘模式、基于 XML 脚本的声明式定义、基于 XMLHTTP 的后台数据通讯机制、以及 WebService 和 DataBinding 支持的实现机制进行分析。

Atlas 实现机制浅析 [2]

1.2 UpdatePanel 与局部重绘模式 (Partial Rendering Mode)

在上一节介绍 Altas 整体结构时曾经提到,可以在启用局部重绘模式的情况下,通过通过 <altas:UpdatePanel .../> 标签定义需要异步更新的范围。
我们知道,传统的 HTTP 协议应用场景中,客户端在用户点击 submit 提交 form 的时候,一个 GET/POST 请求被发送到后台服务器;服务器则根据 form 的 action 指定页面,调用相应的处理者返回 HTML 格式的文本;返回结果并最终由客户端在浏览器中绘制,通常导致浏览器一次明显的刷新。
这种模式从 Web 早期的 CGI 一直沿用到现在的 ASP.NET 中。其优点是简单易用且较为成熟,缺点则是刷新明显且速度慢。因为一个页面中可能大多数内容在此次请求中是无需改变的,而这部分冗余的内容在每次请求都会被反复传输。尤其是对一些交互性较强的页面,每个操作都沿用这个冗长的流程,响应速度和负载都是难以容忍的。期间大家也想过很多缓解方法,例如使用 iframe 等嵌入帧封装独立部件,或者在服务器端针对不同区域进行缓存等等,但因为都还是基于这个传统思路,无法从本质上解决问题。
而对遵循 AJAX 思想的 Altas 框架,则是大大迈出一步,真正实现按需出发进行重绘。
首先,页面在定义时可以根据逻辑被分成若干个更新区域,通过 <altas:UpdatePanel .../> 标签直接定义。
其次,Altas 将接管 ASP.NET 客户端的* Post Back 用 form,并针对局部重绘模式加入特定的参数。
然后,Altas 将接管 ASP.NET 服务器端的页面重绘方法。如果是在局部重绘模式下,则对客户端请求进行解析,并判断需要对那些区域进行重绘。可以通过在 UpdatePanel 中指定重绘条件,来避免不必要的重绘操作。
最后,重绘的结果会被封装成 XML 脚本,通过异步的 XMLHTTP 方式传递会客户端。客户端 Altas 引擎对返回内容进行解析后,更新到页面的相应控件上。

整个过程完全由 Altas 自动在后台完成,不会对前台页面造成刷新或其它的影响。

接下来我们来详细了解一下,Altas 是如何完成这一奇妙的功能。

首先,在 ScriptManager 启用局部重绘模式后(ScriptManager.EnablePartialRendering = true),可以通过 UpdatePanel 定义任意多个更新区域,例如:
以下内容为程序代码:

<atlas:UpdatePanel runat="server" ID="UpdatePanel1">
    <ContentTemplate>
        <strong><span style="text-decoration: underline">Shipping Address</span>:</strong>
            <br />
        <asp:Label ID="lblFirstLineShipping" runat="server" Font-Bold="False"></asp:Label><br />
        <asp:Label ID="lblSecondLineShipping" runat="server"></asp:Label><br />
        <asp:Label ID="lblThirdLineShipping" runat="server"></asp:Label><br />
    </ContentTemplate>
</atlas:UpdatePanel>

更新区域的实际内容,在 ContentTemplate 属性定义。UpdatePanel.ContentTemplate 是一个 ITemplate 接口类型的属性。ASP.NET 通过此接口来定义服务端控件与其子控件的关系,定义如下:
以下内容为程序代码:

[ParseChildren(true), PersistChildren(false)]
public class UpdatePanel : Control
{
[TemplateInstance(TemplateInstance.Single), PersistenceMode(PersistenceMode.InnerProperty)]
  public ITemplate ContentTemplate { get; set; }
}

而如果希望显式指定更新的触发条件,则可以通过 Triggers 属性定义,例如下列代码指定,只有在触发了 btnTrigger 按钮的 Click 事件后,才需要对 UpdatePanel2 区域进行重绘。
以下内容为程序代码:

<asp:Button runat="server" ID="btnTrigger" Text="Trigger" 
  OnClick="btnTrigger_Click" />
..            
<atlas:UpdatePanel runat="server" ID="UpdatePanel2" Mode="Conditional">
  <Triggers>
    <atlas:ControlEventTrigger ControlID="btnTrigger" EventName="Click" />
  </Triggers>
..
</atlas:UpdatePanel>

触发条件目前支持针对控件事件和内容的两类: ControlEventTrigger 和 ControlValueTrigger。所有触发条件都继承自 UpdatePanelTrigger 抽象类。
以下内容为程序代码:

public abstract class UpdatePanelTrigger
{
      internal UpdatePanelTrigger();
      protected internal abstract bool HasTriggered(Control ownerControl);
      protected internal virtual void Initialize(Control ownerControl);
      internal void SetOwner(UpdatePanelTriggerCollection owner);

      private UpdatePanelTriggerCollection _owner;
}

UpdatePanel 在调用 Initialize 进行初始化的时候,会调用每个 UpdatePanelTrigger 的 Initialize 方法。具体的实现可在此事件中,接管服务器框架的相应事件,或者保存服务器控件的当前值。值得注意的是,这里的通过 ControlEventTrigger.EventName 指定的是服务器端控件的事件名称,而不是 HTML 控件的事件名称。因此上述例子的按钮点击事件,名称是 Click 而不是 onclick。而在 Altas 进行服务器端局部重绘时,会询问每个 UpdatePanel 是否需要进行重绘。此时被检查的 UpdatePanel.RequiresUpdate 属性,实际上会调用每个 UpdatePanelTrigger 的 HasTriggered 方法,判断是否需要对此 UpdatePanel 进行重绘。伪代码如下:
以下内容为程序代码:

public class UpdatePanel : Control
{
protected internal virtual void Initialize()
{
    if (_triggers != null)
    {
      if (ScriptManager.GetCurrent(Page).IsInPartialRenderingMode)
      {
        _triggers.Initialize(this);
      }
    }
}
}
public sealed class UpdatePanelTriggerCollection : Collection<UpdatePanelTrigger>
{
internal void Initialize(Control ownerControl)
{
foreach(UpdatePanelTrigger trigger in this)
{
trigger.Initialize(ownerControl);

}
}

UpdatePanel.RequiresUpdate 判断是否需要重绘的代码与之基本类似。

而在 UpdatePanel.OnInit 事件中,则负责在局部重绘模式时,调用 ScriptManager.RegisterUpdatePanel 方法将自己注册到管理器中。然后注册 Page.InitComplete 事件,在 UpdatePanel.OnPageInitComplete 事件处理函数中,初始化自身。伪代码如下:
以下内容为程序代码:

public class UpdatePanel : Control
{
protected override void OnInit(EventArgs e)
{
if (!DesignMode)
{
// 如果没有指定 UpdatePanel 则抛出异常
if (string.IsNullOrEmpty(ID))
throw new InvalidOperationException("UpdatePanel controls must have an explicit ID."[img]/images/wink.gif[/img];
            
// 如果没有找到 ScriptManager 也抛出异常
   ScriptManager manager = ScriptManager.GetCurrent(this.Page);
  
   if (manager == null)
   throw new InvalidOperationException("An UpdatePanel requires a ScriptManager on the page..."[img]/images/wink.gif[/img];
  
   // 如果启用局部重绘模式,则将自己注册到管理器
if (manager.IsInPartialRenderingMode)
       manager.RegisterUpdatePanel(this);

// 页面初始化完成时对自身进行初始化
Page.InitComplete += new EventHandler(OnPageInitComplete);

// 处理模板控件相关事宜
// ...
}
}
 
private void OnPageInitComplete(object sender, EventArgs e)
{
// 仅在第一次初始化页面时对 UpdatePanel 进行初始化
   if (!Page.IsPostBack)    
      if (ScriptManager.GetCurrent(this.Page).EnablePartialRendering)
Initialize();

    _initialized = true;
  }
}

最后,如果 UpdatePanel 需要进行完整重绘时,Page 会调用 UpdatePanel 从 Web.UI.Control 重载来的 void Render(HtmlTextWriter writer) 和 void RenderChildren(HtmlTextWriter writer) 方法进行绘制。后者会根据是否启用重绘模式,用一个 <span/> 标签将 ContentTemplate 中的子内容保护起来,用户在最终更新内容时定位。

了解了 UpdatePanel 的使用和实现后,我们回过头来看看 ScriptManager 是如何使用它们的。

在上一节我们曾提到,ScriptManager 在 OnPagePreRenderComplete 事件中,根据当前状态决定是否写入局部重绘模式的初始化脚本。
以下内容为程序代码:

private void OnPagePreRenderComplete(object sender, EventArgs e)
{
// ...

if (存在任意一种脚本服务、控件或引用)
{
// ...

if (proxyScript != null || _enablePartialRendering)
{
writer.Write("<script type=/"text/javascript/">"[img]/images/wink.gif[/img];

// ...

// 输出局部重绘模式初始化脚本
writer.WriteLine("Web.WebForms._PageRequest._setupAsyncPostBacks(document.getElementById('" + _page.Form.ClientID + "'), '" + UniqueID + "');"[img]/images/wink.gif[/img];

writer.Write(""[img]/images/wink.gif[/img];
}

// ...  
}
}

_setupAsyncPostBacks(...) 函数调用会在客户端载入页面时,完成 Altas 局部重绘引擎的初始化设置工作。
以下内容为程序代码:

Web.WebForms._PageRequestManager = function() 
{
this._setupAsyncPostBacks = function(form, scriptManagerID, updatePanelIDs, asyncPostbackControlIDs) 
{
// 在 _PageRequest 对象中保存参数
    _form = form;
    _scriptManagerID = scriptManagerID;
    _updatePanelIDs = updatePanelIDs;
    _asyncPostbackControlIDs = asyncPostbackControlIDs;

    form._initialAction = form.action;
    
    _onsubmit = form.onsubmit;
    
    // 接管* ASP.NET 的 form 之 onsubmit/onclick 方法
    form.onsubmit = null;
    form.attachEvent('onsubmit', Function.createDelegate(this, this._onFormSubmit));
    form.attachEvent('onclick', Function.createDelegate(this, this._onFormElementClick));
    
    // 接管 ASP.NET 处理 Post Back 请求的函数
    _originalDoPostBack = window.__doPostBack;
    if (_originalDoPostBack) {
        window.__doPostBack = Function.createDelegate(this, this._doPostBack);
    }
  }
}

一般说来,ASP.NET 会在定义的 <form id="form1" runat="server"> 附近,增加一些处理 Post Back 的客户端代码,例如:
以下内容为程序代码:


    <form name="aspnetForm" method="post" action="MyLists.aspx" id="aspnetForm">
<div>
<input type="hidden" name="__EVENTTARGET" id="__EVENTTARGET" value="" />
<input type="hidden" name="__EVENTARGUMENT" id="__EVENTARGUMENT" value="" />
<input type="hidden" name="__LASTFOCUS" id="__LASTFOCUS" value="" />
<input type="hidden" name="__VIEWSTATE" id="__VIEWSTATE" value="TCJEqyt9uS7OFuMId6rlbgLv+36H71Efw5hFAgjKyJ42XauLO8blWV/ofWtkx9Pg+SZ76WA7NvDr+/KDLacJvcKBot564ONmv4RYIXk+6GzGtINC2f4d7VDQPQXyRXwxIavJZsBZGQUwabITF0mTGs9Cus01SoG/cg2ACWQa/uofvZfU1ocGCnmKuu1SLVs6u9Y/UMOMC6lNVJgWOv3CILth90llrrIPN7nCVJC4Xyq3+nSZhzoN0/Oo4Xz4JMjUBFsy7KyDPXEaDHQGQXyRuA==" />
</div>

<script type="text/javascript">
<!--
var theForm = document.forms['aspnetForm'];
if (!theForm) {
    theForm = document.aspnetForm;
}
function __doPostBack(eventTarget, eventArgument) {
    if (!theForm.onsubmit || (theForm.onsubmit() != false)) {
        theForm.__EVENTTARGET.value = eventTarget;
        theForm.__EVENTARGUMENT.value = eventArgument;
        theForm.submit();
    }
}
// -->


Altas 则通过接管上述几个客户端事件,在 ASP.NET 客户端脚本和服务端实现之间,增加了一个透明的代理层。

其中 _onFormSubmit 事件负责完成实际的局部重绘参数构建;_onFormElementClick 事件负责保存表单点击的额外信息,并传递回服务端;_doPostBack 事件则将自动的同步页面 Post Back 操作,重定向到异步的 _onFormSubmit 操作。

首先, _onFormSubmit 事件会针对各种状态进行判断
以下内容为程序代码:

Function.createDelegate = function(instance, method) {
    return function() {
        method.apply(instance, arguments);
    }
}

Web.WebForms._PageRequestManager = function() 
{
this.get_inPostBack = function() {
   return _request != null;
}

this._onFormSubmit = function() 
{
// 如果已经是 Post Back 模式,则直接返回不重复提交
  if (this.get_inPostBack()) {
if (window.event) {
     window.event.returnValue = false;
    }
    return false;
  }
  
  // 如果 form 有 onsubmit 事件处理函数,则对其进行包装
  var continueSubmit = true;
  
  if (_onsubmit) {
    continueSubmit = Function.createDelegate(this, _onsubmit);
  }
  
  if (!continueSubmit) {
    if (window.event) {
      window.event.returnValue = false;
    }
    return false;
  }
  
  // 如果表单的 action 和初始化时不同,则跳过数据处理
  var form = _form;
  if (form.action != form._initialAction) {
    return true;
  }

// 如果不启用异步的 post back 模式,则跳过数据处理
  if (!_postbackSettings.async) {
   return true;
  }
  
  // 数据处理
  // ...
}
}

然后,_onFormSubmit 事件会根据表单中每一个包含数据的元素,构建一个完整的请求参数表。
以下内容为程序代码:

Web.WebForms._PageRequestManager = function() 
{
this._onFormSubmit = function() 
{
// 针对各种状态进行判断
// ...

// 建立 StringBuilder 用于构建请求内容
var formBody = new Web.StringBuilder();
formBody.append(_scriptManagerID + '=' + _postbackSettings.panelID + '&');

for (遍历表单中每个元素)
{
// 处理 INPUT、SELECT 和 TEXTAREA 三类标记
// 将其 id 和 value,拼接成 id=value&id=value&id=value 类型的数据
}

// 如果有额外的输入信息,也添加到请求内容中,用户记录事件等信息
  if (_additionalInput) {
    formBody.append(_additionalInput);
    _additionalInput = null;
  }
  
  // 通过 XMLHTTP 异步发送请求
  // ...
}
}

最后,_onFormSubmit 将构造得到的请求内容,通过 XMLHTTP 的方式发送到服务端。
以下内容为程序代码:

Web.WebForms._PageRequestManager = function() 
{
this._onFormSubmit = function() 
{
// 针对各种状态进行判断
// ...

// 根据表单中每一个包含数据的元素,构建一个完整的请求参数表
// ...

// 构建 XMLHTTP 封装类 WebRequest 实例
  var request = new Web.Net.WebRequest();
  
  // 填充基本请求信息,delta=true 表示启用局部重绘模式,并且关闭缓存
  request.set_url(form.action);
  request.get_headers()['delta'] = 'true';
  request.get_headers()['Cache-Control'] = 'no-cache';
  request.set_timeoutInterval(90000);
  
  // 接管请求完成或超时的事件
  request.completed.add(Function.createDelegate(this, this._onFormSubmitCompleted));
  request.timeout.add(Function.createDelegate(this, this._onFormSubmitTimeout));
  
  // 提交构造得到的请求参数表
  request.set_body(formBody.toString());
  
  // 进入 post back 请求状态
  _request = request;
  this.raisePropertyChanged('inPostBack');
  request.invoke();
  
  if (window.event) {
      window.event.returnValue = false;
  }
  return false;
}
}

当后台异步请求完成时,Altas 脚本会对返回结果进行解析并更新页面。这部分的讨论等完成服务端的结果讨论后再详细展开。
对异步请求超时的情况,仅仅是终止 post back 状态,因为信息不足以判断如何进行处理。个人觉得这种处理思路过于草率了,至少应该提供一些信息,让使用者能通过 inPostBack 属性变化事件,了解到请求到底是成功还是超时。
以下内容为程序代码:

Web.WebForms._PageRequestManager = function() 
{
    this._onFormSubmitTimeout = function(sender, eventArgs) {
        _request = null;
        this.raisePropertyChanged('inPostBack');
    }
}    

_doPostBack 事件基本上就是 _onFormSubmit 的封装。它会根据 post back 请求事件的来源,判断是否需要启用异步 post back 模式。
以下内容为程序代码:

Web.WebForms._PageRequestManager = function() 
{
this._doPostBack = function(eventTarget, eventArgument) 
{
   _additionalInput = null;

   if (this.get_inPostBack()) {
     if (window.event) {
       window.event.returnValue = false;
      }
      return;
    }

// 根据事件来源,判断是否需要启用异步模式
    _postbackSettings = null;
    
    var postbackElement = findNearestElement(eventTarget);
    
    if (postbackElement) 
     _postbackSettings = getPostbackSettings(postbackElement);      
    else 
      _postbackSettings = createPostbackSettings(true, _scriptManagerID);
      
    // 对同步模式,直接调用 ASP.NET 的 doPostBack 实现
    if (!_postbackSettings.async) {
     _originalDoPostBack(eventTarget, eventArgument);
      return;
    }

// 对异步模式,填充事件信息,并调用 _onFormSubmit 完成数据准备和提交操作
    var form = _form;
    form.__EVENTTARGET.value = eventTarget;
    form.__EVENTARGUMENT.value = eventArgument;
    this._onFormSubmit();
    
    if (window.event) {
      window.event.returnValue = false;
    }
  }
}

至此,我们基本上完成了对局部重绘模式下,从 UpdatePanel 到客户端数据提交的分析。下一节将继续针对局部重绘模式,分析服务端对此模式下刷新并返回数据实现,以及客户端如何对返回数据进行解析,并更新到最终页面的

Atlas 实现机制浅析 [3]

1.3 局部重绘模式的服务器端响应

在第一小节中,我们曾提到 ScriptManager 在重载的 Web.UI.Control.OnInit 事件中,会根据页面请求中 delta = true 是否存在,判断当前页面是否处于局部重绘模式中,并接管 LoadComplete 时间来处理此模式。相应的 OnInit 事件还会在局部重绘模式中,主动接管 Page.Render 方法的逻辑来替换完整页面刷新。
以下内容为程序代码:

protected override void OnInit(EventArgs e)
{
// 当不处于设计模式,且控件属于某个页面时
if (!DesignMode && (_page != null))
{
// 判断页面中是否只有一个 ScriptManager 实例,否则抛出异常

// 如果页面请求中 delta 属性为 true 则处于重绘模式
if (_page.Request.Headers["delta"] == "true"[img]/images/wink.gif[/img]
{
_inPartialRenderingMode = true; // 处于重绘模式
      _page.TraceEnabled = false; // 关闭 trace 支持
      
      // 根据每个 UpdatePanel 的重绘状态,返回实际的重绘结果
      _page.LoadComplete += new EventHandler(this.OnPageLoadComplete); 
}

// 完成前面提到的 Altas.js 和 XML 脚本的输出
_page.PreRenderComplete += new EventHandler(this.OnPagePreRenderComplete);
}
}

private void OnPagePreRenderComplete(object sender, EventArgs e)
{
// 是否在局部重绘模式中
if (_inPartialRenderingMode)
{  
// 接管 Page 的 Render 方法
    Page.SetRenderMethodDelegate(new RenderMethod(RenderPageCallback));
    return;
  }
  
  // ...
}  

在 OnPageLoadComplete 中,将遍历通过 RegisterUpdatePanel 注册到 ScriptManager 的所有 UpdatePanel,评估哪些区域是真正需要进行更新的 (UpdatePanel,评估哪些.RequiresUpdate = true),伪代码如下:
以下内容为程序代码:

private void OnPageLoadComplete(object sender, EventArgs e)
{
for(UpdatePanel panel in _allUpdatePanels)
{
if(panel 是 Page.Form 的子控件 && panel.RequiresUpdate)
{
panel.SetPartialRenderingMode(true);
_updatePanels.Add(panel1);
}
  }
}  

而 RenderPageCallback 中,则将取代 Page.Render 的原本逻辑,根据整理出的 _updatePanels 列表中的区域进行重绘。返回的内容将是一个 XML 格式的文档,包括重绘的内容(<rendering> Atlas 实现机制浅析、重绘的区域(<deltaPanels> Atlas 实现机制浅析以及相关 XML 脚本(<xmlScript> Atlas 实现机制浅析等。实现的伪代码如下:
以下内容为程序代码:

private void RenderPageCallback(HtmlTextWriter writer, Control pageControl)
{
  Page page = (Page) pageControl;
  HttpResponse response = page1.Response;
  
  // 关闭 HTML 缓存,设置返回文档类型为 text/xml
  response.Cache.SetCacheability(HttpCacheability.NoCache);
  response.ContentType = "text/xml";
  
  // 输出 HTML 头内容
  writer.Write("<delta><rendering>"[img]/images/wink.gif[/img];
  page.Header.RenderControl(writer);
  
  // 输出 Form 成员的内容
  HtmlForm form = page.Form;
  form.SetRenderMethodDelegate(new RenderMethod(this.RenderFormCallback));
  form.RenderControl(writer);
  
  writer.Write("</rendering>"[img]/images/wink.gif[/img];
  
  // 输出重绘 UpdatePanel 的 ID 列表
  writer.Write("<deltaPanels>"[img]/images/wink.gif[/img];  
  for (UpdatePanel panel in _updatePanels)
  {
   // 添加逗号分隔符
  
   writer.Write(updatePanels.ClientID); 
  }
  writer.Write("</deltaPanels>"[img]/images/wink.gif[/img];
      
  // 输出 XML 脚本,如引用等
  writer.Write("<xmlScript>"[img]/images/wink.gif[/img];
  RenderXmlScript(writer);
  writer.Write("</xmlScript>"[img]/images/wink.gif[/img];
  writer.Write("</delta>"[img]/images/wink.gif[/img];
}

实际的针对控件的重绘逻辑,在 RenderFormCallback 中完成。此函数将针对 _updatePanels 中保存的需要进行重绘的区域,调用其 RenderControl 方法绘制整个子控件树。如果 Page.EnableEventValidation 选项打开,还会通过一个空 HtmlTextWriter 来模拟调用所有的控件输出,来模拟完整的事件引发流程。但其输出的内容被直接抛弃,避免冗余内容通过网络传输。完整的伪代码如下:
以下内容为程序代码:

private void RenderFormCallback(HtmlTextWriter writer, Control containerControl)
{
for (UpdatePanel panel in _updatePanels)
{
panel.RenderControl(writer);
}

if (Page.EnableEventValidation)
{
DummyHtmlTextWriter writer = new DummyHtmlTextWriter();

for (Control control in containerControl.Controls)
{
control.RenderControl(writer);
}
}
}

而在客户端浏览器中,依照上节中的分析,后台更新请求将通过 Web.Net.WebRequest 的封装,以 XMLHTTP 方式发送给页面;处理结果将由 request 对象上注册的 _onFormSubmitCompleted 事件进行解析。
_onFormSubmitCompleted 事件中,首先会对请求的返回状态进行检测,如果出错则进入错误模式并返回;如果返回正常,则先检查请求返回值是否是重定向命令,是则刷新窗口到新地址并返回;然后会根据前面提到的 deltaPanels 标签中 ID 列表,调用 _updatePanel 函数分别对每个区域进行更新;最后,会对隐藏的 input 域、页面标题、HTML 头中的 css 以及 XML 脚本等特殊标签进行处理。伪代码如下:
以下内容为程序代码:

_onFormSubmitCompleted = function(sender, eventArgs) 
{
var isErrorMode = true; // 是否处于错误模式
  var response = sender.get_response();
  var delta; // 实际返回的更新内容
  
  // 请求成功则对返回内容进行解析
  if (response.get_statusCode() == 200) 
  {
if(delta = response.get_xml())
    {
     // 对 IE 浏览器来说,选择 XPath 作为解析语言
      if (Web.Application.get_type() == Web.ApplicationType.InternetExplorer) 
delta.setProperty('SelectionLanguage', 'XPath');
                
      // 返回内容中如果有 pageError 节点则说明服务器端处理出现异常
      if (errorNode = delta.selectSingleNode("/delta/pageError"[img]/images/wink.gif[/img])
        isErrorMode = false;
    }
  }
  
  // 如果发生错误则进入错误模式
  if (isErrorMode) 
  {
    _enterErrorMode(errorNode ? errorNode.attributes.getNamedItem('message').nodeValue : 'Unknown error');
    return;
  }
  
  // 如果有页面重定向命令则重定向窗口
  if (redirectNode = delta.selectSingleNode("/delta/pageRedirect"[img]/images/wink.gif[/img])
  {    
    window.location = redirectNode.attributes.getNamedItem('location').nodeValue
    return;
  }
  
  for(遍历 delta.selectSingleNode("/delta/deltaPanels/text()"[img]/images/wink.gif[/img] 中每个节点)
  {
   _updatePanel(deltaPanelID, 目标区域);
  }
  
  for(遍历 delta.selectNodes('/delta/rendering//input[@type="hidden"]') 中每个隐藏 input 域)
  {
   // 向 page.form 中插入新的隐藏域
  }
  
  // 如果有 title 节点则修改文档标题
  var title = delta.selectSingleNode('/delta/rendering//title/text()')
  document.title = title ? title.nodeValue.trim() ? ';
  
  // 如果有 style 节点则更新 css
if (styleSheetMarkup = delta.selectSingleNode('/delta/rendering/head/style[position()=last()]'))  
    _updateStyleSheet(styleSheetMarkup.text);
        
  // 如果有脚本节点则更新脚本,否则调用 _onFormSubmitCompletedCallback 完成解析
  if (scripts = delta.selectNodes('/delta/rendering//script[@type="text/javascript"]'))
_updateScripts(scripts);
else 
    _onFormSubmitCompletedCallback();        
}

这里对异常的处理,是 Altas M1 版本新增的功能。在前面所分析的 RenderPageCallback 方法中,通过一个 try...catch 将完整的局部重绘页面操作保护起来。如果有异常发生,则调用 OnPageError 事件进行实际处理,并最终通过 OnError 方法将异常信息返回给调用客户端。伪代码如下:
以下内容为程序代码:

private void RenderPageCallback(HtmlTextWriter writer, Control pageControl)
{
// 设置返回内容格式类型等

writer.Write("<delta>"[img]/images/wink.gif[/img];
  
  try
  {
   // 局部重绘页面操作
}
catch(Exception e)
  {
    OnPageError(e);
  }
  
  writer.Write("</delta>"[img]/images/wink.gif[/img];
}

private void OnPageError(Exception ex)

PageErrorEventArgs args = new PageErrorEventArgs(ex);
  OnPageError(args);
  ScriptManager.OnError(args.ErrorMessage, _page.Server, _page.Response);
}

private static void OnError(string errorMessage, IHttpServerUtility httpServer, IHttpResponse response)
{
  httpServer.ClearError();
  response.Clear();
  response.Cache.SetCacheability(HttpCacheability.NoCache);
  response.ContentType = "text/xml";
  response.Write("<delta>"[img]/images/wink.gif[/img];
  response.Write("<pageError message=/"" + HttpUtility.HtmlAttributeEncode(errorMessage) + "/" />"[img]/images/wink.gif[/img];
  response.Write("</delta>"[img]/images/wink.gif[/img];
}

而在 ScriptManager 中,可以通过 ErrorTemplate 标签定义异常信息的显式模板,类似
以下内容为程序代码:

<atlas:ScriptManager runat="server" ...>
  <ErrorTemplate>
    There was an error processing your action.<br />
    <span id="errorMessageLabel"></span>
    <hr />
    <button type="button" id="okButton">OK</button>
  </ErrorTemplate>
</atlas:ScriptManager>

而 ScriptManager.RenderErrorTemplate 方法会根据模板内容,生成名称为 __ErrorContainer 的 HTML 元素,并最终在客户端解析返回值的 _enterErrorMode 函数中进行更新。

而对重绘区域进行更新的 _updatePanel 函数,将根据 deltaPanels 中给出的 ID,定位到目标的更新区域 span 标签。并将其所有子控件进行析构 (dispose) 和删除 (removeChild),并用 rendering 中返回的内容替换之。
以下内容为程序代码:

_updatePanel = function(panelID, rendering) 
{
  var updatePanelElement = document.getElementById(panelID);

  var elementsToDestroy = [];
  var childCount = updatePanelElement.children.length;
  
  for (var i = 0; i < childCount; i++) 
  {
  elementsToDestroy.add(updatePanelElement.children[i]);
  }

for (var j = 0; j < elementsToDestroy.length; j++) 
{
   if (elementsToDestroy[j].control) 
     elementsToDestroy[j].control.dispose();
            
updatePanelElement.removeChild(elementsToDestroy[j]);
  }

  updatePanelElement.innerHTML = rendering;
}


除了上述异常处理的流程外,Altas M1 还在 OnInit 方法中接管了局部重绘模式下的 IHttpContext.ApplicationInstance 对象的 PreSendRequestHeaders 和 Error 事件,分别用于处理页面重定向和全局异常的情况。具体实现机制与上述异常处理机制较为类似,这里就不一一分析了。

至此,一个完整的 Altas 异步请求和局部重绘模式的流程就基本分析完成了,后面有时间将继续就 WebService 支持、数据绑定等实现进行分析,而其原理基本上都是基于之前两节所分析的模式,只不过根据具体的应用有所变化。