65.9K
CodeProject 正在变化。 阅读更多。
Home

构建 Blazor 自动完成控件

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.67/5 (2投票s)

2023 年 1 月 5 日

CPOL

7分钟阅读

viewsIcon

19126

本文演示如何构建一个自动完成控件。

引言

曾经,标准的下拉列表是唯一的解决方案,但现在,键入提示/自动完成控件是现代用户体验中那些必备控件之一。如果你不想购买组件库,就需要自己构建。

本文将展示如何做到这一点,并详细介绍一个创新的去抖器。

HTML 现在有了 datalist 输入控件,这让我们完成了大部分工作。但你还需要处理用户的键盘输入。你可以

  1. 在加载时拉入所有选项的完整列表,然后在组件内对集合执行 Linq 操作来过滤列表。对于较小的列表来说还可以,但用语言词典的内容填充搜索框是行不通的。
  2. 每次按键时都返回数据存储并检索新列表。

如果你输入“uni”,控件是在每次按键时查找并刷新列表,还是等到你停止输入?你的搜索区分大小写吗?你是否将搜索限制在前三个字母?你怎么知道“u”不是唯一的字母?你怎么知道“i”是最后一个字母?

如果我们响应每一次按键,用户体验将取决于控件获取数据和更新显示的速度。如果数据管道的速度比打字速度慢,我们就会积累一个请求队列:数据管道和 UI 可能会出现明显的延迟,直到它们赶上来。

我们需要一个去抖器。不确定我指的是什么的人,我们需要控制由键盘/鼠标驱动的事件引起的组件刷新和数据管道调用的次数。

去抖是一种最小化此效应的机制。普通技术使用一个计时器,每次按键都会重置计时器,并且仅在计时器过期时执行数据管道请求:通常设置为 300 毫秒。快速键入“uni”,它只会查找“i”。缓慢键入,它会在每次按键时查找。

这可以工作,但更新所需的时间是计时器 + 查询/刷新周期。我们可以做得更好。

仓库

本文的仓库在这里:Blazr.Demo.TypeAhead

编码约定

  1. Nullable 全局启用。Null 错误处理依赖于此。
  2. Net7.0
  3. C# 10
  4. 数据对象是不可变的:records
  5. 默认 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);
}
  1. 实例化仅限于 static Create 方法。无法直接“new”一个实例。

  2. Func 委托是要调用以刷新数据的实际方法。方法模式为 Task MethodName()

  3. backoff 是最小更新回退周期:默认值设置为 300 毫秒。

  4. 有两个 private TaskCompletionSource 全局变量,用于跟踪正在运行和已排队的请求。如果你以前没有遇到过 TaskCompletionSource,它是一个提供任务手动创建和管理的⭑对象。你将在代码中看到它是如何工作的。

  5. _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 表示)要么

  1. 由队列处理程序运行。任务以 true 完成:我们执行了操作,你可能需要更新 UI。
  2. 被另一个请求替换。它以 false 完成:无需操作。

AutoCompleteComponent

它包含

  1. 标准的两个绑定参数,
  2. 一个 Func 委托,用于根据提供的 string 返回 string 集合
  3. 以及应用于输入的 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();

实际获取列表项的方法。如果参数 FilterItemsnull,则将 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 的启发。

© . All rights reserved.