目录
介绍
曾经标准选择是唯一的解决方案,而键入/自动完成控件现在是现代UX中必须具有控件的控件之一。如果您不想购买组件库,则需要构建自己的组件库。
本文向您展示了如何并详细介绍了创新的去抖动器。
HTML现在有了datalist输入控件,它让我们大部分时间都在那里。但是您需要处理用户键盘输入。您要么:
- 在加载时拉入选项的完整列表,然后在组件中对集合执行Linq操作以筛选列表。使用较小的列表还可以,但是使用语言词典的内容填充搜索框是行不通的。
- 返回到数据存储并在每次按键时检索新列表。
如果键入“uni”,控件是在每次击键时查找并刷新列表,还是等到停止键入?您的搜索是否区分大小写?您是否将搜索限制在前三个字母?你怎么知道“u”不是唯一的字母?你怎么知道“i”是最后一个字母?
如果我们响应每个击键,用户体验将取决于控件获取数据和更新显示的速度。如果数据管道比键入速度慢,我们会建立一个请求队列:当数据管道和UI赶上时,可能会有明显的延迟。
我们需要一个去保镖。对于那些不确定我的意思的人,我们需要控制由键盘/鼠标驱动的事件引起的组件刷新和对数据管道的调用次数。
去抖动是一种将这种影响降至最低的机制。常规技术使用计时器,该计时器在每次按键时重置,并且仅在计时器过期时执行数据管道请求:通常设置为300毫秒。快速键入“uni”,它只对“i”进行查找。慢慢输入它们,它会在每次按键时进行查找。
它可以工作,但更新所需的时间是计时器+查询/刷新周期。我们可以做得更好。
存储库
本文的存储库在这里:Blazr.Demo.TypeAhead
编码约定
- Nullable全局启用。Null错误处理依赖于它。
- Net 7.0
- C# 10
- 数据对象是不可变的:记录
- sealed默认情况下
动作限制器
这是我的去保镖。没有计时器:它利用异步库中的内置功能。
类大纲。
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方法。没有办法只是“新建”一个实例。
- Func委托是调用以刷新数据的实际方法。方法模式为Task MethodName()。
- 退避是最短更新退避期:默认值设置为300毫秒。
- 有两个private TaskCompletionSource全局变量用于跟踪正在运行和排队的请求。如果您以前没有遇到过TaskCompletionSource,它是一个提供手动创建和管理任务的对象。您将在代码中看到它的工作原理。
- _activeTask引用RunQueueAsync当前实例的Task。它提供了一种机制来检查队列当前是正在运行还是已完成。
队列异步
该方法基于Task并返回bool。
public Task<bool> QueueAsync()
{
获取对当前排队的CompletionTask的引用。可能是null。
var oldCompletionTask = _queuedTaskCompletionSource;
创建一个new 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实例来表示每个请求。它将与TaskTaskCompletionSource实例关联的传递回调用方。排队的请求(由TaskCompletionSource表示)为:
- 由队列处理程序运行。任务完成为true:我们做了一些事情,您可能需要更新UI。
- 替换为另一个请求。它完成为false:无需执行任何操作。
自动完成组件
它具有:
- 标准两个绑定参数,
- 一个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();
}
调用绑定ValueChanged回调的输入更新的UI事件处理程序。
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时,这会触发组件上的两个渲染事件:一个在等待生成之前,一个在等待生成之后。我们不需要它们:除非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服务器。
// 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) };
});
}
https://www.codeproject.com/Articles/5351256/Building-a-Blazor-Autocomplete-Control