2019 年 1 月

第 34 卷,第 1 期

[领先技术]

Blazor 中基于模板的组件

作者 Dino Esposito | 2019 年 1 月

Dino Esposito自 2018 年初发布 Blazor 的第一个公共版本至今已近一年。该平台设计为能够在主机浏览器中运行 C# 和 .NET 代码的客户端 Web 框架,已在多个方向得到改进。

Blazor 仍然是处理从任何可访问的后端服务下载的数据的普通客户端框架,但也修改了其基础,以通过 SignalR 连接完全从服务器中运行。在我看来,最近引入的 SignalR Azure 服务只是确认了 Microsoft 想要将 Blazor 越来越多地作为新式开发平台使用。服务器应用程序和大量客户端之间的可缩放云服务可保证在服务器上有效地托管 .NET Core 代码,并通过 C#(而不是 JavaScript)的中介在客户端上以交互方式运行。

作为客户端 Web 框架,Blazor(其中的一部分将与即将推出的 ASP.NET Core 3.0 版本一起提供)无法在没有组件的情况下完成其工作。在 0.6.0 版本的框架中,Blazor 团队引入了一个特殊风格的组件:基于模板的组件。在本文中,我通过将我之前的两个专栏中介绍的键入提示示例更新为基于模板的组件来探讨其工作方式。

向 Blazor 添加模板

简单的组件可以通过属性进行配置,但实际组件通常需要更多的呈现灵活性,而模板是实现此目的的规范方法。例如,假设有一个数据网格组件。使用 Razor 或任何其他数据绑定基础结构,可以轻松地构建链接到已知数据源的数据表。以下是如何从数据项的集合中构建 HTML 表的一个简单示例:

<table>
@foreach(var item in Items)
{
  <tr>
    <td>@item.FirstName</td>
    <td>@item.LastName</td>
  </tr>
}
</table>

它既快速又简单,但没有太多可以重复使用的功能。现在,假设你通过添加页眉、页脚或在顶部添加搜索栏并在底部添加页导航栏使该网格结构更为丰富。搜索栏和页导航栏背后的图形布局和代码不会随搜索和分页的实际数据而更改。但在你每次使用数据网格显示不同类型的数据时,都需要重写每个搜索栏和页导航栏。

在此最小抽象层次中,尽管各种内部部件背后的核心代码几乎相同,但国家/地区的网格和客户的网格是完全不同的实体。基于模板的组件解决了这一特定场景,并提供了一种方法,使单个 DataGrid 组件能够使用一个基本代码来显示、搜索和分页国家/地区和客户。

假设你想要在 Blazor 前端视图中开发一个丰富的网格组件。图 1 显示了全新的基于模板的 DataSource Blazor 组件的源代码。

图 1 DataSource 模板组件

@typeparam TItem
@inject HttpClient HttpExecutor
<div style="border: solid 4px #111;">   
  <div class="table-responsive">
    <table class="table table-hover">
      <thead>
        <tr>
          @if (HeaderTemplate != null)
          {
            @HeaderTemplate
          }
        </tr>
      </thead>
      <tbody>
        @foreach (var item in Items)
        {
          <tr>
           @RowTemplate(item)
          </tr>
        }
      </tbody>
      <tfoot>
        <tr>
          @if (FooterTemplate != null)
          {
            @FooterTemplate(Items)
          }
        </tr>
      </tfoot>
    </table>
  </div>
</div>
@functions {
  [Parameter]
  RenderFragment HeaderTemplate { get; set; }
  [Parameter]
  RenderFragment<TItem> RowTemplate { get; set; }
  [Parameter]
  RenderFragment<IList<TItem>> FooterTemplate { get; set; }
  [Parameter]
  IList<TItem> Items { get; set; }
}

如你所见,DataSource 组件是围绕 HTML 表的框架构建的,其中正文是通过循环访问绑定集合中的数据记录顶部的表行来构造的。页眉和页脚定义为表行,其中包含客户端页面留下的实际内容。客户端页面可使用通过 @functions 部分定义的公共接口与组件进行交互并对其进一步自定义。到目前为止,你已定义了三个模板:HeaderTemplate、FooterTemplate 和 RowTemplate,以及一个名为“项目”的属性,它充当组件的实际数据源和数据提供程序。

可以将基于模板的组件绑定到固定的数据类型,也可以将组件绑定到以声明方式指定的泛型数据类型。实际上,可以使用同一网格组件来显示、搜索和分页不同的数据集合。若要使组件具有此功能,在 Blazor 中,使用 @typeparam 指令,如下所示:

@typeparam TItem

对 Razor 源代码中找到的 TItem 名字对象的任何引用都被视为对 C# 泛型类的动态确定类型的引用。通过 TItem 属性指定组件中使用的实际类型。图 2**** 演示了客户端页声明 DataSource 模板组件的方法。

图 2 声明 DataSource 模板组件

<DataSource Items="@Countries" TItem="Country">
  <HeaderTemplate>
    <th>Name</th>
    <th>Capital</th>
  </HeaderTemplate>
  <RowTemplate>
    <td>@context.CountryName</td>
    <td>@context.Capital</td>
  </RowTemplate>
  <FooterTemplate>
    <td colspan="2">
      @context.Count countries found.
    </td>
  </FooterTemplate>
</DataSource>

泛型类型属性的名称(上面的代码段中的 TItem)将匹配通过组件源代码中的 @typeparam 指令声明的类型参数的名称。请注意,泛型类型参数通常只是由框架推断,未必由其指定。

编程模板方面

Blazor 模板是 RenderFragment 类型的实例。换句话说,它是由 Razor 视图引擎呈现的标记区块,可被视为 .NET 类型的普通实例。大多数模板是无参数模板,但也可以将其变为泛型模板。泛型模板将接收指定类型的实例作为参数,并可使用该内容来呈现其输出。在图 2 中的示例中,页眉模板是无参数模板,但行模板和页脚模板是泛型模板。

具体而言,RowTemplate 属性采用 TItem 的实例,而 FooterTemplate 属性接收 TItem 实例的集合。如果需要,还可以定义一个模板来接收固定类型的实例。例如,可仅向 FooterTemplate 传递一个整数,表示在页面中呈现的项目数。如以下代码中所示:

RenderFragment<TItem> RowTemplate { get; set; }
RenderFragment<IList<TItem>> FooterTemplate { get; set; }

可在客户端页面中将模板设置为选择性地实现,方法是在使用前通过确认是否存在的普通检查包装其调用。以下是 Blazor 组件使其模板属性之一变为可选的方法:

@if (FooterTemplate != null)
{
  @FooterTemplate(Items)
}

在呈现参数模板时,使用“上下文”隐式名称来引用模板的参数。例如,通过 RowTemplate 属性呈现表行时,使用上下文参数来引用要呈现的项,如下所示:

<RowTemplate>
  <td>@context.CountryName</td>
  <td>@context.Capital</td>
</RowTemplate>

请注意,可以通过模板的上下文属性以声明方式更改上下文参数的名称,如下所示:

<RowTemplate Context="dataItem">
  <td>@dataItem.CountryName</td>
  <td>@dataItem.Capital</td>
</RowTemplate>

因此,可在同一 Blazor 视图中使用 DataSource 泛型组件,以填充不同数据类型的数据网格,如图 3**** 中所示。编写的代码结构总结如下:

<DataSource Items="@Countries" TItem="Country">
  ...
</DataSource>
<DataSource Items="@Forecasts" TItem="WeatherForecast">
  ...
</DataSource>

DataSource 泛型组件
图 3 DataSource 泛型组件

围绕显示网格可能具有的任何外围标记和代码(例如,页导航栏、搜索栏、排序按钮)都被完全重复使用。在我个人看来,这一更高级别的标记自定义让我想起过去使用的 ASP.NET Web 窗体,其中自定义服务器控件通过模板和自定义属性来定义其自己的特定于域的语言。这使大纲显示所需的 UI 的过程变得流畅和顺利。MVC 的出现和后续向普通客户端 Web 开发的转换让我们更进一步了解 HTML 机制,使其不再那么抽象。框架组件只是尝试恢复该级别的表达能力。

重写 TypeAhead Blazor 组件

上个月 (msdn.com/magazine/mt830376),我介绍了完全在 Blazor 中编写的键入提示组件,它提供了与热门的、基于 JavaScript 的 Twitter TypeAhead 插件相同的功能。在该实现中,负责返回提示的服务器终结点实际上被强制返回一个压缩和泛型用途的数据传输对象,其中包含三个属性:在查询中标识的对象的唯一 ID、显示文本、在下拉框中为每个提示显示的标记的第三个属性呈现。

在上个月的演示中,我使用了键入提示组件来查找国家/地区名称。服务器终结点返回了由 ISO 国家/地区代码、国家/地区名称和包含国家/地区名称、首都和洲的 HTML 代码段组成的对象。但是,HTML 代码段是完全由服务器实现控制的,且使用键入提示组件的客户端页面的作者没有机会控制 HTML 代码段的布局。目前为止我所做的仅是,提供一个在基本演示以外了解运行中的基于模板的组件的理想场景。

图 4 显示了新的键入提示组件的 HTML 布局。它与上个月使用的代码相同,但有一处值得注意的例外:由提示提供程序返回的数据的格式。在原始实现中,提示提供程序(示例控制器)返回了数据传输对象,因此,它只是控制由用户选中的实际数据。让提示提供程序返回国家/地区的列表(而不是定制的键入提示项的列表)使 Blazor 组件能够为调用方公开 ItemTemplate 属性,以决定每个下拉菜单项的布局。接收国家/地区的列表使组件能够引发其 OnSelection 事件,并将选定的数据项的实例直接传递给任何相关的侦听器。

图 4 基于模板的 TypeAhead 组件

<div>
  <div class="input-group">
    <input type="text" class="@Class"
           oninput="this.blur(); this.focus();"
           bind="@SelectedText"
           onblur="@(ev => TryAutoComplete(ev))" />
    <div class="input-group-append">
      <button class="btn dropdown-toggle"
              type="button"
              data-toggle="dropdown"
              style="display: none;">
        <i class="fa fa-chevron-down"></i>
      </button>
      <div class="dropdown-menu @(_isOpen ? "show" : "")"
           style="width: 100%;">
        <h6 class="dropdown-header">
          @Items.Count item(s)
        </h6>
        @foreach (var item in Items)
        {
          <a class="dropdown-item"
           onclick="@(() => TrySelect(item))">
            @ItemTemplate(item)
          </a>
        }
      </div>
    </div>
  </div>
</div>

确切地说,通过编码选定数据项的唯一 ID 所收集的隐藏字段仍有必要,这可确保:一旦在 HTML 窗体中使用键入提示组件,它可以通过浏览器的普通通道成功地发布其内容。但现在,可以在键入提示组件边界之外更为自然地置入隐藏的字段,并完全由客户端开发人员控制。

键入提示组件定义名为 ItemTemplate 的模板属性,定义方式如下:

[Parameter]
RenderFragment<TItem> ItemTemplate { get; set; }

TItem 参数由调用方定义。总之,这是设置具有项模板的键入提示组件的标记:

<Typeahead TItem="Country"
           url="/hint/countries"
           name="country"
           onSelectionMade="@ShowSelection">
  <ItemTemplate>
    <span>@context.CountryName</span>&nbsp;
    <b>(@context.ContinentName)</b>
    <small class="pull-right">@context.Capital</small>
  </ItemTemplate>
</Typeahead>

TItem 参数告诉组件它将处理类型为“国家/地区”的对象并接收来自指定的 URL 的提示。无论何时接收基于输入的文本的提示,都将使用 ItemTemplate 部分中标记的动态下拉列表中呈现这些提示。按照设计,项模板接收当前数据项的实例并构建 HTML 行。不用说,下拉列表的形状现在完全由页面作者控制。而这是一个巨大的进步(请参阅图 5****)。

国家/地区下拉列表
图 5 国家/地区下拉列表

总结

TypeAhead 组件是在 Blazor 中编程的一个有趣的组件示例。它集成了通过 HTTP 协议检索数据的逻辑,以及模板和元素(输入字段和下拉列表)之间的一些内部交互。然后,它通过事件与外界进行通信。

Blazor 作为一个吸引人的实验而诞生,尽管它的发展方向并非完全清晰且可能在未来几个月中有所更改,但其发展速度非常快。此时,团队的主要目标是提供通过 WebAssembly 在浏览器中运行 Blazor 客户端的支持。

同时,在 ASP.NET Core 中嵌入 Blazor 具有诸多优点,并不只是应用程序加载更快。将集成到 ASP.NET Core 3.0 中的 Blazor 组件将重命名为 Razor 组件。只是选择了不同的名称,以使一切清晰明了,并可避免混淆浏览器中运行的组件与在生成客户端输出的服务器上运行的组件。无论如何,不管你是在服务器上运行还是在客户端上运行,组件模型应保持不变。


Dino Esposito 在他 25 年的职业生涯中撰写了超过 20 本的书籍和 1000 篇的文章。Esposito 不仅是舞台剧《事业中断》的作者,还是 BaxEnergy 的数字策略分析师,正忙于编写有助于建设环保世界的软件。可以在 Twitter 上关注他 (@despos)。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Daniel Roth