基于功能更丰富的基础类构建您自己的 ASP.NET 页面
Dino Esposito
Wintellect
适用范围:
Microsoft ASP.NET
Microsoft ASP.NET 2.0
摘要:通过继承可以在通用 Microsoft ASP.NET 类(例如 Page 类)中添加功能。这为您提供了一个公共场所,使您可以添加功能并将功能部署到所有页面上。在本文中,Dino 将向您介绍如何添加页面刷新处理、对冗长进程的支持以及使用 Page 类设置焦点控件。(本文包含一些指向英文站点的链接。请注意,在示例文件中,程序员的注释使用的是英文,本文中将其译为中文是为了便于读者理解。)
本页内容
构建功能更丰富的基础类 | |
检测浏览器刷新 | |
使用页面刷新事件 | |
使用户在冗长操作过程中获得愉快体验 | |
设置焦点控件 | |
结论 |
所有 Microsoft ASP.NET 页面均来* System.Web.UI.Page 类表示的通用基础页面。为了处理对 .aspx 资源的请求,ASP.NET 运行库将创建一个动态类,并使该类继承基础 Page 类,或继承其他反过来又继承基础 Page 类的类。如果在支持内含代码模型的 Microsoft Visual Studio .NET 2003 项目中创建页面,动态创建的 Page 类将继承内含代码类,而内含代码类反过来又继承基础 Page 类。
基础 Page 类实现典型的 ASP.NET 页面生命周期(加载-回发-渲染周期),并为衍生页面提供一组预定义的成员和功能,例如,回发检测、脚本插入、渲染和视图状态管理。
总而言之,System.Web.UI.Page 类只是一个基础类,可用来定义一组通用的、最基本的功能和行为。在特定的应用程序中,页面很可能针对更多功能,并提供更强大的编程接口。有两种可能的扩展方式:对页面基础结构进行一般意义上的增强,或者针对应用程序而改进功能。前一种扩展的示例是用来表示页面菜单和菜单项的属性。针对应用程序的页面通常是根据由静态区域、通用区域和可自定义区域组成的逻辑“母版”页设计的。这些区域的内容可能因页面而异,它们通常通过模板、占位符和用户控件进行填充。
请注意,在 Microsoft ASP.NET 2.0 中,母版页的引入大大简化了使用自定义和可编程属性和成员针对应用程序而创建页面的过程。
但是,如果您需要为页面创建功能更丰富、更复杂的基础结构,应该怎么办?如何指示所有页面提供其他系统级别的功能,例如,检测 F5(刷新)键的功能?实现给定技巧所需的代码始终可以与每个特定页面的操作代码(包含在内含代码类中的代码)合并在一起。但是,即使只实现了两三个功能,生成的代码的质量已经开始类似于大家唾弃的意大利面条式代码了,这是非常危险的。您必须寻找其他方法。
构建功能更丰富的基础类
一种更好的方法是创建新的基础 Page 类,用它来代替标准 System.Web.UI.Page 类。在本文中,我将介绍几项通用功能以及它们的一般实现,并将这些功能装入一个新的、功能更丰富的 Page 类中。我要介绍的功能包括:
• | 检测 F5(刷新)键。 |
• | 启动并控制一个需要立即向用户发送反馈页面的冗长操作。 |
• | 在加载页面时设置输入焦点。 |
如果您经常光顾专门介绍 ASP.NET 的新闻组和社区站点,而且阅读了大量文章、书籍和新闻稿,那么您可能已经知道如何在 ASP.NET 1.x 应用程序上下文中分别实现上述各项功能。这里的问题是,如何通过一个组件和一个入口点同时提供所有这些功能。
通过定义自定义 Page 类,您只需付出最少的努力便可在新的 .aspx 页面上提供所有其他功能和服务并获得最大回报。使用 Visual Studio .NET 2003 创建的内含代码页面的声明方式如下:
public class WebForm1 :System.Web.UI.Page{ :}
要使 Web 窗体类继承非默认的 Page 类,只需按照如下所示更改基础类型。
public class WebForm1 :Msdn.Page{ :}
如果页面不是在 Visual Studio .NET 2003 中创建的或者使用了内嵌代码,那么可以通过 @Page 指令中的 Inherits 属性为该页面设置基础类型。
<% @Page Inherits="Msdn.Page" ... %>
您可以按照此处所述逐个页面地更改 ASP.NET 页面的基础类型,也可以使用配置文件中的 <pages> 节点。
<pages pageBaseType="Msdn.Page" />
<pages> 节点的 pageBaseType 属性指示要用作所有动态创建的 Page 类的基础类型的类的名称和程序集。默认情况下,该属性在 machine.config 文件中被设置为 System.Web.UI.Page。您可以在应用程序的 web.config 文件中覆盖该设置。
下面让我们看看如何在实际应用中实现上述各项功能,以及如何将这些功能封装到一个无所不包的类中。
检测浏览器刷新
在几个月前发表在 aspnetPRO Magazine 上的文章中,我概括介绍了当用户按下浏览器中的 F5 键刷新当前页面时,对这一过程进行检测所需的操作步骤。页面刷新是浏览器对特定用户操作(按 F5 键或单击“刷新”工具栏按钮)的响应。页面刷新操作是浏览器内部的一种操作,因为浏览器不会为事件或回调发出任何外部通知。从技术上讲,页面刷新是通过“简单”重复最新请求来实现的。换句话说,浏览器将缓存已处理的最新请求,并在用户单击页面刷新键时重新发布已处理的请求。
正是因为所有浏览器(据我所知)不会为页面刷新事件提供任何类型的通知,所以服务器端的代码(例如,ASP.NET、典型 ASP 或 ISAPI DLL)根本无法区分刷新请求与一般的提交或回发请求。为了帮助 ASP.NET 检测和处理页面刷新,您需要构建能够使两个完全相同的请求看起来不同的环境机制。
浏览器通过重新发送上次发送的 HTTP 有效负载来实现刷新,并使副本看起来与原始版本不同(这是一项额外服务,需要添加额外的参数并且 ASP.NET 页面必须能够缓存这些参数)。下图提供了我要构建的子系统的详细视图。
图 1:为使刷新请求看起来与回发/提交请求不同而设置的机制
会话上下文中处理的每个请求获得一个唯一且递增的票证号码。ASP.NET 页面在生成响应之前生成票证,并将其存储在一个自定义的隐藏字段中发送给浏览器。当用户提交新请求(从而导致回发显示的页面)时,隐藏字段(如果有)将自动附着到服务器请求中。
在 Web 服务器上,新的 HTTP 模块将截取 AcquireSessionState 事件,从隐藏字段中检索当前票证,并将其与内部缓存的上次处理的票证 ID 进行比较。(上次处理的票证存储在会话状态中。)如果当前票证大于上次处理的 ID,或者如果这两个值都为零,则说明请求是一般的提交或回发。除此之外,刷新 HTTP 模块不会执行其他操作,并原封不动地传递请求。
如果上次处理的票证大于或等于当前票证,则将请求标识为页面刷新。在这种情况下,HTTP 模块将只在该请求的 HTTP 上下文的 Items 集合中创建一个新条目。在 ASP.NET 中,HttpContext 对象表示请求的上下文,并在请求的整个生命周期中始终存在。HttpContext 对象的 Items 属性是一个集合,可由 HTTP 模块、工厂处理程序和处理程序使用,用于将自定义信息转发给实际的页面对象。Items 集合中存储的所有内容对处理当前请求的过程中涉及到的所有组件均可见。这些信息的生命周期与请求的生命周期相同,因此,一旦生成响应,所有数据都将被销毁。通过使用 HttpContext.Current 静态属性,可以从该过程中涉及到的任何类访问当前请求的 HTTP 上下文。
刷新 HTTP 模块将在 Items 集合中创建名为 IsPageRefreshed 的新条目。该条目的布尔值表明是通过一般的提交/回发请求页面还是通过刷新请求页面。下面的列表显示了刷新 HTTP 模块的实现。
using System;using System.Web;using System.Web.SessionState;namespace Msdn{public class RefreshModule :IHttpModule {// IHttpModule::Initpublic void Init(HttpApplication app) {// 注册管道事件app.AcquireRequestState += new EventHandler(OnAcquireRequestState); }// IHttpModule::Disposepublic void Dispose() {}// 确定是否正在处理 F5 或后退/前进操作private void OnAcquireRequestState(object sender, EventArgs e) {// 访问 HTTP 上下文HttpApplication app = (HttpApplication) sender;HttpContext ctx = app.Context;// 检查 F5 操作RefreshAction.Check(ctx);return; } }}
RefreshAction 类包含用来确定当前请求是否是页面刷新的逻辑。如果确定为页面刷新,HttpContext 的 Items 集合中将包含一个新条目:IsPageRefreshed 设置为 true。
public static void Check(HttpContext ctx){// 初始化票证字段EnsureRefreshTicket(ctx);// 读取会话中上次处理的票证(从会话中)int lastTicket = GetLastRefreshTicket(ctx);// 读取当前请求的票证(从隐藏字段中)int thisTicket = GetCurrentRefreshTicket(ctx);// 比较两个票证if (thisTicket > lastTicket || (thisTicket==lastTicket && thisTicket==0)) {UpdateLastRefreshTicket(ctx, thisTicket);ctx.Items[PageRefreshEntry] = false; }elsectx.Items[PageRefreshEntry] = true;}
隐藏字段和会话字段的名称在 RefreshAction 类中被设置为公共常量,并且可以在该类的外部使用。
应用程序页面如何利用此机制?什么时候检测页面刷新真正有用?HTTP 模块并不阻止任何请求,它只为最终 ASP.NET 页面添加更多信息以便处理请求。添加的信息包括表示页面刷新的布尔值。
使用页面刷新事件
Web 页的用户通常只执行几个操作,而且从某种程度上讲,执行这些操作时心情都很愉快。这些操作包括“后退”、“前进”、“停止”和“刷新”。但这些操作构成了一种 Internet 浏览器的标准工具包。截取以及细分这些操作可能会对普遍认可的 Internet 操作带来某种“局限性”。对用户可能产生负面影响。
另一方面,当用户刷新当前页面或退回到先前访问的页面时,会向服务器提交已处理过的请求,这有可能会打断应用程序状态的一致性。在这种情况下,也可能对应用程序产生负面影响。
请设想以下情况:
您通过 DataGrid 显示数据,并在每一行中提供一个按钮,供用户删除所表示的数据行。尽管这是很常见的做法(轻轻点击,即可删除当前应用程序中实现的数据),但这种做法极其危险。用户很容易由于失误而单击了错误的按钮,从而破坏数据的一致性,而且如果他们在删除(不管是有意还是无意)之后刷新页面,则很可能会删除第二个行。
当您刷新页面时,浏览器只重复上次发布的内容。从 ASP.NET 运行库的角度来看,只有一个新请求要处理。ASP.NET 运行库无法区分一般的请求和意外重复的请求。如果采取脱机工作的方式,并按内存中存储的 DataSet 中的位置删除记录,则很可能会多删除一条记录。如果上一个操作以 INSERT 结束,刷新页面更有可能会添加一条记录。
这些示例清楚地暴露出某些有争议的设计问题,但它们反映了完全可能的情况。那么,阻止页面刷新最好的方式是什么呢?
本文前面讨论的机制可以预处理请求,并确定是否正在刷新页面。这些信息通过 HttpContext 对象传递给页面处理程序。在页面中,开发人员可以使用以下代码检索这些数据。
bool isRefresh = (bool) HttpContext.Current.Items["IsPageRefreshed"];
但更好的做法是,如果使用自定义的、更有针对性的 Page 类,则可以将数据封装到一个更易于使用的属性中,即封装到 IsPageRefresh 属性中。
public bool IsPageRefresh {get {object o = HttpContext.Current.Items[RefreshAction.PageRefreshEntry];if (o == null)return false;return (bool) o; }}
通过使 Page 类继承新的、功能更丰富的基础类(本例中为 Msdn.Page),可以通过新属性了解发出请求的真正原因。以下示例显示了如何实现不应在页面刷新时重复的某个关键操作。
void AddContactButton_Click(object sender, EventArgs e) {if (!IsPageRefresh)AddContact(FName.Text, LName.Text);BindData();TrackRefreshState();}
仅当在不刷新页面时才添加新联系人,换句话说,仅当用户按照常规方式单击“Add-Contact”(添加联系人)按钮时才会添加联系人。上述代码片断中有一个很奇怪的 TrackRefreshState 方法,它的作用是什么呢?
该方法更新票证计数器,并确保新页面响应包含带有最新票证的隐藏字段。在本例中,通过将会话状态中存储的值递增一来获取下一个票证。(这里只是随便使用了会话状态,最好不要使用会话状态,而使用更具扩展性的提供程序模型,就像在 ASP.NET 2.0 中一样。)
但是,关于 TrackRefreshState 方法(这是有意命名的,以便于大家回想起更熟悉的 TrackViewState 方法),主要有一点要说明。通过调用该方法,除了可以添加其他信息外,还可以将带有当前请求票证的隐藏字段添加到页面响应中。如果没有隐藏字段(参见图 1),刷新机制将无法检测下一个回发是刷新还是提交。换句话说,通过在回发事件处理程序中调用 TrackRefreshState,使得系统知道您要跟踪该操作(而且只跟踪该操作),以确定是否为页面刷新。这样,您只跟踪可能会出错的页面刷新,而且并不是所有页面刷新都会在会话生命周期内发生。
要利用页面刷新功能,只需在 Microsoft Visual Studio .NET 项目中添加一个新页面,然后打开内含代码文件并将页面的基础类更改为 Msdn.Page。接下来,在您执行不应刷新的操作时调用 TrackRefreshState(Msdn.Page 类的新的公共方法)。使用新的布尔属性 IsPageRefresh 检查刷新状态。
使用户在冗长操作过程中获得愉快体验
有关如何在 Web 上跟踪特别耗时的操作这个问题,已经通过多篇文章和多次演讲为大家提供了各种解决方案。我所说的“耗时的操作”是指 Windows 窗体方案中通常需要进度栏的所有操作。在 Web 页上显示进度栏很容易出现问题。进度栏应该能够与服务器通信,以获取有助于更新进度的信息。此外,此操作不应通过回发或刷新元标记来完成,以免完全刷新页面。在任何情况下,均需要具备强大的动态 HTML 支持。
要使用户在冗长操作过程中获得愉快体验,相对简单的方法就是显示一个中间反馈页面,为用户显示一些等待消息,最好是带点动画。此页面完全与上下文无关,但无疑要比加载新页面之前在空白页面上长时间显示一个沙漏更有用。
要在冗长操作过程中显示一些反馈,有一种简单而有效的方法,它可以概括成以下几个步骤:
• | 一旦用户通过单击开始该任务,便将用户重定向到反馈页面。反馈页面必须知道实际执行任务的页面的 URL。此 URL(包括会话状态)可以通过查询字符串进行传递,也可以放置在可访问的数据存储中。 |
• | 开始加载反馈页面后,再重定向到工作页面。这种情况下,重定向是由页面的 onload Javascript 事件中的脚本完成的。浏览器加载并显示反馈页面,然后指向工作页面。页面开始执行冗长的任务,同时为用户显示反馈页面。 |
• | 根据需要,反馈页面可以很复杂并包括许多 UI 元素。它可以包含“请稍候...”消息或显示动画 GIF,或者借助某些动态 HTML 功能,显示某些看起来像是一个真正进度栏的内容。 |
我特意创建了一个 LengthyAction 类来帮助管理冗长任务的开始。
private const string UrlFormatString = "{0}?target={1}";public static void Start(string feedbackPageUrl, string targetPageUrl){// 准备反馈页面的 URLstring url = String.Format(UrlFormatString,feedbackPageUrl, targetPageUrl);// 将调用重定向到反馈页面HttpContext.Current.Response.Redirect(url);}
该类的特点是只有一个静态方法,即 Start。Start 方法获取反馈页面和目标页面(即执行任务的页面)的 URL。该方法将两个参数合并成一个 URL 并进行重定向。
反馈页面可以包含您希望的任何用户界面,但必须满足几个关键的要求。该页面必须能够检索工作页面的名称,并提供一个可能的自动机制,以便通过脚本重定向到工作页面。我定义了一个自定义的基础 Page 类,并将这些功能内置在该类中。这样做时,我必须进行一些假定。特别是,我的实现假定工作页面的名称使用大家熟知的属性名称 target 通过查询字符串进行传递。目标页面的名称存储在名为 TargetURL 的公共属性中。此外,反馈页面提供名为 GetAutoRedirectScript 的函数。此函数的目的是返回通过脚本实现重定向所需的脚本代码。
public string GetAutoRedirectScript() {return String.Format("location.href='{0}';", TargetUrl);}
为了使问题尽可能地简单,FeedbackBasePage 类还查找名为 Body 的通用 HTML 控件。这与您从以下标记中获取的完全一样。
<body runat="server" id="Body">
如果可以通过简单的方法为页面的正文标记编程,FeedbackBasePage 类将找到这种方法并自动添加 onload 属性;否则,您必须手动添加 onload 属性。要使反馈页面正常工作,此属性是必需的。
HtmlGenericControl body = FindControl(BodyId) as HtmlGenericControl;if (body != null)body.Attributes["onload"] = GetAutoRedirectScript();
最后提供给浏览器的标记代码如下所示。
<body onload="location.href='lengthyop.aspx'">
让我们看看使用本文中讨论的类实现冗长操作需要执行哪些步骤。
首先引用所需的程序集,然后为触发操作的单击按钮编写以下事件处理程序。
void ButtonLengthyOp_Click(object sender, EventArgs e) {LengthyAction.Start("feedback.aspx", "work.aspx");}
接下来,在项目中添加反馈页面。这是一个常规 Web 窗体页,您可以按照上文所述修改其 <body> 标记,并将基础类更改为 FeedbackBasePage。在您单击按钮开始进程之后以及显示结果之前,将显示反馈页面的用户界面,如下图所示。
图 2:冗长操作的顺序
在本例中,我使用了一种跨页回发,这对特别冗长的操作来说是一种更普遍的方案。但是,这引发了传递视图状态以及工作页面完成其任务通常所需的参数的问题。您可以使用工作页面的查询字符串将序列化的对象版本连接起来,或者将所有内容都存储在 ASP.NET 缓存或 Session 对象中。这种情况下不能使用 HTTP 上下文,因为该操作涉及多个 HTTP 请求,每个请求都有一个不同的项目集。
请注意,反馈页面的 URL 包含调用的某些细节,并且可能如下所示。
feedback.aspx?target=work.aspx?param1=123¶m2=hello
要隐藏这些细节,您可以定义一个自定义 HTTP 处理程序,并将其绑定到您认为更合适的虚拟 URL。该 HTTP 处理程序可以从缓存或会话状态中检索所需的信息(包括反馈页面和工作页面的名称)。
设置焦点控件
ASP.NET 2.0 提供了一个非常好的新功能,允许您指定首次显示页面时将哪个输入控件设置为焦点。这是一种灵活的功能,可以减少用户通过单击开始操作的负担,例如,在文本框中单击开始输入数据。
要将 HTML 组件指定为输入焦点,您需要一小段 Javascript 代码。首先声明一点:这不是尖端的火箭科学,您可以轻松地将这段 Javascript 代码作为内嵌代码添加到 <body> 标记的 onload 属性中。但是,在 Page 类上使用 SetFocus 方法确定服务器上的焦点控件的名称确实是前进了一大步。实际上,您可以在 ASP.NET 2.0 中使用以下代码。
void Page_Load(object sender, System.EventArgs e) {SetFocus("TheFirstName");}
当显示页面时,名为 TheFirstName 的输入控件将成为焦点。此方法便捷有效,但如何在 ASP.NET 1.x 中对其进行编码?
同样,实现此功能的技巧已为业界人士所熟知,也可以从 Google 中毫不费力地搜索到。但问题是,如何将其集成到基础 Page 类中以便重复使用。
让我们使用以下声明来扩展 Msdn.Page 基础类。
private string m_focusedControl;public void SetFocus(string ctlId) {m_focusedControl = ctlId;}
SetFocus 方法收集控件的 ID 并将其存储在内部成员中。在页面的 PreRender 事件中,调用另一个帮助程序函数以构建和插入 Javascript 代码。
private void AddSetFocusScript(){if (m_focusedControl == "")return;// 添加脚本以声明函数StringBuilder sb = new StringBuilder("");sb.Append("<script language=javascript>");sb.Append("function ");sb.Append(SetFocusFunctionName);sb.Append("(ctl) {");sb.Append(" if (document.forms[0][ctl] != null)");sb.Append(" {document.forms[0][ctl].focus();}");sb.Append("}");// 添加脚本以调用函数sb.Append(SetFocusFunctionName);sb.Append("('");sb.Append(m_focusedControl);sb.Append("');<");sb.Append("/"); // 按照这种方式断开,以避免误解...sb.Append("script>");// 注册脚本(名称区分大小写)if (!IsStartupScriptRegistered(SetFocusScriptName)) RegisterStartupScript(SetFocusScriptName, sb.ToString());}
Javascript 代码可以像动态字符串一样构建,并累积存储在 StringBuilder 对象中。下一步是将该字符串添加到页面输出中。在 ASP.NET 中,要在页面中添加一些客户端脚本代码,必须先在特定页面级别的集合中注册该代码。为此,Page 类提供了几个 RegisterXxx 方法。每个 RegisterXxx 方法将 Javascript 代码块添加到不同的集合中,以便插入到最终页面标记中的不同位置。例如,RegisterStartupScript 在窗体的关闭标记之前插入代码。而 RegisterClientScriptBlock 在窗体的打开标记之后插入脚本代码。重要的是,必须在脚本中包括 <script> 元素的两个标记。每个脚本块都由一个关键字标识,这样多个服务器控件可以使用同一个脚本块,而不会将它发送给输出流两次或多次。
在页面中,以下 Javascript 代码块被插入到窗体的关闭标记之前。这样,它将在初始化后启动时立即开始运行。
<form>:<script language=javascript>function __setFocus(ctl) { if (document.forms[0][ctl] != null) {document.forms[0][ctl].focus(); }}__setFocus('TheFirstName');</script></form>
通过在 Msdn.Page 类上使用 SetFocus 公共方法,您可以在页面代码的任何位置决定在浏览器中显示页面时将哪个控件作为输入焦点。更重要的是,您可以根据运行时条件和/或回发事件作出此决定。
结论
在面向对象的技术(例如 ASP.NET)中,主要优点之一是您可以广泛使用继承。通过继承和改进现有控件的公共接口,您可以很轻松地创建新的自定义服务器控件。在衍生类中,您可以替代虚拟方法,从而改变组件的内部行为。将这些面向对象的编程 (OOP) 原则应用于控件似乎非常自然,非常普遍,但对于表示 ASP.NET 页的类,情况则不尽然。
不过,页面继承广泛用于构建所请求的每个 .ASPX 页面的可执行表示。内含代码页面是指从基础 System.Web.UI.Page 类继承的页面。为什么不定义一个中间 Page 类,为应用程序特定的页面提供功能更丰富的基础呢?
这正是本文所阐述的问题。我在本文中介绍了许多开发人员或多或少都能成功实现的三个常见功能,即检测刷新键、控制冗长操作以及将控件指定为输入焦点,还介绍了如何将这三个功能全部封装到一个无所不包的 Page 类的上下文中。
内含代码和内嵌代码应用程序中使用了新的 Page 类(Msdn.Page 类)来取代基础 Page 类,这个新类以方便且可重复使用的方式为开发人员提供了更多基本功能。功能更丰富的基础 Page 类是为 ASP.NET 应用程序构建更可靠平台的一个里程碑。实际应用程序的所有页面都应从自定义类开始构建,以此来验证 Page 类的功能。
参考资料