65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET AJAX 控件和扩展器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.80/5 (34投票s)

2007 年 12 月 23 日

LGPL3

51分钟阅读

viewsIcon

322495

downloadIcon

4540

本教程将探讨 Visual Studio 2008 中的新服务器控件和服务器控件扩展器。作为技巧、窍门和陷阱的汇编,这是一份全面的教程,将为读者提供使用 Visual Studio 构建高级 AJAX 启用自定义控件所需的技能。

intro

目录

引言

当您打开 Visual Studio 2008 创建项目时,您会注意到它有两个新的 Web 模板,专门用于构建 AJAX 控件:ASP.NET AJAX 服务器控件和 ASP.NET AJAX 服务器控件扩展器。您还会发现一个老朋友,ASP.NET 服务器控件项目模板。

服务器控件、ASP.NET AJAX 服务器控件和 ASP.NET AJAX 扩展器之间有什么区别,应该何时使用它们?

templates

乍一看,ASP.NET 服务器控件似乎与其他两个控件不同,因为它不支持 AJAX。然而,这并非完全正确,在本教程的第一部分,我将演示仅基于服务器控件开发 AJAX 启用控件的程度。虽然 ASP.NET 服务器控件不提供对 AJAX 脚本的直接访问,但它可以实现封装在其他控件(如 UpdatePanel 或 AJAX 扩展的 Timer 控件)中的 AJAX 脚本,以提供 AJAX 功能。对于不太热衷于深入研究 JavaScript 的复杂性和陷阱的控件开发人员来说,服务器控件提供了一条优秀且干净的开发路径。

AJAX 服务器控件和 AJAX 服务器控件扩展器与常规 ASP.NET 服务器控件的区别在于,它们与 JavaScript 文件耦合,并允许控件类属性与 JavaScript 类属性之间进行映射。当您需要其他 AJAX 服务器控件未提供的功能,或者只想使用客户端脚本来自定义您的控件以避免 ASP.NET 控件生命周期时,这是最佳选择。

最后,虽然 AJAX 服务器控件扩展器主要用于为 ASP.NET 页面上的其他控件添加行为(即 JavaScript),但 AJAX 服务器控件是一个独立的控件,您编写的任何客户端脚本在大多数情况下仅适用于控件本身或其子控件。换句话说,AJAX 扩展器将感知您页面上的其他控件,而 AJAX 服务器控件则不会。

值得一提的是,ASP.NET AJAX 服务器控件模板与 ASP.NET 服务器控件模板一样,实现了继承自 System.Web.UI.WebControls.WebControlScriptControl 类,而 ASP.NET AJAX 服务器控件扩展器模板则实现了直接继承自 System.Web.UI.ControlExtenderControl 类。这意味着使用前两种模板,您的控件将包含一些内置属性,如 EnabledHeightWidth,而扩展控件则不具备这些。然而,在实际应用中,这并不是一个显著的区别。有关 WebControlControl 类之间区别的更全面介绍,请参阅 MSDN 上 Dino Esposito 关于该主题的文章 Dino Esposito's article

因此,审视我们正在讨论的三种控件类型的一种好方法是,从增量地为自定义控件添加开发人员功能,同时保留早期控件功能的策略的角度来看。如果您只对通过子控件添加 AJAX 功能感兴趣,那么 ASP.NET 服务器控件是您的最佳选择。如果您需要为您的控件包含一些自定义客户端脚本,那么您应该使用 ASP.NET AJAX 服务器控件。如果您还需要让您的自定义控件感知您页面上的其他控件,以便与其交互,那么应该使用 ASP.NET AJAX 服务器控件扩展器。

当然,鉴于扩展控件可以做到其他两种控件类型所能做到的所有事情,您始终可以选择使用扩展器来完成所有 AJAX 控件开发——许多人也确实如此。然而,如果您是那种喜欢只为特定情况使用正确工具的开发人员,那么您最好仔细考虑哪个基类最适合您的需求。

本着这种精神,本教程将引导您构建三个不同的控件,分别基于 ASP.NET 服务器控件、ASP.NET AJAX 服务器控件和 ASP.NET AJAX 服务器控件扩展器。每个后续控件都将包含并扩展前一个控件的功能。

本教程还将尝试构建一些通用有用的东西,即会话超时监视器。大多数处理会话超时的策略都涉及反应式解决方案,这些解决方案在用户事件发生时检查会话状态,然后执行某些任务,例如页面重定向,如果会话已过期。被动解决方案很常见,原因在于我称之为 Web 中的海森堡不确定性原理的版本。没有办法秘密地查看会话对象,查看它是否仍然存在,而不延长其生命周期。因此,我们等待用户做一些事情,然后要么重定向,如果会话已过期,要么什么也不做。这里的想法是,由于用户无论如何都在延长会话的生命周期,如果它还没有过期,我们可以搭上这个事件来对会话对象进行自己的探测。

被动解决方案的问题在于,当用户试图完成他们在中途开始的某项工作时(例如,他们去喝杯咖啡),却被带到一个新页面,这可能会让您的网站用户感到有些惊吓。一种更友好、更温和的会话过期处理方法是,预测会话何时即将超时,然后代表用户采取一些行动。在这种情况下,用户将返回到一个会话过期页面,并且(希望)确切地知道发生了什么。

鉴于这种控件的轻微复杂性,我还有机会在不增加过多人为设置的情况下,说明构建 AJAX 启用控件所涉及的各种有用技术和陷阱。本教程的目的不仅是为您提供如何开发 ASP.NET AJAX 控件的基础知识,而且还为您提供构建自己的复杂解决方案的有用提示。如果我在尝试完成这些目标中的一个时,无意中破坏了另一个,使本教程过于简单或过于晦涩,并且未能始终找到两者之间的最佳折衷,我请求您的宽容。

注意:本教程和所有源代码都基于 Visual Studio 2008 的 RTM 版本,而不是 VS 2008 beta。我在使用 RTM 版本打开 beta 项目时遇到了问题,并认为反之亦然。

I. ASP.NET 服务器控件

在编写主动会话超时监视器控件时,有必要预料到控件的用户在会话过期时可能想做什么。一种可能性是用户希望自动重定向到另一个网页,无论是解释刚刚发生的事情的友好页面,还是可能到登录页面。此外,此自定义控件的消费者可能只想显示一个不需要重定向的弹出窗口,而是让用户停留在当前页面。第三种选择是消费者希望延长会话,以便只要打开网页,会话就不会死亡。第四,使用我们会话超时控件的开发人员可能想自己处理会话超时。

我们暂定的功能列表包括:

  1. 页面重定向
  2. 弹出窗口
  3. 延长会话时间
  4. 自定义事件处理程序

此外,会话超时监视器还需要:

  1. 知道会话的持续时间,以及
  2. 注意由于页面回发而导致的每次会话超时重置。此外,它还需要:
  3. 能够以 AJAX 方式响应会话过期——也就是说,无需不必要地导致完整的页面回发。

我们将通过使用 3.5 框架随附的 UpdatePanelTimer 控件来实现这一点,这些控件以前包含在 AJAX 扩展中,并将在我们自己的自定义控件中使用。

首先创建一个名为SessionTimeoutTool的新 ASP.NET 服务器控件项目。这将为我们生成项目和解决方案。向解决方案添加一个名为TestTimeoutTool的第二个 ASP.NET Web 项目。打开TestTimeoutToolweb.config文件,并添加一个sessionState元素以设置会话超时周期。为了测试此控件,建议将此属性设置为一个较小的值。两分钟对我来说效果不错。

<system.web>
   <sessionState timeout="2" mode="InProc"/>
</system.web>

这建立了控件开发环境。

通过右键单击解决方案资源管理器中的默认类并将其文件从ServerControl1.cs重命名为TimeoutWatcherControl,来重命名SessionTimeoutTool项目中的默认类。IDE 会为您处理类名的重命名。TimeoutWatchControl类带有 IDE 为您实现的 Text 属性和 RenderContents。您可以删除它们。您也可以安全地删除装饰类声明的 ToolboxDataDefaultProperty 属性。这将使我们剩下相当朴素的类。

namespace SessionTimeoutTool
{
    public class TimeoutWatcherControl : WebControl
    {
    }
}

在完成了初步准备之后,我们现在可以开始构建我们的控件了。我们需要创建一个枚举来跟踪我们控件将支持的各种超时选项。我们还将公开该枚举作为可以在 ASP.NET 标记中配置的属性。

private mode _timeoutMode = mode.CustomHandler;
        
public enum mode
{
    PageRedirect,
    PopupMessage,
    ExtendTime,
    CustomHandler
}

public mode TimeoutMode
{
    get { return _timeoutMode; }
    set { _timeoutMode = value; }
}

我们需要为重定向页面的路径(如果消费者想使用该模式)、弹出消息以及弹出窗口的 CSSClass 添加公共属性,并且我们需要一个事件来抛出,以防消费者想自己处理超时。我们还需要用于超时间隔的私有字段,以及我们实现自定义控件所需的两个子控件,以及两个只读变量,它们将用于在毫秒(Timer 控件使用)和秒(会话超时的度量单位)之间进行转换。

UpdatePanelTimer 控件将被用来在没有任何实际客户端脚本的情况下启用我们自定义控件中的 AJAX 功能。相反,Timer 控件将负责实现 window.setInterval 方法,该方法创建一个 JavaScript 计数器,而 UpdatePanel 通过与 ScriptManager 注册本身,将为我们提供 DOM 中的一个占位符,我们可以根据需要更新它。这将是我在本节中最后一次讨论客户端脚本,因为以这种方式构建自定义控件的要点是,使用封装了客户端脚本的 AJAX 组件,我们不必担心这些组件如何工作。

private string _redirectPage;
private string _message;
private string _popupCSSClass = string.Empty;

public event EventHandler Timeout;

private int _interval = 1000;
private readonly int MINUTES = 60000;
private readonly int SECONDS = 1000;

protected System.Web.UI.Timer _sessionTimer = null;
private UpdatePanel _timeoutPanel = null;

public string RedirectPage
{
    get { return _redirectPage; }
    set { _redirectPage = value; }
}

public string TimeoutMessage
{
    get { return _message; }
    set { _message = value; }
}

public string PopupCSSClass
{
    get { return _popupCSSClass; }
    set { _popupCSSClass = value; }
}

您还需要向服务器控件项目添加对 System.Web.Extensions 程序集的引用,因为它包含我们将使用的 UpdatePanelTimer 控件。

现在,是时候实现控件的 onLoad 处理程序了,从而满足我们上面概述的关于主动会话超时监视器需要能够做什么的三个要求。首先,我们说过超时监视器必须知道会话生命周期设置为多少。我们可以通过从 HttpContext 类中提取 Session 对象来以编程方式检索此信息。如果通过web.config文件设置了会话超时周期,那么将返回此值(以分钟为单位)。如果未设置,则返回默认值 20 分钟。然而,System.Web.UI.Timer 类以毫秒为单位读取时间,因此需要使用我们的只读 MINUTES 变量将 Session.Timeout 值从秒转换为毫秒。

第二个要求,即我们的控件能够感知任何会话超时重置,是通过将我们的代码放在 OnLoad 方法中来实现的。每当托管 TimoutWatcherControl 的页面发生完全或部分回发时,都会调用 OnLoad 方法。通常,ASP.NET 生命周期实际上会在调用宿主页面的 OnLoad 处理程序之前,调用每个子控件的 OnLoad 处理程序。

在使用 TimeoutWatcherControl 时,我们理想的用户很可能想将控件放在 MasterPage 上,而不是尝试在每个单独的页面上放置控件的新实例。在这种情况下,只是为了知道,正常生命周期似乎按照以下顺序调用 OnLoad 处理程序:

  1. 主页面上的控件,
  2. 内容页面上的控件,
  3. 主页面,和
  4. 内容页面。

最后,我们说我们希望 TimeoutWatcherControl 以经济高效的方式处理超时和回发。这是通过在我们的控件中添加一个 UpdatePanel,以及在 UpdatePanel 中添加一个 Timer 控件来实现的。(目前,我们将为这些组件的构造代码放入存根方法。)

根据设计,当 AJAX 扩展 Timer 控件放置在 UpdatePanel 内时,它会自动知道在计时器到期时更新该面板。这对我们来说很好,因为我们希望在计时器确定会话已过期时更改 UpdatePanel 的内容。

在嵌套我们的控件时,您会注意到我们没有使用 UpdatePanelControls 属性。这是因为 UpdatePanel 的内容实际上是进入一个名为 ContentTemplateContainer 的模板对象,而不是 Controls 属性本身,事实上,尝试添加到 Controls 属性会生成一个异常。这是我们迄今为止的代码:

protected override void OnLoad(EventArgs e)
{
    //retrieve session timeout period from server
    System.Web.SessionState.HttpSessionState state 
        = HttpContext.Current.Session;
    
    //convert minutes to milliseconds
    _interval = state.Timeout * MINUTES;

    //create new ajax components
    UpdatePanel timeoutPanel = GetTimeoutPanel();
    System.Web.UI.Timer sessionTimer = GetSessionTimer();
    sessionTimer.Interval = _interval;
    
    //add timer to update panel
    timeoutPanel.ContentTemplateContainer.Controls.Add(
        sessionTimer);

    //add update panel to timeout watcher control
    this.Controls.Add(timeoutPanel);
}

您可以通过右键单击解决方案资源管理器中的方法调用并选择“生成方法存根”来自动创建我们两个方法占位符的方法。(虽然这不是一项新功能,但我必须承认,有些尴尬的是,我以前从未注意到 VS 2008 中有此功能。)

代码看起来应该相当直接,所以值得一提的是,这里发生了一些非常奇怪的事情。首先,我们在每次回发时(无论是完全回发还是部分回发)都会重新创建 _sessionTimer 对象并将其放入我们的 UpdatePanel 中。为什么在尝试将多个 Timer 对象加载到我们的页面时不会出错?

部分解释涉及以下事实:它被加载到一个 UpdatePanel 中,该面板的 UpdateMode 设置为“Always”。因为 UpdatePanel 设置为“Always”而不是有条件地更新自身,所以在每次部分和完全页面回发时,面板内容模板中的所有数据都会被清除。这对我们来说很好,因为我们想在每次回发时关闭计时机制(这恰好也会重置会话超时),并且在这里我们通过丢弃前一个计时器并创建一个新的计时器来实现这一点,该计时器使用完整的会话超时生命周期开始其倒计时。然后,每次回发,我们都会偶然地重置我们自己的内部时钟,并重新等待会话过期。

但是,这里有一个第二个谜团。为什么每次回发时重新创建 UpdatePanel 不会引起问题,因为 UpdatePanel 容器超出了其内容模板的范围?这里的答案有点奇怪,需要我们以不同的方式思考 Web 应用程序中的状态。在 ASP.NET AJAX 之前,通常认为 ASP.NET 页面的 HTML 内容是 ASP 堆栈中最短暂的层。它始终可以通过各种客户端脚本技术进行更改,而底层的代码隐藏类则保持不变。反过来,代码隐藏类可以在回发时被销毁和重新创建,但在此之后,Session 对象将始终保持稳定和稳固。就像新柏拉图主义的软件生命周期理论一样,通常认为 Web 页面中的持久性源于服务器,并逐渐被代码隐藏和最终的实际 HTML 标记所消散。

然而,有了 ASP.NET AJAX,这个模型就不再适用了。部分页面回发会迫使我们遍历代码隐藏页面的 OnLoad 和其他方法,而无需对物理网页进行任何更改。在新模型中,浏览器中呈现的页面和会话都保留状态,而我们的代码隐藏是堆栈中最不稳定的元素。

虽然标记作为 UpdatePaneldiv 标记呈现并在页面的第一次完全回发时注册到 ScriptManager 组件,但在每次后续的部分回发时,我们需要创建一个与面板标记相对应的新代码对象,而这些标记实际上并未发生任何变化。作为一项实验,您可以尝试在每次部分回发时为 UpdatePanel 设置不同的 ID。您会注意到,如果我们创建的 UpdatePanel 的 ID 与我们最初实例化的 UpdatePanel 的 ID 不对应,代码将抛出异常。我对幕后发生的事情的理解充其量是模糊的,但在使用 ASP.NET AJAX 生命周期方面,如此新颖的特性无疑为 Web 开发带来了新的转折。

这是我们刚才讨论的代码。虽然我添加了对 UpdatePanel 的空值检查(这只是良好的编程实践),但此代码片段中的 _updatePanel 变量实际上始终返回 null 值。另请注意,我们为 TimerTick 事件添加了一个事件处理程序,该事件在计时器完成倒计时后触发。

protected void SessionTimer_Tick(object sender, EventArgs e)
{
}

private System.Web.UI.Timer GetSessionTimer()
{
    if (null == _sessionTimer)
    {
        _sessionTimer = new System.Web.UI.Timer();
        _sessionTimer.Tick += 
            new EventHandler<EventArgs>(SessionTimer_Tick);
        _sessionTimer.Enabled = true;
        _sessionTimer.ID = this.ID + "SessionTimeoutTimer";
    }
    return _sessionTimer;
}

private UpdatePanel GetTimeoutPanel()
{
    if (null == _timeoutPanel)
    {
        _timeoutPanel = new UpdatePanel();
        _timeoutPanel.ID = this.ID + "SessionTimeoutPanel";
        _timeoutPanel.UpdateMode = 
            UpdatePanelUpdateMode.Always;
    }
    return _timeoutPanel;
}

为了完成这个控件,我们只需要处理当认为会话已过期时应该发生的不同情况。在这里,为了简化代码,我将再次插入一些占位符调用。

protected void SessionTimer_Tick(object sender, EventArgs e)
{
    switch (TimeoutMode)
    {
        case mode.ExtendTime:
            //do nothing, page has reposted
            //45 seconds before timeout
            break;
        case mode.PageRedirect:
            DisableTimer();
            Redirect(RedirectPage);
            break;
        case mode.PopupMessage:
            DisableTimer();
            BuildPopup();
            break;
        case mode.CustomHandler:
        default:
            DisableTimer();
            OnTimeout();
            break;
    }
}

处理 ExtendTime 模式可能是最容易实现的解决方案。由于我们嵌套在 UpdatePanel 中的 Timer 会自动调用部分回发,这反过来又会自动延长会话超时,因此我们只需确保在会话实际过期之前执行此操作。我们可以回到 OnLoad 处理程序中,将我们的内部计时器设置为在会话过期前一段时间(假设 45 秒)到期。我们将修改 OnLoad 处理程序,使其如下所示:

System.Web.UI.Timer sessionTimer = GetSessionTimer();
if (TimeoutMode == mode.ExtendTime)
    sessionTimer.Interval = _interval - (45 * SECONDS);
else
    sessionTimer.Interval = _interval;

Redirect 方法也相对容易实现。我们将简单地将 RedirectPage 属性传递给我们的方法,然后使用 Response 对象重定向到指定的页面。然而,为了使其更有趣一些,我们将使用 System.Web.VirtualPathUtility 来解析 redirectPage 参数。这允许我们的自定义控件支持 URL 字符串开头的波浪号 (“~”),并允许我们的控件用户使用波浪号来指定应用程序相对路径。

private void Redirect(string redirectPage)
{
    if (!string.IsNullOrEmpty(redirectPage))
    {
            Context.Response.Redirect(
                VirtualPathUtility.ToAbsolute(
                redirectPage));
    }
}

对于弹出消息,我们希望创建一个浮动的 DIV 并将其注入我们的 UpdatePanel 内容模板。我们还希望找到一种方法来禁用我们的控件,因为我们不希望在最终用户离开很长时间时出现多个弹出窗口。这有点复杂,因为我们需要能够访问当前会话计时器以禁用它,并在回发之间保存已禁用计时器的事实,这样它就不会在下一个回发时自行打开。遵循我们上面讨论的原理,即页面标记实际上比代码隐藏页面更持久,事实证明,我们实际上可以将计时器的启用状态保留在页面视图状态对象中。这确保了如果我们禁用计时器,当页面发生部分或完全页面回发时,计时器将保持禁用状态。

我们可能还希望计时器在非回发页面初始化时重新启用自身。幸运的是,视图状态对象在非回发时作为新对象返回,并且由于我们将 TimerEnabled 属性设置为默认值 true,因此非回发页面视图始终创建一个启用的计时器控件。当 TimoutWatcherControl 托管在 MasterPage 中而不是常规 Web 窗体中时,这也会起作用。

public bool TimerEnabled
{
    get
    {
        object timedOut = ViewState[this.ID + "TimedOutFlag"];
        if (null == timedOut)
            return true;
        else
            return Convert.ToBoolean(timedOut);
    }
    set 
    {
        GetSessionTimer().Enabled = value;
        ViewState[this.ID + "$TimedOutFlag"] = value;
    }
}

private void DisableTimer()
{
    this.TimerEnabled = false;
}

为了完全实现 DisableTimer() 方法,需要对我们的 GetSessionTimer() 进行最后一次更改,该方法最初是为了将内部计时器的 Enabled 属性设置为 true。相反,我们现在将从视图状态中提取此值。

//_sessionTimer.Enabled = true;
_sessionTimer.Enabled = this.TimerEnabled;

现在,我们只需要检索当前 UpdatePanel 并向其中添加一个浮动的 DIV。我们通过构建一个简单的 Panel 控件来实现这一点,该控件设置为position: absolute并具有 z-index。此 Panel 包含 TimoutWatcherControl 标记中设置的弹出消息以及一个用于重新启动计时器的按钮。我们通过将事件处理程序挂接到浮动 DIV 的 OK 按钮来重新启动计时器。

void but_Click(object sender, EventArgs e)
{
    this.TimerEnabled = true;
}

private void BuildPopup()
{
    UpdatePanel p = GetTimeoutPanel();
    Panel popup = new Panel();

    AddCSSStylesToPopupPanel(popup);

    popup.Height = 50;
    popup.Width = 125;
    popup.Style.Add("position", "absolute");
    popup.Style.Add("z-index", "999");

    AddMessageToPopupPanel(popup, TimeoutMessage);
    EventHandler handlePopupButton = new EventHandler(but_Click);
    AddOKButtonToPopupPanel(popup, handlePopupButton);
    p.ContentTemplateContainer.Controls.Add(popup);
}

最后,为了向控件的宿主页面抛出一个超时事件,我们实现了 OnTimeout() 方法。

public event EventHandler Timeout;

protected void OnTimeout()
{
    if (Timeout != null)
        Timeout(this, EventArgs.Empty);
}

您现在可以构建此解决方案,以便 TimoutWatcherControl 在您的工具箱中可用。

为了测试控件,请向TestTimeoutTool项目添加一个新的 WebForm。将其拖入一个 AJAX 扩展 ScriptManager、一个 UpdatePanel 和一个 Button。最后,将 TimeoutWatcherControl 拖到窗体上。完成后,您的标记应该如下所示:

<div>  
    <asp:ScriptManager ID="ScriptManager1" runat="server">
    </asp:ScriptManager>
    This page first loaded at <%= DateTime.Now.ToLongTimeString() %>.
    
    <asp:UpdatePanel ID="UpdatePanel1" 
         runat="server" UpdateMode="Conditional">
    <ContentTemplate>
    This panel refreshed at <%= DateTime.Now.ToLongTimeString() %>.
    <br /><asp:Button Text="Refresh Panel" ID="Button1"
        runat="server"/>
    </ContentTemplate>
    </asp:UpdatePanel>  
</div>

此测试的目的是确保当 UpdatePanel 中发生部分更新时,我们的 TimeoutWatcher 就像会话扩展其超时周期一样,重新扩展其内部超时。我们可以通过创建一个名为SessionExpired.aspx的新 WebForm 并将其硬编码为标记中 TimeoutWatcherConrol 的属性来测试 PageRedirect 选项。

并且,mutatis mutandis,在 UpdatePanel 中的时间大约两分钟后,您应该被重定向到 RedirectPage 参数中指定的页面。

我们的自定义控件只有一个潜在的问题。由于 AJAX 扩展 Timer 在其计数器耗尽时会导致回发,因此我们实际上是创建了一个新的会话对象来通知用户旧的会话对象已过期。在某种意义上,我们只是重新调用了上面提到的不确定性原理。一个更清洁的解决方案将实现我们的各种超时事件,而根本不使用回发。然而,要实现这个更清洁的解决方案,我们需要构建一个 ASP.NET AJAX 服务器控件。

II. ASP.NET AJAX 服务器控件

创建新的 AJAX 服务器控件项目时会得到什么?

在大多数情况下,它看起来与 ASP.NET 服务器控件非常相似,尽管它没有默认实现 Text 属性。相反,您会发现两个与 AJAX 行为相关的新方法:GetScriptDescriptors()GetScriptReferences()。我稍后会详细讨论这些。新的 AJAX 服务器控件项目还附带对 System.Web.Extensions 程序集的自动引用,以及一个 JScript 文件和一个资源文件,两者的名称均为TimeoutWatcherBehavior。通常,您需要重命名这些文件,或者直接删除它们并创建自己的文件。最后,而且并不明显的是,您的AssemblyInfo文件将包含特殊说明,以使您的脚本文件可用作资源;如果重命名脚本,则需要修改这些说明。

对于本教程的这一部分,我需要您创建一个新的 ASP.NET AJAX 服务器控件项目。由于我们想扩展当前实现,而不是替换它,因此您需要将迄今为止编写的所有代码(类声明和类末尾的花括号之间的所有内容)保存到工具箱中。您可能还想保存 TimeoutWatcherControl 中的 using-namespace 指令。

为简单起见,我将在此新的 AJAX 服务器控件中使用相同的项目名称,即SessionTimeoutTool,这意味着我需要删除当前具有该名称的项目,并将其移动到一个新位置,然后再创建新的项目。如果您想使用不同的项目名称,这是可以的,请务必注意您的代码和我将在本教程的这一部分中描述的代码之间会因这个原因产生的细微命名差异。

在我们新的控件项目中,我们要做的第一件事是重命名所有默认文件:ServerControl1.csTimeoutWatcherBehavior.jsTimeoutWatcherBehavior.resx。无论项目名称如何,这些文件默认都命名如此。

让我们将ServerControl1.cs重命名为TimeoutWatcherAjaxControl.cs。VS 会主动为我们重命名类声明和构造函数。我们还将 JScript 文件重命名为TimoutWatcherBehavior.js。不幸的是,VS 在这里有点吝啬,我们必须打开 JScript 文件并更手动地重命名我们的方法和初始化程序。执行编辑 | 查找和替换 | 快速替换,将所有“ClientControl1”实例替换为“TimeoutWatcherBehavior”。您应该针对整个项目进行此操作,而不仅仅是此文件,以确保cs文件也已更新为正确的 JScript 文件引用。

如果您使用 SessionTimeoutTool 项目名称构建了 JScript 文件,更改后的 JScript 文件看起来应该像这样。如果不是,它应该使用您的特定项目名称作为命名空间。

/// <reference name="MicrosoftAjax.js"/>

Type.registerNamespace("SessionTimeoutTool");

SessionTimeoutTool.TimeoutWatcherBehavior = function(element) {
    SessionTimeoutTool.TimeoutWatcherBehavior.initializeBase(this, [element]);
}

SessionTimeoutTool.TimeoutWatcherBehavior.prototype = {
    initialize: function() {
        AjaxServerControl1.TimeoutWatcherBehavior.callBaseMethod(this, 'initialize');
        
        // Add custom initialization here
    },
    dispose: function() {        
        //Add custom dispose actions here
        SessionTimeoutTool.TimeoutWatcherBehavior.callBaseMethod(this, 'dispose');
    }
}
SessionTimeoutTool.TimeoutWatcherBehavior.registerClass(
       'SessionTimeoutTool.TimeoutWatcherBehavior', Sys.UI.Control);

if (typeof(Sys) !== 'undefined') Sys.Application.notifyScriptLoaded();

这个 JScript 文件中有四个有趣的部分。第一个是对 Type.Namespace 的调用,这是 ASP.NET AJAX 库中包含的一项新语言功能,用于避免名称冲突并使 JavaScript 更加成熟。在 AJAX 控件中,通常希望使用项目名称作为命名空间。

跳过代码的正文(稍后我们将返回),有一个 registerClass 方法。这是 Microsoft AJAX 库(有时称为 AJAX Framework)实现的一个方法,用于将您的 JavaScript 注册为一个类。比这更有趣的是,类名之后的 registerClass 参数允许您指定您的自定义类继承的任何其他 AJAX 类。我们不会在本教程中探讨此语言功能,但值得一提的是,它展示了 Microsoft 为使 JavaScript 表现得像面向对象语言所付出的努力。

最后一行,以“if (typeof(Sys) !== 'undefined')”开头,必须位于每个脚本文件的底部。它基本上只是通知 ScriptManager 文件已完成处理。这是为了兼容 Safari 浏览器,Safari 浏览器没有原生方法来指示客户端脚本文件已完成加载。

回到代码正文,最后值得注意的一点是,您的 JScript 类是如何在主函数和原型函数之间分割的。这是 Microsoft 提倡的用于构建 AJAX 行为的原型约定。它是原生 JavaScript 功能的混合体,被重新用于使 JavaScript 表现得更面向对象,以及 Microsoft AJAX 库。

以下是一些关于此编程约定的初步提示:

  1. 首先,将代码分成主正文和原型,基本上为您提供了 C# 编程中使用的部分类模型的灵活性。原型属性也可以被认为是扩展基本代码的一种方式。通常,您希望将初始化代码和任何局部变量放在主正文中。对象的方法或属性应该放在原型中。
  2. initializeBasecallBaseMethod 是 MS AJAX 库的语言增强功能,initializedispose 方法也是如此。在这种情况下,您的基类恰好是 Sys.UI.Control
  3. 按约定,类级别变量前面带有下划线,然后是一个驼峰式命名的变量名。但是请记住,这只是一个约定。JavaScript 中没有秘密,即使是您的类级别变量也可以被外部代码访问。
  4. 属性构造函数前面带有“get_”和“set_”。
  5. 所有方法,包括类声明,都遵循函数名、冒号、函数声明的范式(例如,myFunction: function(){})。
  6. JavaScript 方法通常可以被认为是静态方法。为了支持类实例的概念,需要有一种方法来指示您使用的是实例对象的字段、属性或方法,而不是静态访问器。MS AJAX 库为此提供了 this 关键字。在您的自定义代码中,您应该尽早并频繁地使用它。
  7. MS AJAX 库支持委托和事件处理程序。我们将在本教程后面讨论如何实现它们,但作为开发 AJAX 类,了解它们是您武器库的一部分很重要。
  8. 调试:与 C# 或 VB 不同,您只需在源中设置断点即可进行调试,调试 JavaScript 会更复杂一些。这是因为客户端脚本在 .NET 中必须经过处理才能生成结果脚本。但是,这个结果脚本在运行时才能获得。为了解决这个问题,您需要在实例化代码的早期放置一个对 JavaScript 调试对象的调用,如下所示:Sys.Debug.fail(""),以强制设置断点。这将强制 IDE 在处理结果代码时中断。届时,当您可以访问结果脚本时,您就可以开始在脚本中设置断点,就像在 Visual Studio 中的任何其他语言一样。

确定命名空间、类名和脚本文件的文件名后,还必须将资源文件名更改为与脚本文件匹配:在这种情况下,它应该重命名为TimeoutWatcherBehavior.resx。我们使用它的方式是,资源文件基本上充当占位符,让程序集知道该名称存在一个资源。然后,该名称可以用于将我们的脚本文件设置为 Web 资源。

转到.js文件的属性,并将其生成操作属性设置为Embedded Resource

现在,转到项目属性文件夹,并打开AssemblyInfo.cs进行编辑。在这里,我们将设置元数据,以使 JScript 文件可用作资源,然后作为 Web 资源。作为 Web 资源,它将通过ScriptResource.axd自动从动态位置引用,而不是通过文件位置。

如果您有机会检查 AjaxControlToolkit(一个独立的自定义 AJAX 控件和扩展器程序集),您会注意到它通过将自定义属性应用于每个自定义控件类来处理 Web 资源,指定与该类耦合的脚本资源。这是可能的,因为 Toolkit 已实现了内部代码,该代码使用反射自动将脚本连接为 Web 资源。这都很酷。

然而,我们将编写此控件,而不参考 Toolkit 等第三方工具的基本类。相反,我们将尝试仅使用 Visual Studio 2008 提供的功能。

AssemblyInfo文件中,您应该找到两个引用您的 JScript 文件的assembly属性。如果它们不存在,您可能需要添加它们。对于 TimeoutWatcherAjaxControl,它们应该如下所示:

[assembly: System.Web.UI.WebResource("SessionTimeoutTool.TimeoutWatcherBehavior.js", 
                                     "text/javascript")]
[assembly: System.Web.UI.ScriptResource("SessionTimeoutTool.TimeoutWatcherBehavior.js",
   "SessionTimeoutTool.TimeoutWatcherBehavior", "SessionTimeoutTool.Resource")]

我们真正想要的是将我们的脚本变成脚本资源,但要做到这一点,我们还必须将其标记为程序集中的 Web 资源。声明为 Web 资源只需要使用如上所示的 WebResourceAttribute 声明,并引用文件名和文件类型(“text/javascript”)。

我们使用 ScriptResourceAttribute 将文件注册为可以通过ScriptResource.axd处理程序访问的脚本资源。第一个参数是脚本文件名,它应该与 WebResource 声明中的相同。第二个参数是资源文件名,如上所述,它是一个占位符。第三个参数是一个类型——在这种情况下,类型是我们项目命名空间中的一个资源。

每个参数都应包含控件项目的命名空间。由于我们将嵌入资源放在项目根目录中,因此命名空间和文件名足以标识它。如果您想将脚本文件放在子文件夹中,那么您需要在AssemblyInfo文件中按命名空间、子文件夹和文件名来指定。例如,如果我们已将TimeoutWatcherBehavior.jsresx文件放在名为common的子文件夹下,我们的元数据条目将如下所示:

[assembly: System.Web.UI.WebResource("SessionTimeoutTool.Common.TimeoutWatcherBehavior.js", 
                                     "text/javascript")]
[assembly: System.Web.UI.ScriptResource("SessionTimeoutTool.Common.TimeoutWatcherBehavior.js",
   "SessionTimeoutTool.Common.TimeoutWatcherBehavior", "SessionTimeoutTool.Resource")]

尽管我们添加了必要的元数据来标识我们的脚本文件为脚本资源,但我们仍然需要确保它被实例化。这通过我们自定义控件的 ScriptControl 基类的一个方法 GetScriptReferences() 来完成。

要实现 GetScriptReferences() 方法,我们只需要添加以下一行代码:

yield return new ScriptReference("SessionTimeoutTool.TimeoutWatcherBehavior.js", 
             this.GetType().Assembly.FullName); 

同样,如果资源位于子文件夹中,那么在指定资源名称时将需要包含子文件夹名称。在后台,这个 yield 语句确保在某个时候,脚本引用,如

<script src="/ScriptResource.axd?d=8O8TXUV..." type="text/javascript"></script>

将被插入到我们的 Web 页面中,使我们的 JavaScript 文件可用,尽管方式有些混淆(而且可以说更安全)。这实际上是此方法的所有用途。

ScriptControl 基类的另一个需要重写的方法是 GetScriptDescriptors()。此方法也会在我们的结果 Web 页面中生成代码。它基本上生成对 MS AJAX 库特定的 $create() 方法的调用,例如:

$create(SessionTimeoutTool.TimeoutWatcherBehavior, 
  {"interval":120000,"message":"Timed out"}, 
  null, null, $get("TimeoutWatcherControl1"));

实例化我们的 JavaScript 行为类。此方法有点复杂,因为它可用于修改生成的 $create() 方法,方法是添加其他属性(如上例中的“interval”属性)甚至为其提供初始值。最简单的 C# 实现将如下所示:

ScriptControlDescriptor descriptor = 
          new ScriptControlDescriptor("SessionTimeoutTool.TimeoutWatcherBehavior", 
          this.ClientID);
yield return descriptor;

将我们的自定义控件与我们的 JavaScript 行为类耦合的最后一步是设置生成 $create() 方法中的一些属性值。我们已经知道我们需要将会话超时的间隔传递给我们的客户端脚本,否则客户端脚本无法确定此值。我们还希望将弹出消息文本以及消费者请求的功能(例如,PopupMessagePageRedirect)传递给客户端脚本。所有这些都已在我们之前编写的代码中可用。最后,我们希望有一种方法来指示是使用客户端脚本,还是使用我们已经编写的服务器端代码。

将我们之前保存到工具栏的所有代码拖到 TimeoutWatcherAjaxControl 类中。幸运的是,所有这些代码在派生自 ScriptControl 的类中都可以像在派生自 WebControl 的类中一样工作。

现在,我们将允许用户决定他们是想使用服务器端代码(在当前会话过期时启动一个新的空会话),还是纯客户端代码(不启动)。这是通过一个类级别变量、一个新的枚举和一个公共属性来实现的:

public enum ScriptMode
{
    ServerSide,
    ClientSide
}

private ScriptMode _scriptMode = ScriptMode.ServerSide;

public ScriptMode RunMode
{
    get { return _scriptMode; }
    set { _scriptMode = value; }
}

如果消费者选择服务器端选项,我们应该修改 OnLoad 事件,以便仅创建 UpdatePanel 并将其添加到当前控件中。

if (RunMode == ScriptMode.ServerSide)
{
    //create new ajax components
    UpdatePanel timeoutPanel = GetTimeoutPanel();
    
    ...

    //add update panel to timeout watcher control
    this.Controls.Add(timeoutPanel);
}

GetScriptDescriptors() 本身,我们现在可以将以下属性添加到我们的描述符中:intervaltimoutModeredirectPagemessage。这些将让我们的 AJAX 类知道会话的生命周期、消费者希望如何处理超时、重定向到的页面以及如果请求了弹出窗口则显示的文本。

protected override IEnumerable<ScriptDescriptor>
GetScriptDescriptors()
{
    if (RunMode == ScriptMode.ClientSide)
    {
     ScriptControlDescriptor descriptor = 
        new ScriptControlDescriptor("SessionTimeoutTool.TimeoutWatcherBehavior", 
        this.ClientID);
     descriptor.AddProperty("interval", _interval);
     descriptor.AddProperty("timeoutMode", _timeoutMode);
     descriptor.AddProperty("message", _message);
     descriptor.AddProperty("redirectPage", 
        string.IsNullOrEmpty(_redirectPage) ? "" : 
        VirtualPathUtility.ToAbsolute(_redirectPage));
     yield return descriptor;
    }
}

AddPropertybasically允许我们将服务器值传递给我们的客户端脚本。请记住,这里的代码在任何方面都不知道我们 JavaScript 代码的内容。相反,描述符只是提供了如何将 $create 调用发出到我们页面中的说明,并将我们的属性硬编码到其中。然后,当所有客户端脚本加载完成后,将调用发出的脚本;它会实例化我们的 AJAX 对象(通过利用 MS AJAX Framework),然后按照 descriptor 对象中的指示初始化客户端对象属性。

但是,如果您现在尝试运行此代码,您应该会收到某种异常消息,因为我们仍然需要在 TimeoutWatcherBehavior 类中编写这些属性。

在 JavaScript 行为类中创建属性类似于在 C# 中创建属性。主要区别在于代码放置的位置以及您必须在所有地方使用 this 关键字。您的类级别变量放在主 JavaScript 类中,而您的属性访问器放在原型函数中。由于我们的自定义控件发出的 $create 调用正在查找“interval”属性,因此我们需要编写 get_interval 方法和 set_interval 方法。请注意,这里的“interval”是小写的,就像我们尝试映射的属性名称一样。我们将对 timoutModeredirectPagemessage 执行相同的操作。请注意下面的代码示例,在原型定义中,所有方法后面都跟着一个逗号,除了原型中的最后一个方法。

SessionTimeoutTool.TimeoutWatcherBehavior = function(element) {
    SessionTimeoutTool.TimeoutWatcherBehavior.initializeBase(this, [element]);
    this._interval = 1000; 
    this._message = null;
    this._timeoutMode = null;
}

SessionTimeoutTool.TimeoutWatcherBehavior.prototype = {
    initialize: function() {
        SessionTimeoutTool.TimeoutWatcherBehavior.callBaseMethod(this
        , 'initialize');
    },
    get_interval: function() {
        return this._interval;
    },
    set_interval: function(value) {
        this._interval = value;
    }
    
    ...
}

虽然 JavaScript 的新智能感知通常非常好,但一个轻微的烦恼是,原型中的代码无法识别主类中的变量。因此,当您开始键入“this.”时,“_interval”不是智能感知会建议的值之一。尽管微软也提倡这种编码风格,但智能感知不支持这一点有点奇怪。

您可能还记得 timeoutMode 是作为枚举传递的。为了使其在我们的客户端代码中可理解,我们需要一种方法将其值转换为熟悉的内容。我们知道枚举在底层实际上是整数,所以我们可以记住 timoutMode 值为 0 是页面重定向,值为 1 是弹出消息,依此类推。

然而,为了提高可读性,我们最好编写一个客户端枚举来实现这一点。客户端枚举是 MS Atlas 库支持的另一项功能。客户端枚举器的代码应放在“typeof(Sys) !== 'undefined'”行之前。我将把我们自定义控件的枚举器放在 JavaScript 版本旁边,以便您可以看到相似之处:

//C#
public enum mode
{
    PageRedirect,
    PopupMessage,
    ExtendTime,
    CustomHandler
}

//JScript
SessionTimeoutTool.Mode = function(){};
SessionTimeoutTool.Mode.prototype = 
{
    PageRedirect: 0,
    PopupMessage: 1,
    ExtendTime: 2,
    CustomHandler: 3
}
SessionTimeoutTool.Mode.registerEnum("SessionTimeoutTool.Mode");

为了在客户端代码中跟踪会话超时,我们需要为我们的 JavaScript 类创建一个内部计时器。为此,请在主类中添加一个额外的实例变量,即 _timer

this._timer = null;

这将用于处理我们的内部计时器。我们可以尝试自己处理 window.setInterval 调用以设置计时器。但是,在这种情况下,我们将使用一个计时器类,我认为我最初是在 Bertrand LeRoy 的博客上找到的,但它也可以在 AjaxControlToolkit 中找到。

向我们的项目添加一个新的脚本文件并使其可供 TimeoutWatcherBehavior 类使用,只需要我们完成与使我们的行为类成为 ScriptResource 相同的步骤:

  1. 将脚本文件的生成操作设置为“Embedded Resource”。
  2. 添加一个同名的资源文件(即,Timer.resx)。
  3. AssemblyInfo.cs中将脚本文件标记为 WebResource 和 ScriptResource。
  4. 在自定义控件类的 GetScriptReferences() 方法中添加一个 yield 语句,以便为它创建一个ScriptResource.axd引用。

这是 Sys.Timer 代码,附带许可证,它有效地包装了 window.setInterval 方法:

// (c) Copyright Microsoft Corporation.
// This source is subject to the Microsoft Permissive License.
// See http://www.microsoft.com/resources/sharedsource/
//                licensingbasics/sharedsourcelicenses.mspx.
// All other rights reserved.


/// <reference name="MicrosoftAjax.js" />
/// <reference name="MicrosoftAjaxTimer.js" />
/// <reference name="MicrosoftAjaxWebForms.js" />


/////////////////////////////////////////////////////////////////////////
// Sys.Timer


Sys.Timer = function() {
    Sys.Timer.initializeBase(this);
    this._interval = 1000;
    this._enabled = false;
    this._timer = null;
}

Sys.Timer.prototype = {
    get_interval: function() {
        
        return this._interval;
    },
    set_interval: function(value) {
        
        if (this._interval !== value) {
            this._interval = value;
            this.raisePropertyChanged('interval');
            
            if (!this.get_isUpdating() && (this._timer !== null)) {
                this._stopTimer();
                this._startTimer();
            }
        }
    },
    
    get_enabled: function() {
        
        return this._enabled;
    },
    set_enabled: function(value) {
        
        if (value !== this.get_enabled()) {
            this._enabled = value;
            this.raisePropertyChanged('enabled');
            if (!this.get_isUpdating()) {
                if (value) {
                    this._startTimer();
                }
                else {
                    this._stopTimer();
                }
            }
        }
    },

    
    add_tick: function(handler) {
        
        
        this.get_events().addHandler("tick", handler);
    },

    remove_tick: function(handler) {
        
        
        this.get_events().removeHandler("tick", handler);
    },

    dispose: function() {
        this.set_enabled(false);
        this._stopTimer();
        
        Sys.Timer.callBaseMethod(this, 'dispose');
    },
    
    updated: function() {
        Sys.Timer.callBaseMethod(this, 'updated');

        if (this._enabled) {
            this._stopTimer();
            this._startTimer();
        }
    },

    _timerCallback: function() {
        var handler = this.get_events().getHandler("tick");
        if (handler) {
            handler(this, Sys.EventArgs.Empty);
        }
    },

    _startTimer: function() {
        this._timer = window.setInterval(Function.createDelegate(this, 
                      this._timerCallback), this._interval);
    },

    _stopTimer: function() {
        window.clearInterval(this._timer);
        this._timer = null;
    }
}

Sys.Timer.descriptor = {
    properties: [   {name: 'interval', type: Number},
                    {name: 'enabled', type: Boolean} ],
    events: [ {name: 'tick'} ]
}

Sys.Timer.registerClass('Sys.Timer', Sys.Component);

if (typeof(Sys) !== 'undefined')
    Sys.Application.notifyScriptLoaded();

这是AssemblyInfo中的元数据信息:

[assembly: WebResource("SessionTimeoutTool.Timer.js", "text/javascript")]
[assembly: ScriptResource("SessionTimeoutTool.Timer.js",
   "SessionTimeoutTool.Timer", "SessionTimeoutTool.Resource")]

并且,这是修改后的 GetScriptReferences() 实现。请注意,我们为每个要使其可用的脚本资源添加了一个新的 yield 语句。

// Generate the script reference
protected override IEnumerable<ScriptReference>
        GetScriptReferences()
{
    if (RunMode == ScriptMode.ClientSide)
    {
        yield return new ScriptReference("SessionTimeoutTool"
        + ".TimeoutWatcherBehavior.js"
         , this.GetType().Assembly.FullName);
        yield return new ScriptReference("SessionTimeoutTool.Timer.js"
         , this.GetType().Assembly.FullName);
    }
}

可以使用 new 关键字实例化 Sys.Timer 类。这应该在原型函数的 initialize 方法中完成。接下来,我们需要为 Sys.Timer 对象的 tick 事件添加一个处理程序。尽管语法略有不同,但 AJAX 库委托的工作方式与 C# 中的委托非常相似。使用 AJAX 库 Function 类的 createDelegate() 创建一个指向 TimeoutWatcherBehavior 类中名为 tickHandler 的方法的委托。目前,tickHandler 不会做什么,但我们稍后会填写它。将此委托传递给 Sys.Timer 对象的 add_tick 方法。

接下来,我们要确保每次页面重新加载时,我们的内部计时器都会重置自身。我们通过处理 OnLoad 事件在 C# 中完成了这个操作。我们在这里可以使用 MS AJAX 库公开的一个事件来做类似的事情。该库公开了一个名为 Sys.Application.add_load 的函数,该函数在 Web 页面进行完全或部分更新时调用传递给它的任何委托。为了利用这一点,我们只需要为类方法 setTime 创建一个委托,该方法将重置计时器并将此委托传递给 add_load 函数。

最后,在我们的 set_Time 方法中,我们只需要关闭计时器,用我们从自定义控件收到的 _interval 值重置它,然后重新启动它。

    initialize: function() {
        SessionTimeoutTool.TimeoutWatcherBehavior.callBaseMethod(this
            , 'initialize');
        //create timer
        this._timer = new Sys.Timer();
        //create timer handler           
        tickHandlerDelegate = Function.createDelegate(this, this.tickHandler);
        this._timer.add_tick(tickHandlerDelegate);
        //create onload handler
        setTime = Function.createDelegate(this,this.setTimer);
        Sys.Application.add_load(setTime);
    },
    tickHandler: function(){
    },
    setTimer:function()
    {
        if(this._timer)
        {
            this._timer.set_enabled(false);
            this._timer.set_interval(this.get_interval());
            this._timer.set_enabled(true);
        }
    },
    
    ...
    
    dispose: function() {        
        SessionTimeoutTool.TimeoutWatcherBehavior.callBaseMethod(this
        , 'dispose');
        if (this._timer) {        
            this._timer.dispose();
            this._timer = null;
        }
        $clearHandlers;
    }

要完成此代码部分,我们需要确保当我们的 TimoutWatcherBehavior 类被销毁时,我们也调用内部计时器对象的 dispose 方法。此外,为了安全起见,我们将调用 $clearHandlers。这是一个通用方法,用于断开我们在类中可能已挂钩的所有事件处理程序。

我们所要做的就是处理 tickHandler 方法中 timeoutMode 属性的所有可能值。

我们将通过为 tickHandler 创建一个骨架实现,并包含一些存根方法来开始。

     tickHandler: function(){
        if(this._timeoutMode == SessionTimeoutTool.Mode.PageRedirect)
        {
            this.pageRedirect();
            return;
        }
        if(this._timeoutMode == SessionTimeoutTool.Mode.PopupMessage)
        {
            this.popup();
            return;
        }
        if(this._timeoutMode == SessionTimeoutTool.Mode.ExtendTime)
        {
            this.extendTime();
            return;
        }
        if(this._timeoutMode == SessionTimeoutTool.Mode.CustomHandler)
        {
            this.customHandler();
            return;
        }  
    },

pageRedirectpopup 方法都相对容易实现,特别是因为我们将只使用 JavaScript alert 来处理弹出请求。只需确保在弹出窗口之前禁用计时器,否则我们将最终获得多个警报窗口。

    pageRedirect: function(){
        window.location = this._redirectPage;
    },
    popup: function(){
        this._timer._stopTimer();
        if(this._message == null)
        {
            alert("The session has expired.");
        }
        else
        {
            alert(this._message);
        }
    },

敏锐的读者会注意到,在本教程的这一部分中,到目前为止,我们实际上没有使用任何真正的 AJAX 功能。“真正”的 AJAX 包括使用 XMLHttpRequest 对象从 JavaScript 与服务器进行交互。即使遵循 AJAX 的更广泛定义(如 AJAXControlToolkit 中实现的),即与服务器对象通信或操作页面 DOM,我们也还没有做任何特别 AJAX 的事情。到目前为止,我们只将 JavaScript 封装在服务器控件中,并设法从服务器间接传递了一些信息给我们的客户端脚本。

然而,要实现 extendTime 方法,我们将需要与服务器通信。我们接下来采用的技术应该为任何您可能想在自己的项目中进行的真正 AJAX 提供模板。

基本解决方案非常简单。MS AJAX 库支持从 JavaScript 调用 Web 服务。因此,我们只需要创建一个通过将值写入会话来扩展会话的 Web 服务。

在您的控件项目中,添加对程序集 System.Web.Services 的引用。接下来,我们将想向该项目添加一个 Web 服务。不幸的是,Web 服务的模板在 AJAX 服务器控件项目下不显示,因此您需要为测试项目创建它,如果您已打开它,然后将其拖到控件项目中。您可以使用此服务的默认名称(出于稍后解释的原因)。确保服务被 ScriptService 属性装饰,这允许它从客户端脚本调用。创建一个扩展会话的方法,名为 ExtendSessionTimeout,并确保将其标记为需要访问当前会话的服务,方法是标记此标签:WebMethod(EnableSession = true)。Web 服务最终应该如下所示:

using System.ComponentModel;
using System.Web.Services;
using System.Web.Services.Protocols;

namespace SessionTimeoutTool
{
    [WebService(Namespace = "http://tempuri.org/")]
    [WebServiceBinding(ConformsTo = WsiProfiles.BasicProfile1_1)]
    [ToolboxItem(false)]
    [System.Web.Script.Services.ScriptService]
    public class WebService1 : System.Web.Services.WebService
    {

        [WebMethod(EnableSession = true)]
        public void ExtendSessionTimeout()
        {
            Session["extendTimeout"]=true;
        }
    }
}

使用 ASP.NET AJAX 调用 Web 服务的标准方法是向 ScriptManager 添加对我们服务的引用。这对我们来说用处不大,因为用户的 ScriptManager 在不同的程序集中,我们无法访问它。幸运的是,ASP.NET AJAX 还提供了 Sys.Net.WebServiceProxy.invoke 方法,它允许我们在不需要此类引用的情况下进行 Web 服务调用。对我们服务的调用将如下所示:

var webRequest = Sys.Net.WebServiceProxy.invoke("WebService1.asmx"
, "ExtendSessionTimeout", false, null
, null, null, "User Context");

但这里的问题是。在 .NET 中,Web 服务是使用引用底层 Web 服务类的*.asmx页面实现的。调用代码的 AJAX 函数运行在实现我们自定义控件的程序集中。然而,AJAX 函数调用的WebService1.asmx不存在于该程序集中。如果我们尝试按原样运行代码,我们将抛出一个异常,因为找不到文件。也没有办法将一个asmx页面设置为资源文件,以便在客户端程序集中公开它。

一种解决方案是让我们的控件的消费者在他们自己的项目中实现WebService1.asmx。虽然这可行,但相当不酷。最好有一个自包含的 AJAX 服务器控件,无论它在哪里使用,都能找到它自己的内部 Web 服务。

HttpHandlers 提供了一种侵入性较小的解决方案。实现 HttpHandlerFactory 需要控件的消费者对他的web.config文件进行少量修改,但这仍然比强迫他实现一个完整的类来满足我们的规范要好。HttpHandlerFactory 的目的是提供关于 Web 服务器如何处理某些文件扩展名的说明。实际上,它们可以在web.config文件中配置,以提供一个别名,然后该别名可以映射到我们在 HttpHandlerFactory 中指定的类或 Web 对象。诀窍是找到一种方法来实现一个 HttpHandlerFactory,在任何客户端使用我们的自定义控件时,它都可以返回我们的内部 Web 服务。

首先,让我们设置我们的 TimeoutWatcherBehavior 类来调用此别名。我们可以这样编写我们的 extendTime 函数:

    extendTime: function(){
        this.callWebService();
    },
    callWebService: function()
    {
        var webRequest = Sys.Net.WebServiceProxy.invoke(
        "SessionTimeoutTool.asmx" //path
        , "ExtendSessionTimeout" //methodName
        , false //useHttpGet
        , null //parameters 
        , this.succeededCallback
        , this.failedCallback
        , "User Context");//userContext 
    },
    succeededCallback: function(result, eventArgs)
    {
        if(result !== null)
            alert(result);
    },
    failedCallback: function(error)
    {
        alert(error);
    },

此处可以找到此 AJAX 库函数的规范 here。即使我们的 Web 方法没有返回值,我也包含了回调函数的存根方法,供参考。如果您不需要回调,成功和失败参数可以为 null。还有一个最终的可选参数,我在上面的参考代码中省略了它,它设置了调用的超时时间。AJAX 库文档说它可以设置为 null,但这实际上会导致异常。如果您不需要超时,则应省略该参数。

消耗我们控件的项目需要为我们的 Web 服务别名包含一个 HttpHandler 元素。每当 Web 服务器从引用我们 TimeoutWatcherAjaxControl 的程序集中接收到对我们别名“SessionTimeoutTool.asmx”的调用时,我们将调用重定向到一个名为“SessionTimeoutTool.SessionTimeoutHandlerFactory”的 HttpHandlerFactory(我们尚未编写)。

  <system.web>
    <httpHandlers>
      <add verb="*" path="SessionTimeoutTool.asmx" 
           type="SessionTimeoutTool.SessionTimeoutHandlerFactory" 
           validate="false"/>
      ...
    </httpHandlers>
  </system.web>

编写我们的 HttpHandlerFactory 类需要一些黑魔法。Microsoft 的消息板上充斥着 Microsoft 员工的条目,他们说您根本不能从一个项目调用另一个项目中的 Web 服务。当然,这并非不可能,但确实很棘手。Hugo Batista 在他的 博客中提供了一个解决方案。不幸的是,当 .NET 3.5 中的 WebServiceHandlerFactoryScriptHandlerFactory 取代作为处理扩展名为asmx的文件的主要类时,此解决方案被淘汰了。ScriptHandlerFactory 位于 System.Web.Extensions 程序集中,它将常规 Web 服务调用委托给 WebServiceHandlerFactory。然而,来自 JavaScript 的 Web 服务调用是通过 RestHandlerFactory 实现的。此外,RestHandlerFactory 允许我们传递 Web 服务类型的定义,而不是要求我们传递*.asmx页面的路径。

然而,还有一个额外的复杂性。System.Web.Extensions 中的所有 HttpHandlerFactory 类都是internal范围的。因此,为了使用这些类,我们必须使用一些反射。Robertjan Tuit 在 CodeProject 文章 Robertjan Tuit 中提供了一个解决方案。他的解决方案非常出色,但显然被低估了。我强烈鼓励您给他五星好评。我不得不阅读了好几个中文黑客网站,并使用谷歌翻译来弄清楚他到底在做什么。基本上,他使用了一个反射工具来查看 ScriptHandlerFactory,以弄清楚在做什么,并重新实现了整个过程,以便向它传递一个 Web 服务类型引用而不是*.asmx文件路径。

我精简了他的代码,因为它仅用于处理来自 JavaScript 的 Web 服务调用,并且仅针对一个特定的 Web 服务。我还添加了一些额外的代码,基于对 RestHandlerFactory 实现的反射,以便传递会话信息。

实现相当通用,并且可以复制粘贴。您可以在将来的项目中直接使用它。唯一变化的是 Web 服务类的值,它在 webServiceType 变量中设置。以下是将添加到SessionTimeoutTool项目中的完整代码:

using System;
using System.Collections.Generic;
using System.Text;
using System.Web;
using System.Reflection;
using System.Web.Services.Protocols;
using System.Web.Script.Services;
using System.Web.SessionState;

namespace SessionTimeoutTool
{
    class SessionTimeoutHandlerFactory: IHttpHandlerFactory
    {
        #region IHttpHandlerFactory Members

        IHttpHandlerFactory factory = null;
        Type webServiceType = typeof(WebService1);

        public IHttpHandler GetHandler(HttpContext context, string requestType
            , string url, string pathTranslated)
        {
            Assembly ajaxAssembly = typeof(GenerateScriptTypeAttribute).Assembly;

            factory = (IHttpHandlerFactory)System.Activator.CreateInstance(
                       ajaxAssembly.GetType(
                       "System.Web.Script.Services.RestHandlerFactory"));

            IHttpHandler restHandler = (IHttpHandler)System.Activator.CreateInstance(
                ajaxAssembly.GetType("System.Web.Script.Services.RestHandler"));

            ConstructorInfo WebServiceDataConstructor = ajaxAssembly.GetType(
                "System.Web.Script.Services.WebServiceData").GetConstructor(
                BindingFlags.NonPublic | BindingFlags.Instance
                , null, new Type[] { typeof(Type), typeof(bool) }, null);

            MethodInfo CreateHandlerMethod = restHandler.GetType().GetMethod(
                "CreateHandler", BindingFlags.NonPublic | BindingFlags.Static, 
                null, new Type[] { ajaxAssembly.GetType(
                "System.Web.Script.Services.WebServiceData"), 
                typeof(string) }, null);

            IHttpHandler originalHandler = 
             (IHttpHandler)CreateHandlerMethod.Invoke(restHandler, 
             new Object[]{ WebServiceDataConstructor.Invoke(
             new object[] { webServiceType, false }), 
             context.Request.PathInfo.Substring(1)
            });
            Type t = ajaxAssembly.GetType(
               "System.Web.Script.Services.ScriptHandlerFactory");
            Type wrapperType = null;
            if (originalHandler is IRequiresSessionState)
                wrapperType = t.GetNestedType("HandlerWrapperWithSession"
                    , BindingFlags.NonPublic | BindingFlags.Instance);
            else
                wrapperType = t.GetNestedType("HandlerWrapper"
                    , BindingFlags.NonPublic | BindingFlags.Instance);

            return (IHttpHandler)System.Activator.CreateInstance(
                wrapperType, BindingFlags.NonPublic | BindingFlags.Instance
                , null, new object[] { originalHandler, factory }, null);
        }

        public void ReleaseHandler(IHttpHandler handler)
        {
            factory.ReleaseHandler(handler);
        }

        #endregion       
    }
}

我们最后需要做的是确保我们的代码不会等到会话已经过期才调用 extendTime 方法。与我们的 TimeoutWatcherControl 一样,当选择 ExtendTime 选项时,我们将把 TimeoutWatcherAjaxControl 配置为将内部计时器设置为提前 45 秒触发。我们将在行为类的 setTimer 方法中做到这一点。

setTimer:function()
{
    if(this._timer)
    {
        this._timer.set_enabled(false);
        if(this._timeoutMode == SessionTimeoutTool.Mode.ExtendTime)
            this._timer.set_interval(this.get_interval()- 45000);
        else
            this._timer.set_interval(this.get_interval());
        this._timer.set_enabled(true);
    }

在我们的测试项目中,我们可以使用与测试页面重定向选项相同的标记来测试此功能。我们的自定义控件的标记将如下所示:

<cc1:TimeoutWatcherAjaxControl 
    ID="TimeoutWatcherAjaxControl1" 
    TimeoutMode="ExtendTime" 
    RunMode="ClientSide" 
    runat="server" />

我们还将向测试页面添加第三个 UpdatePanel,该面板将通过检查我们添加到 Session 对象中的变量来检查会话是否仍然有效。当变量不存在(这是页面首次命中时的情况)并且会话过期时,面板将告诉我们该会话是新的;否则,它将返回 false

<asp:UpdatePanel ID="UpdatePanel2" runat="server">
<ContentTemplate>
<div style="border: medium solid Yellow; padding: 5px; width:400px;">
At <%= DateTime.Now.ToLongTimeString() %> 
this session is brand new: 
<%= Session["old"]==null?"true":"false" %>.
<% Session["old"] = "someValue"; %>
     <br /><asp:Button Text="Check Session" ID="Button2" runat="server"/>
</div>
</ContentTemplate>
</asp:UpdatePanel>

单击“检查会话”按钮将延长会话,因为它会导致部分回发,因此请确保在单击之前,在上次页面更新后等待大约两分钟(或您设置的任何会话超时长度)。

check session state

上述技术是创建能够从客户端代码调用服务器端代码的 AJAX 控件的关键。据我所知,Microsoft 没有提供任何其他方法来将真正的 AJAX 功能构建到服务器控件中,这很可惜。但是,至少我们有了一个解决方法。

我们仍然需要编写 customHandler 方法。有几种方法可以做到这一点。一种选择是公开我们服务器控件中的附加属性来传递 Web 服务和方法名称。然后,我们可以使用上面描述的 WebServiceProxy.invoke 方法来调用此 Web 服务并运行用户的代码。

然而,这种功能已经通过 ServerModeTimeout 事件可用,而且在这里只是寻找另一种方法来做同样的事情没有意义。相反,我们将公开一个新属性,允许用户将自定义 JavaScript 传递给我们的控件,并在会话过期时执行它。

TimeoutWatcherAjaxControl C# 类添加一个名为 CustomHandlerJScript 的新属性。

private string _customHandlerJScript;

public string CustomHandlerJScript
{
    get { return _customHandlerJScript; }
    set { _customHandlerJScript = value; }
}

GetScriptDescriptors 方法中,将此值传递给 TimeoutWatcherBehavior JavaScript 类。

descriptor.AddProperty("customHandlerJScript", _customHandlerJScript);

现在,在我们的 TimeoutWatcherBehavior 中,添加一个接收此值的访问器:

SessionTimeoutTool.TimeoutWatcherBehavior = function(element) {
    ...
    this._customHandlerJScript = null;
}
SessionTimeoutTool.TimeoutWatcherBehavior.prototype = {
    ...
    get_customHandlerJScript:function()
    {
        return this._customHandlerJScript;
    },
    set_customHandlerJScript:function(value)
    {
        this._customHandlerJScript = value;
    },
    ...
}

customHandler 函数中,我们将简单地使用经典的 JavaScript 函数 eval 来执行由控件的消费者传递的脚本。

    customHandler: function(){
        this._timer._stopTimer();
        eval(this.get_customHandlerJScript());
    },

要测试此功能,我们的 TimeoutWatcherAJAXControl 的标记应如下所示:

<cc1:TimeoutWatcherAjaxControl 
    ID="TimeoutWatcherAjaxControl1" 
    TimeoutMode="CustomHandler" 
    RunMode="ClientSide"
    CustomHandlerJScript="alert('this is the custom handler');"
    runat="server" />

如果一切顺利,大约两分钟后,您应该会看到这个:

customhandler

我们的 ASP.NET AJAX 服务器控件已完成。

三、ASP.NET AJAX 服务器控件扩展器

顾名思义,ASP.NET AJAX 服务器控件扩展器只是在 AJAX 服务器控件的基础上增加了一些额外功能。您可能还记得,我们 AJAX 行为类的类声明接收一个名为 element 的参数。虽然我们没有讨论这一点,但可以通过调用 this.get_element() 在我们的 JavaScript 代码中访问传入的 element。然后,element 参数的值会传递到服务器控件的 GetScriptDescriptors 方法中的行为类。它是 ScriptControlDescriptor 构造函数的第二个参数。

ScriptControlDescriptor descriptor = new ScriptControlDescriptor(
    "SessionTimeoutTool.TimeoutWatcherBehavior", this.ClientID);

在 AJAX 服务器控件中,我们只需传递自定义控件的 ID,然后在我们的行为类中使用 get_element() 函数来获取 DOM 元素。然而,在 AJAX 服务器控件扩展器中,我们传递的是我们网页上另一个控件的 ID。

在扩展控件中,我们可以使用此 id 来挂钩到另一个页面控件的 DOM 元素,并挂钩其方法以提供自定义行为。实际上,这为我们提供了两种向服务器端控件添加 AJAX 功能的方法。我们可以使用 AJAX 服务器控件模板,并实现一个带有附加 JavaScript 的 TextBox 控件。或者,我们可以创建一个独立的行为集,然后将其附加到常规的 TextBox 控件。

这本质上是 AJAX 服务器控件和 AJAX 服务器控件扩展器之间唯一的区别:自定义控件的 JavaScript 是应用于自身还是应用于外部控件。然而,扩展器模型更加灵活,因为通过它,您可以进入一个预先存在的应用程序,并简单地开始向预先存在的控件添加行为,而不是不得不开始将它们中的每一个替换为您自己的 AJAX 定制控件。扩展器还有允许您将多个来自多个服务器控件扩展器的行为添加到单个控件的额外好处。您可以将此视为允许任何控件继承自多个基类的方式,而 AJAX 服务器控件仅允许您继承自一个。

我们上面构建的服务器控件具有一个相当基础的弹出功能实现。如果允许用户指向一个外部面板,并在我们的实现中将其变成一个浮动的 DIV,那会酷得多。我们可以通过将我们的服务器控件变成一个扩展控件来实现这一点。

有两种方法可以创建我们的 TimeoutWatcherAjaxControlExtension。我们可以像上面一样,创建一个基于ASP.NET AJAX 服务器控件扩展器模板的全新项目,然后将所有代码复制过去。但是,常规 AJAX 控件和 AJAX 扩展器之间的差异相当小,因此我将选择创建一个基于 TimoutWatcherAJAXControl 的新类,并对其进行一些调整。这样就可以避免复制所有 *.js 文件和 AssemblyInfo 设置到新项目中(当然,如果您愿意,也可以这样做)。

按照我的方法,只需创建一个名为 TimeoutWatcherAjaxControlExtender.cs 的新类文件。将上面编写的所有 AJAX 控件代码复制到其中。扩展控件继承自 ExtenderControl 而不是 ScriptControl,因此我们需要进行此更改。此外,类声明需要一个属性,该属性指定我们要扩展的控件类型。在这种情况下,它将是一个 Panel 控件。我已经注释掉了原始类声明,以便您可以看到区别。

//public class TimeoutWatcherAjaxControl : ScriptControl
[TargetControlType(typeof(Panel))]
public class TimeoutWatcherAjaxControlExtender: ExtenderControl
{

接下来,GetScriptDescriptors 方法在扩展器类中具有不同的签名。它接受一个控件作为参数。当我们在方法实现中创建一个新的 ScriptBehaviorDescriptor 对象时,我们只需将目标控件的 id 传递进去,而不是服务器控件的 ID。

protected override IEnumerable<ScriptDescriptor>
//GetScriptDescriptors() -- old  
GetScriptDescriptors(Control targetControl)
{
    if (RunMode == ScriptMode.ClientSide)
    {
        ScriptControlDescriptor descriptor = 
            new ScriptControlDescriptor("SessionTimeoutTool."
                + "TimeoutWatcherBehavior", targetControl.ClientID);
        ...

这些是我们真正需要做的唯一更改。JavaScript 行为类无论您是构建服务器控件还是扩展器,都具有相同的结构,因此我们可以直接重用我们在上一节中编写的类。因为我们继承自 ExtenderControl 类,所以我们的自定义扩展器会自动公开一个新的名为 TargetControlID 的属性,扩展控件的使用者将在其标记中使用此属性来标识将被转换为浮动 DIVPanel 控件。

通常,您现在会在 JavaScript 原型中使用 get_element() 函数来挂钩 Panel 的属性和事件,以添加新行为。您可以将其与 AJAX 库的 $addHandlers 方法结合使用来捕获 DOM 事件,然后将其传递给您自己的自定义函数,如下所示:

ControlNamespace.ClientControl1.prototype = {
    initialize: function() {
        $addHandlers(this.get_element(), 
                 { 'click' : this._onClick,
    },
    _onClick: function()
    {
        alert("clicked");
    },

AJAX 控件工具包已经包含了一个非常好的 Popup Extender JavaScript 类,因此我们将采取捷径,使用该行为类而不是尝试自己编写脚本。此外,这将为我们提供一个机会,了解如何从第三方程序集中提取 JavaScript 类并在我们自己的控件扩展器中使用它们。

要使用工具包,我们需要将 ACT 程序集添加到我们的 bin 目录,然后引用它。您可以从本教程的示例项目获取程序集,或者从 Microsoft 网站下载。

为了使用 ACT 脚本,我们不需要向 AssemblyInfo 类添加任何条目,因为它们已经在 ACT 程序集中被标记为资源。我们只需要确保它们作为 *.axd 资源进行实例化,并通过 ScriptResource.axd 路径进行访问。在 GetScriptReferences 方法中,添加三个额外的 yield 语句,以便使 ACT 的 PopupBehavior 类可访问。一个用于 PopupExtender 本身,另外两个是 PopupBehavior 正常运行所需的一些基类。

yield return new ScriptReference("AjaxControlToolkit"+ 
             ".ExtenderBase.BaseScripts.js", "AjaxControlToolkit");
yield return new ScriptReference("AjaxControlToolkit" + 
             ".Common.Common.js", "AjaxControlToolkit");
yield return new ScriptReference("AjaxControlToolkit" + 
             ".PopupExtender.PopupBehavior.js", "AjaxControlToolkit");

现在我们可以从我们自己的自定义 JavaScript 行为类实例化 PopupBehavior 类。在主类中添加一个名为 this._popupBehavior 的新变量。然后,在原型类的 initialize 例程中,使用 AJAX 库的 $create 函数将其设置为一个新的 PopupBehavior 实例。

this._popupBehavior = $create(AjaxControlToolkit.PopupBehavior
        , {"id":this.get_id()+'PopupBehavior'}
        , null
        , null
        , this.get_element());

现在可以重写我们的弹出窗口,使其所有功能都是关闭内部计时器并调用 PopupBehavior 类的 show() 方法。

popup: function(){
        this._timer._stopTimer();
        this._popupBehavior.show();
        },

可以通过将 Panel 控件添加到 Web 窗体并将 id 设置为标记中扩展器的 TargetControlID 来测试此控件。

    <asp:Panel ID="timeoutPanel" runat="server" 
    style="display:none; 
        text-align: center; width:200px; background-color:White; 
        border-width:2px; border-color:Black; border-style:solid; 
        padding:20px;">
    This session timed out.
    <br /><br />
    <center>
    <asp:Button ID="ButtonOk" runat="server" Text="OK" />
    </center>
    </asp:Panel>
        <cc1:TimeoutWatcherAjaxControlExtender
        TargetControlID="timeoutPanel"
         ID="TimeoutWatcherAjaxControlExtender1"
         TimeoutMode="PopupMessage"
         RunMode="ClientSide"
         runat="server" />

但是有一个警告。扩展控件要求设置 TargetControlID,无论它是否在您的控件中使用。它还必须是用于修饰类声明的 TargetControlType 属性中指定的类型。这意味着我们的扩展器的用户必须设置一个虚拟的 Panel 来作为 TargetControlID,如果他们想使用除 PopupMessage 之外的任何功能,这并不特别优雅。为了使事情稍微方便一些,尽管仍不完美,我将 TargetControlType 属性更改为 Control 而不是 Panel,这至少可以让使用者在未选择 Popup 选项时指向页面上的任何控件。

这样就完成了我们的扩展控件,也是本教程的最后一节。我希望本教程能为您提供构建自己的高级控件所需的技能和见解。

JavaScript 总是会比较棘手,并且各种试图清理它并使其更面向对象的尝试有时看起来就像在猪身上涂口红。但是,它确实比以前干净多了,并且借助 Visual Studio 2008 AJAX 服务器控件和 AJAX 扩展器模板,我们现在可以选择将大部分代码隐藏在自定义控件中,这样对客户端脚本不感兴趣的开发人员就无需查看它,但仍然可以从中受益。

在冗长的教程结束时,您可能想知道的最后一件事是如何为您的控件向工具箱添加图标。您只需要将一个位图或图标文件添加到您的项目中,并将其生成操作设置为“嵌入的资源”。然后,将这两个工具箱属性添加到您的类声明中,并在需要的地方放置您的类名。在这个例子中,我使用了 Catbert 图标。

[TargetControlType(typeof(Control))]
[System.Drawing.ToolboxBitmap(typeof(TimeoutWatcherAjaxControlExtender)
    , "Catbert.ico")]
[ToolboxData("<{0}:TimeoutWatcherAjaxControlExtender runat="server">
    </{0}:TimeoutWatcherAjaxControlExtender>")]
public class TimeoutWatcherAjaxControlExtender: ExtenderControl
{...}

如果您的控件被引用为项目引用,则图标不会显示。只有在编译您的控件然后引用其程序集时,图标才会显示。

ASP.NET AJAX 控件和扩展器 - CodeProject - 代码之家
© . All rights reserved.