Knockout ASP.NET Web Forms 导航





5.00/5 (5投票s)
Knockout 教程示例的渐进增强版 ASP.NET Web Forms
引言
单页应用程序 (SPA) 非常流行。它们是网站,其中所有导航都发生在单个页面内,没有任何完整的页面刷新。
通常,这些应用程序是通过服务器返回 JSON 的 Web 服务和客户端的模板引擎来构建的,该模板引擎将数据转换为 HTML,用于更新 DOM。然而,这些网站往往存在以下问题:
- 它们无法完全访问 - 这些网站依赖于 JavaScript,而 JavaScript 并非在所有浏览器中都启用。它们不便于键盘操作,例如,必须单击的元素无法使用 Tab 键访问。
- 它们对 SEO 不友好 – 搜索引擎爬虫可以被认为是禁用了 JavaScript 的客户端,因此最适合使用返回 HTML 的标准超链接。
这类网站的一个例子在 题为“单页应用程序”的 Knockout 教程 中有详细介绍。
在本文中,我们将演示使用 ASP.NET Web Forms 构建此 Knockout 示例网站同样容易。我们将采用渐进增强的策略,这是一种 Web 设计策略,允许所有人访问基本内容,同时为支持的客户端提供增强的体验,因此生成的网站将完全可访问且对 SEO 友好。
背景
您应该对 ASP.NET 导航框架有中级了解,http://navigation.codeplex.com/。
可以通过在程序包管理器控制台中运行 Install-Package Navigation 命令来通过 NuGet 安装它。
构建一个 Webmail 客户端
由于我们正在构建一个单页应用程序,它将只包含一个 ASPX 页面,我们将在其中添加一个表示邮件文件夹的列表:收件箱、归档、已发送和垃圾邮件。我们将使用 NavigationHyperLink
构建每个文件夹,因为这允许我们以声明式方式设置导航详细信息,即,无需使用代码隐藏。我们将使用刷新导航,通过将控件的 Direction
属性设置为 Refresh
,这意味着在单击链接时我们将停留在同一页面上。
<ul class="folders">
<li><nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox" Direction="Refresh" /></li>
<li><nav:NavigationHyperLink ID="Archive" runat="server" Text="Archive" Direction="Refresh" /></li>
<li><nav:NavigationHyperLink ID="Sent" runat="server" Text="Sent" Direction="Refresh" /></li>
<li><nav:NavigationHyperLink ID="Spam" runat="server" Text="Spam" Direction="Refresh" /></li>
</ul>
使文件夹可选中
要将文件夹标记为选中,我们需要知道单击了哪个文件夹,因此我们将文件夹名称设置在每个 NavigationHyperLink
的 ToData
中。
<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox"
Direction="Refresh" ToData="<%$ NavigationData: folder=inbox %>" />
为了使我们的代码隐藏为空且代码结构良好,我们将使用 ASP.NET 数据绑定以及 ObjectDataSource
控件。这涉及到我们创建一个单独的类来包含我们的逻辑。在此类中,我们将添加一个方法来设置四个属性,每个属性用于一个文件夹的 CSS 类设置。
public object Display(string folder)
{
folder = folder ?? "inbox";
return new
{
InboxCss = folder == "inbox" ? "selected" : string.Empty,
ArchiveCss = folder == "archive" ? "selected" : string.Empty,
SentCss = folder == "sent" ? "selected" : string.Empty,
SpamCss = folder == "spam" ? "selected" : string.Empty,
};
}
现在我们需要将我们的文件夹列表 UI 包装在 FormView
中,并将其连接到连接到我们刚刚创建的 Display
方法的 ObjectDataSource
。
<asp:FormView ID="Display" runat="server" DataSourceID="DisplaySource" RenderOuterTable="false">
<ItemTemplate>
<ul class="folders">
...
</ul>
</ItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="DisplaySource" runat="server"
TypeName="Mail.Controllers.MailController" SelectMethod="Display">
</asp:ObjectDataSource>
因为 Display
方法有一个文件夹作为参数,我们需要向 ObjectDataSource
添加一个 SelectParameter
。我们将为此使用 NavigationDataParameter
,其 Name
与我们在 NavigationHyperLink
的 ToData
中使用的键匹配。
<asp:ObjectDataSource ID="DisplaySource" runat="server"
TypeName="Mail.Controllers.MailController" SelectMethod="Display">
<SelectParameters>
<nav:NavigationDataParameter Name="folder" />
</SelectParameters>
</asp:ObjectDataSource>
现在我们只需数据绑定我们 NavigationHyperLink
的 CssClass
属性。因此,通过单击一个文件夹,其 CSS 类名将被设置为“selected”,因此,通过正确的 CSS,它将被突出显示。
<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox"
ToData="<%$ NavigationData: folder=inbox %>"
Direction="Refresh" CssClass='<%# Eval("InboxCss") %>' />
显示邮件网格
现在我们可以选择一个文件夹了,让我们显示该文件夹中的邮件。与之前一样,我们将使用数据绑定,所以我们首先创建我们的类方法进行绑定。假设我们已经设置了一个存储库,该方法使用 LINQ 查询按选定的文件夹进行过滤。
public IEnumerable<MailInfo> List(string folder)
{
return _Repository.MailInfos.Where(m => m.Folder == folder).ToList();
}
我们将创建一个 List.ascx 用户控件,其中包含一个连接到我们刚刚创建的 List
方法的 ObjectDataSource
的 ListView
。
<asp:ListView ID="MailList" runat="server" DataSourceID="MailListSource">
<LayoutTemplate>
<table class="mails">
<thead><tr><th>From</th><th>To</th>
<th>Subject</th><th>Date</th></tr></thead>
<tbody><tr runat="server" id="itemPlaceholder" /></tbody>
</table>
</LayoutTemplate>
<ItemTemplate>
<tr>
<td><%# HttpUtility.HtmlEncode(Eval("From"))%></td>
<td><%# HttpUtility.HtmlEncode(Eval("To"))%></td>
<td><%# HttpUtility.HtmlEncode(Eval("Subject"))%></td>
<td><%# HttpUtility.HtmlEncode(Eval("Date", "{0:MMM d, yyyy}"))%></td>
</tr>
</ItemTemplate>
</asp:ListView>
<asp:ObjectDataSource ID="MailListSource" runat="server"
TypeName="Mail.Controllers.MailController" SelectMethod="List">
<SelectParameters>
<nav:NavigationDataParameter Name="folder" />
</SelectParameters>
</asp:ObjectDataSource>
现在我们将我们的 List.ascx 拖到我们的 ASPX 页面上,使其出现在我们的文件夹列表下方。
<ItemTemplate>
<ul class="folders">
...
</ul>
<mail:List ID="List" runat="server"/>
</ItemTemplate>
如果我们运行我们的应用程序,在首次加载时将看不到任何邮件,尽管后续的文件夹选择确实正确地显示了邮件网格。
这是因为在首次加载时,文件夹尚未在 NavigationData
中设置,因此以空值传递给我们的 List
方法,并且没有返回邮件。我们可以通过在我们的 Display
方法中设置默认值来解决此问题(因为此方法在我们的 List
方法之前运行)。Display
方法的第一行已经初始化了文件夹(如果为空),所以我们只需要修改这一行,通过动态 Bag
属性将此值设置到 NavigationData
中。
StateContext.Bag.folder = folder = folder ?? "inbox";
查看 individual 邮件
现在我们可以显示所选文件夹中的邮件了,让我们打开一封邮件进行阅读。我们的方法与上面显示邮件网格的方法相同。所以我们首先将有我们的类方法,这次接收所选邮件的 ID。
public MailInfo Details(int id)
{
if (id == 0) return null;
return _Repository.MailInfos.Single(m => m.Id == id);
}
接下来,我们将创建一个 Details.ascx 用户控件,这次 NavigationDataParameter
指向所选邮件的 ID。
<asp:FormView ID="MailDetails" runat="server" DataSourceID="MailDetailsSource" RenderOuterTable="false">
<ItemTemplate>
<h1><%# HttpUtility.HtmlEncode(Eval("Subject"))%></h1>
<p><%# HttpUtility.HtmlEncode(Eval("From"))%></p>
<p><%# HttpUtility.HtmlEncode(Eval("To"))%></p>
<p><%# HttpUtility.HtmlEncode(Eval("Date", "{0:MMM d, yyyy}"))%></p>
<p><%# Eval("Message")%></p>
</ItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="MailDetailsSource" runat="server"
TypeName="Mail.Controllers.MailController" SelectMethod="Details">
<SelectParameters>
<nav:NavigationDataParameter Name="id" />
</SelectParameters>
</asp:ObjectDataSource
我们将我们的 Details.ascx 拖到页面上,放在 List.ascx 的下方。
<ItemTemplate>
<ul class="folders">
...
</ul>
<mail:List ID="List" runat="server"/>
<mail:Details ID="Details" runat="server"/>
</ItemTemplate>
代码正在使用所选邮件 ID,但是我们还没有在任何地方设置它。我们将采用与选择文件夹相同的方法,所以我们在上面的 List.ascx 中,将我们的 td
的内容替换为 NavigationHyperLink
,并用邮件 ID 填充 ToData
。
<nav:NavigationHyperLink ID="DetailsLink" runat="server"
ToData='<%# new NavigationData(){{ "id", Eval("Id") }} %>'
Direction="Refresh" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' />
如果我们运行我们的应用程序,在邮件网格中选择一行会正确地在网格下方显示邮件,但无论邮件属于哪个文件夹,突出显示的文件夹始终是收件箱。
这是因为我们邮件网格中的链接只传递了邮件 ID 而没有传递文件夹,所以选中的文件夹丢失并默认回到了显示收件箱已选中。我们可以通过更改 ToData
属性来包含文件夹来解决此问题,但有一个更好的方法:将 IncludeCurrentData
属性设置为 true
,这将传递当前的 NavigationData
以及 ToData
中指定的,因此当前选定的文件夹将被包含在内。
<nav:NavigationHyperLink ID="DetailsLink" runat="server"
ToData='<%# new NavigationData(){{ "id", Eval("Id") }} %>'
Direction="Refresh" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' IncludeCurrentData="true" />
整理用户体验
目前,邮件网格和 individual 邮件同时显示。我们需要在查看邮件时隐藏网格,反之亦然。为了解决这个问题,我们将创建另一个名为 layout
的 NavigationData
,并使用它来控制 List
和 Details
用户控件的可见性。
我们将此布局参数添加到 Display
方法,并使用它来设置两个属性以确定可见性设置。我们还将确保它默认显示列表,并将此默认值设置为 NavigationData
中,以便其他方法可以使用它,就像我们为文件夹所做的那样。
public object Display(string layout, string folder)
{
StateContext.Bag.layout = layout = layout ?? "list";
StateContext.Bag.folder = folder = folder ?? "inbox";
return new
{
ListVisible = layout == "list",
DetailsVisible = layout == "details",
InboxCss = folder == "inbox" ? "selected" : string.Empty,
ArchiveCss = folder == "archive" ? "selected" : string.Empty,
SentCss = folder == "sent" ? "selected" : string.Empty,
SpamCss = folder == "spam" ? "selected" : string.Empty,
};
}
然后,我们将一个布局 NavigationDataParameter
添加到页面 ObjectDataSource
,并将用户控件的 Visible
属性绑定到我们创建的两个属性。
<ItemTemplate>
<ul class="folders">
...
</ul>
<mail:List ID="List" runat="server" Visible='<%# Eval("ListVisible") %>'/>
<mail:Details ID="Details" runat="server" Visible='<%# Eval("DetailsVisible") %>'/>
</ItemTemplate>
如果我们运行我们的应用程序,我们的网格仍然有效,但选择邮件无效,因为 individual 邮件从未可见。这是因为布局目前始终设置为 _list_,而从未更改为 _details_。我们将通过更改邮件网格中的 NavigationHyperLink
来传递 _details_ 布局来解决此问题;并且,为了确保在选择文件夹时重置布局,我们将更改我们的文件夹 NavigationHyperLink
来传递 _list_ 布局。
<nav:NavigationHyperLink ID="DetailsLink" runat="server"
ToData='<%# new NavigationData(){{ "id", Eval("Id") }, { "layout", "details" }} %>'
Direction="Refresh" IncludeCurrentData="true" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' />
<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox"
ToData="<%$ NavigationData: folder=inbox, layout=list %>"
Direction="Refresh" CssClass='<%# Eval("InboxCss") %>' />
现在布局在 NavigationData
中,我们可以使用它来优化我们的代码性能。通过将其添加到我们的 List
和 Details
方法(因此也作为相应 ObjectDataSource
的 NavigationDataParameter
),我们可以检查它以确定是否值得调用存储库。
public IEnumerable<MailInfo> List(string layout, string folder)
{
if (layout != "list") return null;
return _Repository.MailInfos.Where(m => m.Folder == folder).ToList();
}
public MailInfo Details(string layout, int id)
{
if (layout != "details") return null;
return _Repository.MailInfos.Single(m => m.Id == id);
}
防止完全页面刷新
到目前为止,我们只有一个非常基本的应用程序,所有文件夹和邮件选择导航都通过正常的超链接完成。我们将通过使用 ASP.NET AJAX 来增强它,以便支持的客户端可以从更丰富的用户体验中受益。问题是 ASP.NET AJAX 基于回发模型工作,而我们目前从不回发。但是,将所有 NavigationHyperLink
的 PostBack
属性设置为 true
会使它们以回发模式工作,而不是作为正常的超链接(如果 JavaScript 被禁用,它们将继续作为超链接工作)。
<nav:NavigationHyperLink ID="Inbox" runat="server" Text="Inbox"
ToData="<%$ NavigationData: folder=inbox, layout=list %>"
Direction="Refresh" CssClass='<%# Eval("InboxCss") %>' PostBack="true" />
<nav:NavigationHyperLink ID="DetailsLink" runat="server"
ToData='<%# new NavigationData(){{ "id", Eval("Id") }, { "layout", "details" }} %>'
Direction="Refresh" IncludeCurrentData="true" Text='<%# HttpUtility.HtmlEncode(Eval("From"))%>' PostBack="true" />
如果我们运行我们的应用程序,我们将看到单击文件夹和邮件不再会导致 URL 更改,因为我们总是在回发。既然我们已经将链接更改为回发,我们就可以通过在页面上添加 ScriptManager
并将其 FormView
包装在 UpdatePanel
中,轻松地将它们更改为部分页面请求。
<asp:ScriptManager ID="ScriptManager" runat="server"/>
<asp:UpdatePanel ID="Content" runat="server" UpdateMode="Conditional">
<ContentTemplate>
<asp:FormView ID="Display" runat="server"
DataSourceID="DisplaySource" RenderOuterTable="false">
<ItemTemplate>
...
</ItemTemplate>
</asp:FormView>
<asp:ObjectDataSource ID="DisplaySource" runat="server"
TypeName="Mail.Controllers.MailController" SelectMethod="Display">
<SelectParameters>
<nav:NavigationDataParameter Name="layout" />
<nav:NavigationDataParameter Name="folder" />
</SelectParameters>
</asp:ObjectDataSource>
</ContentTemplate>
</asp:UpdatePanel>
如果我们运行我们的应用程序,我们将看到所有请求现在都是 AJAX 请求(如果 JavaScript 已启用),但如果 JavaScript 被禁用,它会恢复到正常的超链接导航。然而,使用 AJAX 的一个副作用是后退按钮不再起作用,因为只有在执行完整的页面刷新时才会自动将页面添加到历史记录中。另一个副作用是位置不再可书签化,因为包含我们正在查看的文件夹和/或邮件的信息不再存在于 URL 中,因此我们总是默认回到收件箱。我们将在下一节中解决这两个问题。
支持后退/前进并使位置可书签化
在上节中,我们添加了 AJAX 功能,但牺牲了浏览器历史记录和可书签化的位置。这可能被认为比禁用 JavaScript 的用户体验更差!幸运的是,我们可以使用 ASP.NET AJAX History 来恢复这两项功能。首先,我们将通过 ScriptManager
控件启用 ASP.NET AJAX History。
<asp:ScriptManager ID="ScriptManager" runat="server" EnableHistory="true" EnableSecureHistoryState="false"/>
接下来,我们需要在每次文件夹或布局更改时添加一个历史记录点。所以,我们将向页面 FormView
添加一个 DataBound
侦听器,因为每当这些数据发生更改时,它都会被自动调用。我们将调用导航框架中的 AddHistoryPoint
方法,因为它允许我们传递当前的 NavigationData
,即当前选定的文件夹和邮件 ID。
protected void Display_DataBound(object sender, EventArgs e)
{
if (ScriptManager.GetCurrent(this).IsInAsyncPostBack &&
!ScriptManager.GetCurrent(this).IsNavigating)
{
StateController.AddHistoryPoint(this, new NavigationData(true), null);
}
}
如果我们运行我们的 Web 应用程序,我们将看到每次执行 AJAX 请求时,URL 都会附加一个包含当前选定文件夹和邮件 ID 的哈希值,并且浏览器历史记录中会添加一个项目。然而,书签和后退按钮都不起作用;前者仍然总是默认回到收件箱,而后者根本不更改显示。
让我们向 ScriptManager
的 Navigate
事件添加一个侦听器并执行历史导航。这会将 URL 的哈希部分中包含的状态信息恢复到 NavigationData
,随后我们所有的数据绑定控件都会使用此新数据刷新自身。
protected void ScriptManager_Navigate(object sender, HistoryEventArgs e)
{
if (ScriptManager.IsInAsyncPostBack)
{
StateController.NavigateHistory(e.State);
}
}
如果我们运行我们的 Web 应用程序,我们将看到书签现在可以正常工作。但奇怪的是,单击后退按钮似乎仍然没有效果。这是因为我们还没有告诉 ASP.NET AJAX UpdatePanel
的内容已更新。我们将在每次页面 FormView
数据绑定时手动更新 UpdatePanel
。
protected void Display_DataBound(object sender, EventArgs e)
{
Content.Update();
...
}
结论
我们使用导航框架为 ASP.NET Web Forms 构建了 Knockout Webmail 示例的渐进增强版本。我们没有编写一行 JavaScript,它是 100% 服务器端代码。我们遵循了 DRY 原则,例如,我们没有复制代码来处理 JavaScript 的开启/关闭场景。代码行数与原始 Knockout 示例大致相同,但我们的应用程序完全可访问且对 SEO 友好。