2018 年 11 月

第 33 卷,第 12 期

领先技术 - Blazor 自定义组件

作者 Dino Esposito

Dino Esposito采用 Blazor 编程,自然而然就会广泛使用组件。在 Blazor 中,组件是根据某状态实现某 UI 呈现逻辑的 .NET 类。Blazor 组件非常接近于即将发布的 W3C 规范中的 Web 组件概念,以及单页应用程序 (SPA) 框架中的相似实现。Blazor 组件由 HTML、C# 和交互 JavaScript 代码和 CSS 组成,它们共同形成了一个具有通用编程接口的元素。Blazor 组件可以触发事件和公开属性,操作方式与 HTML DOM 元素大致相同。

在我最近发表的关于 Blazor 的文章中,我介绍了两个专用组件。第一个组件提供了综合 UI,用于键入某文本、运行查询和触发外部事件(将检索到的数据用作参数)。示例中的第二个组件是不可自定义的定制网格,用于处理事件、抓取数据和填充内部表。收集查询文本的输入字段通过预先键入 Bootstrap 扩展提供了自动补全功能。在本文中,我从这个角度进行生成,并介绍了提供预先键入功能的完全基于 Blazor 的组件的设计和实现。在任务结束时,将拥有只依赖核心 Bootstrap 4 捆绑包的可重用 CSHTML 文件。

TypeAhead 的公共接口

目标是生成 Blazor 本机组件,它的行为类似于经典 typeahead.js 组件(请访问 twitter.github.io/typeahead.js)的扩展版本。最终的组件存在于文本框和下拉列表中间。当用户在文本输入字段中键入时,此组件会查询远程 URL,并获取可能要输入值的提示。JavaScript typeahead 虽然提供建议,但不强制用户接受任何建议。换言之,仍可接受与建议不同的自定义文本。

不过,在某些情况下,你希望用户先输入文本,再从有效条目列表中进行选择。这就好比是,需要在其中提供国家/地区名称或客户名称的输入字段。世界上有超过 200 个国家/地区,软件系统中通常有数百个(或更多)客户。真的应该使用下拉列表吗?更智能的预先键入组件可以先让用户键入字词“United”(打个比方),再显示匹配国家/地区列表,如“United Arab Emirates”、“United Kingdom”和“United States”。如果用户键入任何自定义文本,代码会自动清除缓冲区。

另一个相关问题:如果未提供自定义文本功能,很可能还需要使用代码。理想情况下,需要用户先键入国家/地区名称或客户名称,你再从托管表单中发布国家/地区 ID 或客户唯一标识符。在纯 HTML 和 JavaScript 中,需要使用其他某段脚本,用于添加隐藏字段,并管理来自 typeahead 插件的选择。Blazor 本机组件将所有这些功能都隐藏在后台。接下来将介绍如何编写此类组件。

组件设计

Typeahead 组件旨在成为用于 HTML 表单的附加输入字段。它由两个标准输入字段组成,一个是文本类型字段,一个是隐藏字段。隐藏输入字段的特点是有 NAME 属性,此属性可让字段在用于表单时完全交互。下面是可便于使用新组件的某段示例标记:

<typeahead style="margin-top: 40px;"
           class="form-control"
           url="/hint/countries1"
           selectionOnly="true"
           name="country"
           placeholder="Type something"
           onSelectionMade="@ShowSelection" />

可以看到,此组件镜像反转一些 HTML 专用属性(如 Style、Class、Name 和 Placeholder),以及自定义属性(如 Url、SelectionOnly)和事件(如 onSelectionMode)。Url 属性设置了为获取提示而要调用的远程终结点,而布尔属性 SelectionOnly 则控制此组件行为,以及输入是只应来自选择,还是可以将自定义键入的文本用作输入。让我们来看看图 1 中此组件的 Razor 标记和相关 C#。有关完整详细信息,请参阅 bit.ly/2ATgEKm 中示例项目的 typeahead.cshtml 文件。

图 1:Typeahead 组件的标记

<div class="blazor-typeahead-container">
  <div class="input-group">
    <input type="text" class="@Class" style="@Style"
           placeholder="@Placeholder"
           oninput="this.blur(); this.focus();"
           bind="@SelectedText"
           onblur="@(ev => TryAutoComplete(ev))" />
    <input type="hidden" name="@Name" bind="@SelectedValue" />
    <div class="input-group-append">
      <button class="btn btn-outline-secondary dropdown-toggle"
              type="button" data-toggle="dropdown"
              style="display: none;">
      </button>
      <div class="dropdown-menu dropdown-menu-right
                  scrollable-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))">
            @((MarkupString) item.MenuText)
          </a>
        }
      </div>
    </div>
  </div>
</div>

图 2**** 列出了此组件定义和支持的属性。

图 2:TypeAhead 组件的属性

“名称” 说明
获取并设置要应用于此组件内部 HTML 元素的 CSS 类集合。
“名称” 在此组件被纳入 HTML 表单时,获取并设置 NAME 属性的值。
占位符 获取并设置要用作正在呈现的 HTML 元素的占位符的文本。
SelectedText 获取并设置显示文本。这既可以作为初始值,也可以作为已选择文本,无论是由用户键入,还是由用户从下拉菜单中选择。
SelectedValue 获取并设置已选择值。这未必与 SelectedText 值一致。已选择值绑定到隐藏字段,而 SelectedText 则绑定到文本字段。
SelectionOnly 布尔值,用于确定是允许用户键入自定义文本,还是强制用户只能从所提供的提示中选择一个。
Style 获取并设置要应用于此组件内部 HTML 元素的 CSS 样式集合。
Url 为获取提示而要调用的远程终结点的地址。

请务必注意,Typeahead 组件的 HTML 布局比仅仅两个输入字段更为复杂。它旨在以 TypeAheadItem 类的形式接收提示,如下所示:

public class TypeAheadItem
{
  public string MenuText { get; set; }
  public string Value { get; set; }
  public string DisplayText { get; set; }}

所有建议提示都是由以下内容组成:设置输入字段的显示文本(如国家/地区名称)、显示在下拉列表中的菜单文本(如更丰富的基于 HTML 的文本),以及唯一标识已选择项的值(如国家/地区代码)。虽然 Value 是可选属性,但却在 Typeahead 组件用作智能下拉列表时发挥关键作用。在这种情况下,Value 属性的作用与 HTML Option 元素的 Value 属性相同。Url 属性引用的远程终结点处的代码应返回一组 TypeAheadItem 实体。图 3 展示了返回与查询字符串匹配的国家/地区名称列表的终结点示例。

图 3:返回国家/地区名称列表

public JsonResult Countries(
  [Bind(Prefix = "id")] string filter = "")
{
  var list = (from country in CountryRepository().All();
    let match =
      $"{country.CountryName} {country.ContinentName}".ToLower()
    where match.Contains(filter.ToLower())
    select new TypeAheadItem()
    {
      Value = country.CountryCode,
      DisplayText = country.CountryName,
      MenuText = $"{country.CountryName} <b>{country.ContinentName}</b>
        <span class='pull-right'>{country.Capital}</span>"
    }).ToList();
  return Json(list);
}

需要注意两点,这两点均与表达性有关。提示服务器和预先键入组件之间应该存在某种亲密关系。作为开发人员,你对此有最终决定权。在本文介绍的示例代码中,Web 服务终结点返回 TypeAheadItem 对象集合。不过,在自定义实现中,可以让终结点返回应用程序专用属性集合,并让此组件动态决定应对文本使用哪个属性,以及应对值使用哪个属性。但 TypeAheadItem 对象提供了第三个属性 MenuText,其中包含要分配给下拉列表项的 HTML 字符串,如图 4**** 所示。

Typeahead Blazor 组件实际效果
图 4:Typeahead Blazor 组件实际效果

MenuText 设置为串联国家/地区名称和洲名称,以及在控件中右对齐的首都名称。可使用符合视觉对象需求的任意 HTML 格式。

不过,在服务器上静态确定标记并不是理想方法。更好的方法是,将任何相关数据发送到客户端,并允许将标记指定为此组件的模板参数。无独有偶,模板化组件是即将推出的炫酷 Blazor 功能,我将在今后的专栏文章中进行介绍。

此外,还请注意,在图 4 中,用户正在键入洲名称,并收到此洲内所有国家/地区的提示。实际上,国家/地区终结点的实现根据国家/地区名称和洲名称的串联,匹配查询字符串(屏幕截图中的“oce”),就像上面代码片段中的 LINQ 变量匹配一样。

这是明显更为强大的相关数据搜索方法的第一步。例如,可以先用逗号或空格拆分查询字符串,以获得一组筛选器字符串,再使用 OR 或 AND 运算符合并它们。另一项改进是,虽然当前示例中的下拉列表项的模板在终结点实现中进行硬编码,但可以作为 HTML 模板与查询字符串一起提供。

说到底,本文介绍的 Typeahead 组件仅将 Twitter JavaScript typeahead 插件用作起点。借助 Blazor 组件,开发人员可以轻松隐藏实现详细信息,最终提高 Web 开发人员编写的代码的抽象级别。

此组件的机制

在图 1**** 中,大家已仔细查看了 Blazor Typeahead 组件背后的标记。它依赖 Bootstrap 4,并依靠在相同 CSHTML 源文件中定义的一些自定义 CSS 样式。如果希望开发人员能够自定义这些样式,只需为他们提供文档即可。无论如何,愿意使用 Typeahead 组件的开发人员不必了解自定义 CSS 样式(如可滚动菜单)。

此组件表达为 Bootstrap 4 输入组,由文本输入字段、隐藏字段和下拉按钮组成。文本输入字段是用户键入任何查询字符串的位置。隐藏字段是要通过任何主机 HTML 表单转发的已接受提示值的存储位置。只有隐藏字段才有 HTML 名称属性集。下拉按钮提供包含提示的菜单。每当用户在输入字段中键入新文本后,系统就会填充菜单项列表。虽然此按钮默认不可见,但无论何时有需要,都可以编程方式显示它的下拉窗口。为此,可利用 Blazor 的数据绑定功能。内部布尔变量 (_isOpen) 已被定义,用于确定是否应将 Bootstrap 4 的“show”CSS 类添加到此按钮的下拉部分。代码如下:

<div class="dropdown-menu
            dropdown-menu-right
            scrollable-menu
            @(_isOpen ? "show" : "")"> ...
</div>

Blazor 绑定运算符用于将 SelectedText 属性绑定到文本输入字段的 value 属性,并将 SelectedValue 属性绑定到隐藏字段的 value 属性。

如何触发远程查询提示?在纯 HTML 5 中,需要定义输入事件的处理程序。更改事件不适用于文本框,因为只有在失去焦点后,此事件才会触发,而这并不适用于这种特定情况。在本文使用的 Blazer 版本(版本 0.5.0)中,可以将某段 C# 代码附加到输入事件,但它实际上无法按预期方式运行。

作为临时解决方案,我将填充下拉列表的代码附加到了 blur 事件,并添加了某段 JavaScript 代码,用于处理直接调用 blur 的输入事件,并以 DOM 级别为重点。正如图 1 所示,为响应 blur 事件而运行的 TryAutoComplete 方法执行远程调用,抓取 TypeAheadItem 对象的 JSON 数组,并填充内部 Items 集合:

async Task TryAutoComplete(UIFocusEventArgs ev)
{
  if (string.IsNullOrWhiteSpace(SelectedText))
  {
    Items.Clear();
      _isOpen = false;
    return;
  }
  var actualUrl = string.Concat(Url.TrimEnd('/'), "/", SelectedText);
  Items = await HttpExecutor.GetJsonAsync<IList<TypeAheadItem>>(actualUrl);
    _isOpen = Items.Count > 0;
}

如果发生这种情况,下拉列表会填充有菜单项并显示,如下所示:

@foreach (var item in Items)
{
  <a class="dropdown-item"
    onclick="@(() => TrySelect(item))">
    @((MarkupString) item.MenuText)
  </a>
}

请注意,强制转换为 MarkupString(ASP.NET MVC Razor 中 Html.Raw 的 Blazor 对应项)。默认情况下,Razor 处理的任何文本都会进行编码,但将表达式强制转换为 MarkupString 类型的情况除外。因此,若要显示 HTML,必须完成 MarkupString 强制转换。每当用户单击菜单项后,TrySelect 方法都会运行,如下所示:

void TrySelect(TypeAheadItem item)
{
  _isOpen = false;
  SelectedText = item.DisplayText;
  SelectedValue = item.Value;
  OnSelectionMade?.Invoke(item);
}

此方法接收与已单击元素关联的 TypeAheadItem 对象。接下来,它将 _isOpen 设置为 false 以关闭下拉列表,并根据需要更新 SelectedText 和 SelectedValue。最后,它调用 StateHasChanged 以刷新用户界面,并引发自定义 SelectionMade 事件。

连接到 Typeahead 组件

使用 Typeahead 组件的 Blazor 视图会将用户界面的各部分绑定到 SelectionMade 事件。同样,为了让更改生效,应使用以下代码调用 StateHasChanged 方法:

void ShowSelection(TypeAheadItem item)
{
  _countryName = item.DisplayText;
  _countryDescription = item.MenuText;
  this.StateHasChanged();
}

在代码片段中,事件随附的数据绑定到视图的本地属性,在 DOM 刷新后,视图便会自动更新(见图 5****)。

更新后的视图
图 5:更新后的视图

总结

越来越多的新式 Web 前端开始由组件构成。组件不仅提高了标记语言的抽象级别,还提供了更干净利落的 Web 内容创建方法。与其他客户端框架一样,Blazor 也有自己的自定义组件定义,可加快和简化开发。若要获取本文的源代码,可以访问 bit.ly/2ATgEKm,以及上月关于使用 JavaScript typeahead 插件的专栏文章。


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

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


在 MSDN 杂志论坛讨论这篇文章