构建 Blazor 自动完成控件






3.67/5 (2投票s)
本文演示如何构建一个自动完成控件。
引言
曾经,标准的下拉列表是唯一的解决方案,但现在,键入提示/自动完成控件是现代用户体验中那些必备控件之一。如果你不想购买组件库,就需要自己构建。
本文将展示如何做到这一点,并详细介绍一个创新的去抖器。
HTML 现在有了 datalist
输入控件,这让我们完成了大部分工作。但你还需要处理用户的键盘输入。你可以
- 在加载时拉入所有选项的完整列表,然后在组件内对集合执行 Linq 操作来过滤列表。对于较小的列表来说还可以,但用语言词典的内容填充搜索框是行不通的。
- 每次按键时都返回数据存储并检索新列表。
如果你输入“uni
”,控件是在每次按键时查找并刷新列表,还是等到你停止输入?你的搜索区分大小写吗?你是否将搜索限制在前三个字母?你怎么知道“u
”不是唯一的字母?你怎么知道“i
”是最后一个字母?
如果我们响应每一次按键,用户体验将取决于控件获取数据和更新显示的速度。如果数据管道的速度比打字速度慢,我们就会积累一个请求队列:数据管道和 UI 可能会出现明显的延迟,直到它们赶上来。
我们需要一个去抖器。不确定我指的是什么的人,我们需要控制由键盘/鼠标驱动的事件引起的组件刷新和数据管道调用的次数。
去抖是一种最小化此效应的机制。普通技术使用一个计时器,每次按键都会重置计时器,并且仅在计时器过期时执行数据管道请求:通常设置为 300 毫秒。快速键入“uni
”,它只会查找“i
”。缓慢键入,它会在每次按键时查找。
这可以工作,但更新所需的时间是计时器 + 查询/刷新周期。我们可以做得更好。
仓库
本文的仓库在这里:Blazr.Demo.TypeAhead
编码约定
Nullable
全局启用。Null
错误处理依赖于此。- Net7.0
- C# 10
- 数据对象是不可变的:records
- 默认
sealed
ActionLimiter
这是我的去抖器。没有计时器:它利用了 Async 库中的内置功能。
类的轮廓。
public sealed class ActionLimiter
{
// The public Methods
public Task<bool> QueueAsync();
public static ActionLimiter Create(Func<Task> toRun, int backOffPeriod);
private int _backOffPeriod = 0;
private Func<Task> _taskToRun;
private Task _activeTask = Task.CompletedTask;
private TaskCompletionSource<bool>? _queuedTaskCompletionSource;
private TaskCompletionSource<bool>? _activeTaskCompletionSource;
private async Task RunQueueAsync();
private ActionLimiter(Func<Task> toRun, int backOffPeriod);
}
-
实例化仅限于
static
Create
方法。无法直接“new”一个实例。 -
Func
委托是要调用以刷新数据的实际方法。方法模式为Task MethodName()
。 -
backoff 是最小更新回退周期:默认值设置为 300 毫秒。
-
有两个
private
TaskCompletionSource
全局变量,用于跟踪正在运行和已排队的请求。如果你以前没有遇到过TaskCompletionSource
,它是一个提供任务手动创建和管理的⭑对象。你将在代码中看到它是如何工作的。 -
_activeTask
引用RunQueueAsync
当前实例的Task
。它提供了一种机制来检查队列当前是否正在运行或已完成。
QueueAsync
该方法基于 Task
并返回一个 bool
。
public Task<bool> QueueAsync()
{
获取当前已排队 CompletionTask
的引用。它可能为 null
。
var oldCompletionTask = _queuedTaskCompletionSource;
创建新的 CompletionTask
并获取其 Task
的引用。这是为了确保在分配给活动队列之前引用它。
var newCompletionTask = new TaskCompletionSource<bool>();
var task = newCompletionTask.Task;
切换分配给活动队列的 CompletionTask
引用。
_queuedTaskCompletionSource = newCompletionTask;
将旧的 CompletionTask
设置为已完成,返回 false
:未执行任何操作。
if (oldCompletionTask is not null && !oldCompletionTask.Task.IsCompleted)
oldCompletionTask?.TrySetResult(false);
检查 _activeTask
是否未完成,即 RunQueueAsync
正在运行。如果不是,则调用 RunQueueAsync
并将其 Task
引用分配给 _activeTask
。
if (_activeTask is null || _activeTask.IsCompleted)
_activeTask = this.RunQueueAsync();
返回与新排队的 CompletionTask
关联的任务。
return task;
}
完整方法
public Task<bool> QueueAsync()
{
var oldCompletionTask = _queuedTaskCompletionSource;
var newCompletionTask = new TaskCompletionSource<bool>();
var task = newCompletionTask.Task;
_queuedTaskCompletionSource = newCompletionTask;
if (oldCompletionTask is not null && !oldCompletionTask.Task.IsCompleted)
oldCompletionTask?.TrySetResult(false);
if (_activeTask is null || _activeTask.IsCompleted)
_activeTask = this.RunQueueAsync();
return task;
}
RunQueueAsync
private async Task RunQueueAsync()
{
如果当前 CompletionTask
已完成,则释放对其的引用。
if (_activeTaskCompletionSource is not null &&
_activeTaskCompletionSource.Task.IsCompleted)
_activeTaskCompletionSource = null;
如果当前 CompletionTask
正在运行,则一切都已就绪,无需执行任何操作,因此返回。
if (_activeTaskCompletionSource is not null)
return;
使用 while
循环,只要有已排队的 CompletionTask
,就保持进程运行。
while (_queuedTaskCompletionSource is not null)
如果我们到了这里,就没有活动的 CompletionTask
。将已排队的 CompletionTask
引用分配给活动的 CompletionTask
并释放已排队的 CompletionTask
引用。队列现在是空的。
_activeTaskCompletionSource = _queuedTaskCompletionSource;
_queuedTaskCompletionSource = null;
启动一个 Task.Delay
任务,设置为延迟回退周期,主任务在 _taskToRun
中,并等待两者。实际的回退周期将是两个任务中运行时间较长的一个。
var backoffTask = Task.Delay(_backOff);
var mainTask = _taskToRun.Invoke();
await Task.WhenAll( new Task[] { mainTask, backoffTask } );
主任务已完成,因此我们将活动的 CompletionTask
设置为已完成并释放对其的引用。返回值为 true
:我们执行了操作。
_activeTaskCompletionSource.TrySetResult(true);
_activeTaskCompletionSource = null;
}
循环回检查是否已排队另一个请求:在我们处理上一个排队请求时发生了 UI 事件。如果未完成。
return;
}
完整方法
private async Task RunQueueAsync()
{
if (_activeTaskCompletionSource is not null &&
_activeTaskCompletionSource.Task.IsCompleted)
_activeTaskCompletionSource = null;
if (_activeTaskCompletionSource is not null)
return;
while (_queuedTaskCompletionSource is not null)
{
_activeTaskCompletionSource = _queuedTaskCompletionSource;
_queuedTaskCompletionSource = null;
var backoffTask = Task.Delay(_backOffPeriod);
var mainTask = _taskToRun.Invoke();
await Task.WhenAll( new Task[] { mainTask, backoffTask } );
_activeTaskCompletionSource.TrySetResult(true);
_activeTaskCompletionSource = null;
}
return;
}
摘要
该对象使用 TaskCompletionSource
实例来表示每个请求。它将与 TaskCompletionSource
实例关联的 Task
返回给调用者。已排队的请求(由 TaskCompletionSource
表示)要么
- 由队列处理程序运行。任务以
true
完成:我们执行了操作,你可能需要更新 UI。 - 被另一个请求替换。它以
false
完成:无需操作。
AutoCompleteComponent
它包含
- 标准的两个绑定参数,
- 一个
Func
委托,用于根据提供的string
返回string
集合 - 以及应用于输入的 CSS。
[Parameter] public string? Value { get; set; }
[Parameter] public EventCallback<string?> ValueChanged { get; set; }
[Parameter, EditorRequired] public Func<string?,
Task<IEnumerable<string>>>? FilterItems { get; set; }
[Parameter] public string CssClass { get; set; } = "form-control mb-3";
private
全局变量
private ActionLimiter deBouncer;
private string? filterText; //The value we'll get from oninput events
private string listid = Guid.NewGuid().ToString(); //unique id for the datalist
private IEnumerable<string> items =
Enumerable.Empty<string>(); //string list for the datalist>
一个 ctor
来初始化 ActionLimiter
。
public AutoCompleteControl()
=> deBouncer = ActionLimiter.Create(GetFilteredItems, 300);
OnInitializedAsync
获取初始过滤器列表。这可能是一个空列表。
protected override Task OnInitializedAsync()
=> GetFilteredItems();
实际获取列表项的方法。如果参数 FilterItems
为 null
,则将 items
设置为空集合,否则将 items
设置为返回的集合。
private async Task GetFilteredItems()
{
this.Items = FilterItems is null
? Enumerable.Empty<string>()
: await FilterItems.Invoke(filterText);
}
由 @oninput
调用的方法。它将 filterText
设置为当前 string
,然后对 deBouncer
队列化一个请求。如果返回 true
- deBouncer
未取消请求 - 则调用 StateHasChanged
来更新组件。请参阅改进组件性能以了解为何调用 StateHasChanged
。
private async void OnSearchUpdated(ChangeEventArgs e)
{
this.filterText = e.Value?.ToString() ?? string.Empty;
if (await deBouncer.QueueAsync())
StateHasChanged();
}
UI 事件处理程序,用于输入更新调用绑定 ValueChanged
回调。
private Task OnChange(ChangeEventArgs e)
=> this.ValueChanged.InvokeAsync(e.Value?.ToString());
UI 标记代码
<input class="@CssClass" type="search" value="@this.Value"
@onchange=this.OnChange list="@listid" @oninput=this.OnSearchUpdated />
<datalist id="@listid">
@foreach (var item in this.Items)
{
<option>@item</option>
}
</datalist>
改进组件性能
组件在每次按键时都会引发 UI 事件:调用 OnSearchUpdated
。由于我们继承自 ComponentBase
,这会在组件上触发两次渲染事件:一次在 await yield 之前,一次在之后。我们不需要它们:除非 deBouncer.QueueAsync()
返回 true
,否则它们不起作用。
我们可以通过实现 IHandleEvent
并定义一个自定义 HandleEventAsync
来更改这一点,该自定义方法仅在不调用 StateHasChanged
的情况下调用该方法。我们在需要时手动调用它。
我们也可以短路 OnAfterRenderAsync
处理程序,因为我们也不使用它。
以下是如何操作
@implements IHandleEvent
@implements IHandleAfterRender
//....
Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
=> callback.InvokeAsync(arg);
Task IHandleAfterRender.OnAfterRenderAsync()
=> Task.CompletedTask;
最后,我们添加一个代码隐藏文件来密封类:sealed
对象比开放对象稍快。 .NET 7.0 的一些后台更改是尽可能密封类。
public sealed partial class AutoCompleteControl {}
演示页面
数据管道的代码在附录中。此页面演示了国家/地区选择控件的自动完成功能。它不言自明。要么返回整个列表(如果搜索为空),如这里所示,要么返回一个空列表。
@page "/Index"
@inject IndexPresenter Presenter
<PageTitle>Index</PageTitle>
<AutoCompleteControl FilterItems=this.Presenter.GetItems
@bind-Value=this.Presenter.TypeAheadText />
<div class="alert alert-info">
TypeAheadText : @this.Presenter.TypeAheadText
</div>
用于密封组件的代码隐藏类。
public sealed partial class Index {}
演示页面表示器
IndexPresenter
是管理 UI 页面使用的数据的表示层对象。它是一个 Transient
注册服务。
public class IndexPresenter
{
private ICountryDataBroker _dataBroker;
public IndexPresenter(ICountryDataBroker countryService)
=> _dataBroker = countryService;
public string? TypeAheadText;
public IEnumerable<Country> filteredCountries
{ get; private set; } = Enumerable.Empty<Country>();
public async Task<IEnumerable<string>> GetItems(string search)
{
var list = await _dataBroker.FilteredCountries(search, null);
return list.Select(item => item.Name).AsEnumerable();
}
}
附录 - 解决方案的数据管道
这些文章的数据管道。
CountryDataProvider
CountryDataProvider
从 API 获取数据并将其映射到应用程序数据对象。它是一个基础设施域对象。
提供程序在加载时从 API 获取数据。由于这是一个异步操作,它使用 LoadTask
来持有正在执行的后台 API 加载代码,并在任何数据请求时等待其完成。
public sealed class CountryDataProvider
{
private readonly HttpClient _httpClient;
private List<CountryData> _baseDataSet = new List<CountryData>();
public Task LoadTask { get; private set; } = Task.CompletedTask;
private List<Continent> _continents = new();
private List<Country> _countries = new();
public CountryDataProvider(HttpClient httpClient)
{
_httpClient = httpClient;
this.LoadTask = LoadBaseData();
}
public async ValueTask<IEnumerable<Country>> GetCountriesAsync()
{
await this.LoadTask;
return _countries.AsEnumerable();
}
public async ValueTask<IEnumerable<Continent>> GetContinentsAsync()
{
await this.LoadTask;
return _continents.AsEnumerable();
}
public async ValueTask<IEnumerable<Country>> FilteredCountries
(string? searchText, Guid? continentUid = null)
=> await this.GetFilteredCountries(searchText, continentUid);
public async ValueTask<IEnumerable<Country>>
FilteredCountriesAsync(Guid continentUid)
{
await this.LoadTask;
return _countries.Where(item => item.ContinentUid == continentUid);
}
private async Task LoadBaseData()
{
// source country file is
// https://github.com/samayo/country-json/blob/master/src/
// country-by-continent.json
// on my site it's in wwwroot/sample-data/countries.json
_baseDataSet = await _httpClient.GetFromJsonAsync
<List<CountryData>>("sample-data/countries.json") ?? new List<CountryData>();
var distinctContinentNames = _baseDataSet.Select
(item => item.Continent).Distinct().ToList();
foreach (var continent in distinctContinentNames)
_continents.Add(new Continent { Name = continent });
foreach (var continent in _continents)
{
var countryNamesInContinent = _baseDataSet.Where(item =>
item.Continent == continent.Name).Select(item => item.Country).ToList();
foreach (var countryName in countryNamesInContinent)
_countries.Add(new Country { Name = countryName,
ContinentUid = continent.Uid });
}
}
private async ValueTask<IEnumerable<Country>>
GetFilteredCountries(string? searchText, Guid? continentUid = null)
{
await this.LoadTask;
var query = _countries.AsEnumerable();
if (continentUid is not null && continentUid != Guid.Empty)
query = query.Where(item => item.ContinentUid == continentUid);
if (!string.IsNullOrWhiteSpace(searchText))
query = query.Where(item =>
item.Name.ToLower().Contains(searchText.ToLower()));
return query.OrderBy(item => item.Name);
}
private record CountryData
{
public required string Country { get; init; }
public required string Continent { get; init; }
}
}
CountryDataBroker
一个接口和一个使用 CountryDataProvider
的实现。
public interface ICountryDataBroker
{
public ValueTask<IEnumerable<Country>> GetCountriesAsync();
public ValueTask<IEnumerable<Continent>> GetContinentsAsync();
public ValueTask<IEnumerable<Country>>
FilteredCountries(string? searchText, Guid? continentUid = null);
public ValueTask<IEnumerable<Country>> FilteredCountriesAsync(Guid continentUid);
}
public sealed class CountryDataBroker : ICountryDataBroker
{
private CountryDataProvider _countryDataProvider;
public CountryDataBroker(CountryDataProvider countryDataProvider)
=> _countryDataProvider = countryDataProvider;
public async ValueTask<IEnumerable<Country>> GetCountriesAsync()
=> await _countryDataProvider.GetCountriesAsync();
public async ValueTask<IEnumerable<Continent>> GetContinentsAsync()
=> await _countryDataProvider.GetContinentsAsync();
public async ValueTask<IEnumerable<Country>>
FilteredCountries(string? searchText, Guid? continentUid = null)
=> await _countryDataProvider.FilteredCountries(searchText, continentUid);
public async ValueTask<IEnumerable<Country>>
FilteredCountriesAsync(Guid continentUid)
=> await _countryDataProvider.FilteredCountriesAsync(continentUid);
}
数据类
public sealed record Country
{
public Guid Uid { get; init; } = Guid.NewGuid();
public required Guid ContinentUid { get; init; }
public required string Name { get; init; }
}
public sealed record Continent
{
public Guid Uid { get; init; } = Guid.NewGuid();
public required string Name { get; init; }
}
服务注册。这适用于 Blazor Server。
// Add services to the service container.
builder.Services.AddScoped<CountryDataProvider>();
builder.Services.AddScoped<ICountryDataBroker, CountryDataBroker>();
builder.Services.AddTransient<CountryPresenter>();
builder.Services.AddTransient<IndexPresenter>();
// Register a HttpClient
if (!builder.Services.Any(x => x.ServiceType == typeof(HttpClient)))
{
builder.Services.AddScoped<HttpClient>(s =>
{
var uriHelper = s.GetRequiredService<NavigationManager>();
return new HttpClient { BaseAddress = new Uri(uriHelper.BaseUri) };
});
}
历史
- 2023 年 1 月 5 日:原始文章
参考文献
我版本的去抖器受到了 An Easier Blazor Debounce - CodeProject - Jeremy Likness 的启发。