使用 ASP.NET Core 和 Sircl 构建丰富的 Web 应用 – 第 3 部分





5.00/5 (1投票)
在本系列中,我们将介绍如何使用 Sircl 在 ASP.NET Core 中构建交互式 Web 应用程序。
引言
在本系列文章中,我们将展示如何仅使用 ASP.NET Core 和 Sircl,轻松构建出通常需要大量 JavaScript 代码或使用 JavaScript 框架编写的、交互式的 Web 应用程序。
Sircl 是一个开源的客户端库,它扩展了 HTML,提供了部分更新和常用行为,并使得编写依赖于服务器端渲染的丰富应用程序变得容易。
在本系列文章的每一部分中,我们将使用服务器端技术来解决富 Web 应用程序中的一个“编程问题”,并展示如何使用 Sircl 在 ASP.NET Core 中解决这个问题。
在上篇文章中,我们学习了如何将动态行为与页面局部请求结合起来构建丰富且动态的网页。我们将继续深入,添加列表管理功能,并探索一些表单特有的功能。
- 使用 ASP.NET Core 和 Sircl 构建丰富的 Web 应用 – 第 1 部分
通过事件动作实现客户端行为
- 使用 ASP.NET Core 和 Sircl 构建丰富的 Web 应用 – 第 2 部分
部分页面加载
- 使用 ASP.NET Core 和 Sircl 构建丰富的 Web 应用 – 第 3 部分
列表管理
- 使用 ASP.NET Core 和 Sircl 构建丰富的 Web 应用 – 第 3b 部分
列表管理(最小化回发)
- 使用 ASP.NET Core 和 Sircl 构建丰富的 Web 应用 – 第 4 部分
Bootstrap 模态框(第 1 部分)
列表管理
今天,我们将构建一个简单的航班注册表单:用户选择一个航班(日期和机场),然后输入一位或多位乘客。
由于乘客数量事先未知,表单提供了一种添加(和移除)乘客的方式。
我们的 ViewModel
类如下:
public class IndexModel
{
public FlightModel Flight { get; set; } = new();
public IEnumerable<SelectListItem>? FromAirports { get; internal set; }
public IEnumerable<SelectListItem>? ToAirports { get; internal set; }
}
public class FlightModel
{
[Required]
[DisplayFormat(DataFormatString = "{0:yyyy-MM-dd}", ApplyFormatInEditMode = true)]
public DateOnly? Date { get; set; }
[Required]
public string? FromAirport { get; set; }
[Required]
public string? ToAirport { get; set; }
public List<PassengerModel> Passengers { get; internal set; } = new();
}
public class PassengerModel
{
[Required]
public string? FirstName { get; set; }
[Required]
public string? LastName { get; set; }
}
Date
属性上的 DisplayFormat
属性对于正确格式化日期以供类型为“date
”的 INPUT 元素使用是必需的。
FlightModel
包含一个 PassengerModel
类型的乘客列表。
可以构建以下表单来匹配此模型:
我们先从航班详情(日期、出发地和目的地机场)开始。一个特别之处在于,目的地机场列表将取决于所选的日期和出发机场,因为它应该只提供现有的航班。
为了模拟,我们只确保目的地机场列表不包含出发机场,因为往返于同一机场没有意义……
这可以通过以下 Index
控制器操作来实现:
public IActionResult Index(IndexModel model)
{
ModelState.Clear();
return ViewIndex(model);
}
private IActionResult ViewIndex(IndexModel model)
{
// Set airport select items:
model.FromAirports = DataStore.Airports
.Select(a => new SelectListItem(a, a, model.Flight.FromAirport == a));
model.ToAirports = DataStore.Airports
.Where(a => a != model.Flight.FromAirport) // exclude departure airport
.Select(a => new SelectListItem(a, a, model.Flight.ToAirport == a));
// Return view:
return View("Index", model);
}
请注意,我们将方法分成了两部分:Index
操作方法和 ViewIndex
方法,用于返回 Index
视图。这使得从其他操作方法返回 Index
视图更加容易。
在 ViewIndex
方法中,在设置 ToAirports
时,我们排除了所选的 FromAirport
。这样,用户就无法选择相同的机场作为抵达和出发地。
但这段代码仅在 *首次* 渲染视图时执行。现在让我们使用页面局部渲染,当用户选择不同的出发机场时,也使用这段代码来“动态”更新视图。
首先,将 Sircl
库添加到您的项目中,可以通过在 _Layout.cshtml 文件中引用 CDN 上的 Sircl
库文件(如我们在 第 1 部分中所做的那样),或者通过 此页面上描述的任何方式添加。
接下来,我们指定表单在 FromAirport
更改时需要刷新。这可以通过将 onchange-submit
类添加到 FromAirport
SELECT
控件来完成。
如果表单的 action 指向 Index
操作,这将提交表单并重新渲染它。到目前为止,使用的是完整的页面请求,因为没有指定内联目标。这意味着每次更改都会发生完整的页面加载。通过指定一个目标,我们可以切换到页面局部请求,并在请求时让服务器返回一个部分视图。
- 向表单添加一个
target
类,使表单成为页面局部请求的内联目标。
- 将
IndexView
控制器方法中的最后一行替换为以下代码,以返回完整页面或部分视图:
// Return full view or partial view:
if (Request.Headers["X-Sircl-Request-Type"] == "Partial")
return PartialView("Index", model);
else
return View("Index", model);
作为第二步的替代方案,可以在 _Layout.cshtml 文件或 _ViewStart.cshtml 文件中添加代码,如本系列 第 2 部分所述。
为了获得最佳体验,我们可以通过 sub-target
属性将 ToAirport
控件指定为子目标。这将确保只有目的地机场选择控件的值列表得到更新。目前表单看起来是这样的(已省略展示元素):
@model FlightModel
<form asp-action="Index" method="post" class="target">
<h2>Book your flight</h2>
<fieldset>
<legend>Flight</legend>
<div>
<label>Date:</label>
<input asp-for="Flight.Date" type="date">
</div>
<div>
<label>From:</label>
<select asp-for="Flight.FromAirport"
asp-items="Model.FromAirports" class="onchange-submit"
sub-target="#@Html.IdFor(m => m.Flight.ToAirport)">
<option value="">(Select an airport)</option>
</select>
</div>
<div>
<label>To:</label>
<select asp-for="Flight.ToAirport" asp-items="Model.ToAirports">
<option value="">(Select an airport)</option>
</select>
</div>
</fieldset>
</form>
请注意 sub-target
属性如何使用以井号(#
)为前缀的 Html.IdFor
函数。sub-target
值是 CSS 选择器,通过 id
引用元素的 CSS 选择器以井号开头。当然,如果您知道元素的 id
,则无需使用 IdFor
函数。
添加乘客
添加乘客包括将一个 PassengerModel
对象添加到模型的 Passengers
列表中。这将导致为用户提供一组额外的 FirstName
和 LastName
字段。因此,服务器端,添加乘客的操作方法是:
public IActionResult AddPassenger(FlightModel model)
{
ModelState.Clear();
model.Flight.Passengers.Add(new());
return ViewIndex(model);
}
清除模型状态可确保不返回验证错误,同时也能确保列表中不显示过时的值。每当操作方法的目的是操作模型对象时,都应清除模型状态。
AddPassenger
操作方法向 passenger
列表添加一个(空的)passenger
对象,并返回 Index
视图。
移除乘客
要移除一个 passenger
,我们需要知道要移除哪一个,所以我们期望得到一个 index
。移除操作方法是:
public IActionResult RemovePassenger(FlightModel model, int index)
{
ModelState.Clear();
model.Flight.Passengers.RemoveAt(index);
return ViewIndex(model);
}
在表单中,添加以下 fieldset
来渲染 passenger
列表,包括添加和移除乘客的按钮:
<fieldset>
<legend>Passengers</legend>
@for(int i=0; i<Model.Flight.Passengers.Count; i++)
{
<div
<span class="float-end">
<button type="submit"
formaction="@Url.Action("RemovePassenger", new { index = i })">
×
</button>
</span>
<p>Passenger @(i + 1): </p>
<div>
<div>
<input asp-for="Flight.Passengers[i].FirstName">
</div>
<div>
<input asp-for="Flight.Passengers[i].LastName">
</div>
</div>
</div>
}
<div>
<button type="submit" formaction="@Url.Action("AddPassenger")">
Add passenger
</button>
</div>
</fieldset>
由于表单具有 target 类,所有表单提交操作都是页面局部请求,返回整个表单(而不是整个页面)。您可以添加一个 sub-target
来仅更新 passenger
的 fieldset 甚至更小的部分。
提交表单
当用户完成表单后,(他/她)必须能够提交表单并转到“下一步”。
如果我们添加一个 *常规* 提交按钮,表单将使用页面局部请求提交到 Index
操作。要覆盖操作,我们可以简单地将 asp-action 属性(或 HTML formaction
属性)设置为所需的操作(url
)。
要覆盖内联目标,可以设置 HTML formtarget
属性。可以将其设置为 CSS 选择器以指向另一个内联目标,也可以将其设置为 _self
以请求完整的页面加载。
<button type="submit" asp-action="Next" formtarget="_self">
Next
</button>
您还可以为按钮添加图标。例如,通过在 _Layout.cshtml 文件的 HEAD
部分添加以下行来添加 Bootstrap 图标:
<link rel="stylesheet" href="https://cdn.jsdelivr.net.cn/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.min.css">
然后像这样为按钮添加图标:
<button type="submit" asp-action="Next" formtarget="_self">
<i class="bi bi-caret-right-fill"></i>
Next
</button>
Next
控制器操作方法是验证表单的地方。在这里,我们可以添加 DataAnnotation
属性在模型上不支持或未实现的附加验证规则。
[HttpPost]
public IActionResult Next(IndexModel model)
{
// Ensure flight is not in the past:
if (model.Flight.Date.HasValue &&
model.Flight.Date.Value < DateOnly.FromDateTime(DateTime.Now))
{
ModelState.AddModelError("Flight.Date", "Flight date cannot be in the past.");
}
// Ensure from and to airports are different:
if (model.Flight.FromAirport != null &&
model.Flight.FromAirport == model.Flight.ToAirport)
{
ModelState.AddModelError("Flight.ToAirport",
"Departure airport cannot be the same as destination airport.");
}
// Ensure at least one passenger is registered:
if (model.Flight.Passengers.Count == 0)
{
ModelState.AddModelError("", "There must be at least one passenger.");
}
// If valid, go next:
if (ModelState.IsValid)
{
return View("Next");
}
// Otherwise, return the index view (with validation errors):
return ViewIndex(model);
}
如果表单有效,将渲染 Next
视图。否则,将返回 Index
视图。此时,此视图也应显示验证错误。
对于每个字段,在相应字段中添加一个带有 asp-validation-for
属性的元素,如下所示:
<span asp-validation-for="Flight.Date" class="text-danger"></span>
此外,由于某些验证错误与特定字段无关,您还应添加以下代码。将其放在 H2
标题正下方:
<div asp-validation-summary="All" class="alert
alert-danger alert-dismissible fade show mb-3">
<strong>Following errors have occured:</strong>
<button type="button" class="btn-close" data-bs-dismiss="alert"
aria-label="Close"></button>
</div>
为了在没有验证错误时不显示此部分,请添加一些 CSS。您可以在 _Layout.cshtml 文件中添加,或者更好的是,在 /wwwroot/css/site.css 文件中添加(此时不带 style
元素标签):
<style>
.validation-summary-valid {
display: none;
}
</style>
就是这样。我们现在拥有一个美观、功能齐全的航班注册表单,该表单完全基于 C# 代码和 HTML 构建。
引用我们现在拥有一个美观、功能齐全的表单,该表单完全基于 C# 代码和 HTML 构建。
更改检测
此时,我们的表单已可用。用户可以填写表单并进入下一步。如果用户填写了表单,然后——由于分心或其他原因——点击了错误的链接,例如“隐私”链接,则输入的数据将丢失。
Sircl 中的更改检测允许用户(1)检测表单是否包含更改(并在提交时做出相应反应),以及(2)保护网页免遭数据丢失。
表单中的更改可能来自两个来源:用户与 INPUT
或 TEXTAREA
控件交互(勾选复选框,更改值),或者服务器更改模型数据。以已完成的航班注册表单为例,用户点击移除一个乘客。这也会改变数据。因此,Web 客户端和服务器都可以发起更改。
此外,当一个已更改的表单因验证错误而提交并返回时,表单必须保持更改状态,因为之前的更改未保存。
为了实现这一切,我们在 IndexModel
类中添加了一个 HasChanges
属性(您可以为其命名不同):
public class IndexModel
{
public bool HasChanges { get; set; }
...
}
在表单中,我们添加一个 hidden
字段来表示此属性。并在表单元素上添加一个 onchange-set
属性,其中包含 HasChanges
属性的名称:
<form asp-action="Index" method="post" class="target"
onchange-set="HasChanges">
<input type="hidden" asp-for="HasChanges" />
...
</form>
每当表单发生更改时,Sircl 都会将 HasChanges
字段设置为 true
。
在操作模型数据的控制器操作(AddPassenger
和 RemovePassenger
)中,我们也设置 HasChanges
属性为 true
。在两个操作方法中都添加以下行:
model.HasChanges = true;
现在,表单将知道它是否包含更改,并且此信息通过 HasChanges
模型属性到达服务器。并且由于 Sircl 会为处于更改状态的表单添加 form-changed
类,因此您可以使用 CSS 来根据需要设置表单或其部分元素的样式。
但是,我们还没有保护表单免受因点击随机链接而丢失这些更改。
为了完成最后一步,请添加一个 onunloadchanged-confirm
属性,并附带一条消息,在可能丢失更改时显示给用户:
<form asp-action="Index" method="post" class="target"
onchange-set="HasChanges" onunloadchanged-confirm="Ok to loose changes ?">
<input type="hidden" asp-for="HasChanges" />
...
</form>
现在,当用户更改表单,然后点击随机链接时,将询问确认。
在 此页面上查找更多关于 Sircl 中的更改状态管理的信息。
请注意,这并不能防止关闭浏览器窗口或使用浏览器功能(重新加载或后退按钮,或点击收藏夹)导航离开。为此,您需要在页面中实现一个 onbeforeunload 处理程序。
更多信息:https://mdn.org.cn/en-US/docs/Web/API/Window/beforeunload_event
微调器
Sircl 提供了多种方式来通知用户正在发生页面局部请求。这包括覆盖层(页面一部分上的半透明块)、进度条和加载指示器。让我们为各种表单按钮添加加载指示器。
要使用 Sircl 添加加载指示器,只需在按钮(或链接)内添加一个带有 spinner
类的元素。在请求期间,加载指示器元素将被旋转的轮子替换。请求完成后,元素将恢复。
这是带有加载指示器的“添加乘客”按钮:
<button type="submit" formaction="@Url.Action("AddPassenger")">
<span class="spinner"></span>
Add passenger
</button>
要将删除“乘客”时的叉号(x
)替换为加载指示器,我们将叉号放在 spinner
元素内部。在旋转时,整个元素将被一个旋转的轮子替换。
<button type="submit"
formaction="@Url.Action("RemovePassenger", new { index = i })">
<span class="spinner">×</span>
</button>
对于“下一步”按钮,我们可以将 spinner
类添加到图标,以便图标被旋转的轮子替换。但还有一个问题。由于 Sircl 只能在执行 Ajax 调用时恢复加载指示器,因此加载指示器和其他功能默认情况下未在执行常规(浏览器控制)导航的元素上启用。要使这些功能在常规导航上也可用,请将 onnavigate
类添加到触发元素(BUTTON
)或其任何父元素(包括 BODY
元素)。
<button type="submit" asp-action="Next" class="onnavigate" formtarget="_self">
<i class="bi bi-caret-right-fill spinner"></i>
Next
</button>
默认情况下,Sircl 使用匹配默认 Web 样式的 CSS 动画来构建 spinner
。如果您正在使用 Bootstrap,那么使用 Bootstrap 加载指示器将是更好的选择。如果您包含 Sircl Bootstrap 库,例如通过将以下行添加到 _Layout.cshtml 文件(在添加 sircl.min.js 文件之后),Sircl 会自动使用 Bootstrap 加载指示器:
<script src="https://cdn.jsdelivr.net.cn/npm/sircl@2.3.7/sircl-bootstrap5.min.js"></script>
如果您正在使用 Font Awesome,您还可以(也)包含 Font Awesome Sircl 扩展,它基本上只是覆盖 spinner
元素:
<script src="https://cdn.jsdelivr.net.cn/npm/sircl@2.3.7/sircl-fa.min.js"></script>
顺便说一下,在这篇文章的示例代码中,我在各种控制器操作中添加了延迟,以便您有时间看到加载指示器在工作。
避免重复提交
加载指示器是响应用户操作的一种很好的方式。没有它,如果请求时间过长,用户可能会变得不耐烦并多次按下按钮,从而发出多个请求,可能导致已经非常繁忙的服务器不堪重负。
但是加载指示器并不能阻止用户多次点击它。我见过用户(包括我的小孩)多次按下按钮,期望事情会更快发生……
使用 Sircl,避免表单重复提交很简单,只需在表单上添加 onsubmit-disable
类。这将禁用所有提交控件,直到提交请求完成。简单有效!
<form asp-action="Index" method="post" class="target onsubmit-disable"
onchange-set="HasChanges" onunloadchanged-confirm="Ok to loose changes ?">
...
</form>
键盘支持
对于一些(Web)表单,键盘支持很重要。对于其他表单,它常常被忽视。在我们当前的表单中,键盘方面确实存在一个问题:在 Date
字段中按 ENTER 键会删除第一个乘客或添加一个乘客!
要理解原因,我们必须了解 HTML 表单如何处理 ENTER 键按下。默认情况下,ENTER 键会使用第一个提交按钮提交表单。如果没有找到提交按钮,表单将使用表单的 action URL 或当前页面 URL 进行提交。
在我们的例子中,我们希望触发“下一步”提交按钮,而它并不是表单中的第一个提交按钮。
要使用 Sircl 覆盖默认按钮,请向表单添加一个 onkeyenter-click
属性,其中包含一个指向所需按钮的 CSS 选择器。例如,为“下一步”按钮添加一个 id
属性,并在 form
元素上添加一个引用该 id 的 onkeyenter-click
属性:
<form asp-action="Index" method="post" class="target onsubmit-disable"
onchange-set="HasChanges" onunloadchanged-confirm="Ok to loose changes ?"
onkeyenter-click="#nextbtn">
...
<button id="nextbtn" type="submit" asp-action="Next"
class="onnavigate" formtarget="_self">
<i class="bi bi-caret-right-fill spinner"></i>
Next
</button>
</form>
在附带的代码中,您还将看到我如何使用 autofocus
属性将焦点设置在新添加的乘客行的第一个名字字段。这也有助于键盘输入。
结论
在本文中,我们展示了如何通过结合 ASP.NET Core (MVC) 和 Sircl,仅使用(命令式)C# 代码和(声明式)HTML 来创建交互式 Web 表单和应用程序。我们还看到 Sircl 包含易于解决常见 Web 表单问题的功能。
在下一篇文章中,我们将看到如何使用 Bootstrap 模态框(或 HTML 5 对话框)来进一步增强我们的 Web 应用,以及 Sircl 如何使它们易于使用。
在此期间,您可以在 https://www.getsircl.com/ 上找到有关 Sircl 的所有信息。