生成Blazor自动完成控件

目录

介绍

存储库

编码约定

动作限制器

队列异步

RunQueueAsync

总结

自动完成组件

提高组件性能

演示页面

演示页面演示者

附录——解决方案的数据管道

CountryDataProvider

CountryDataBroker

数据类


介绍

曾经标准选择是唯一的解决方案,而键入/自动完成控件现在是现代UX必须具有控件的控件之一。如果您不想购买组件库,则需要构建自己的组件库。

本文向您展示了如何并详细介绍了创新的去抖动器。

HTML现在有了datalist输入控件,它让我们大部分时间都在那里。但是您需要处理用户键盘输入。您要么:

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

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

如果我们响应每个击键,用户体验将取决于控件获取数据和更新显示的速度。如果数据管道比键入速度慢,我们会建立一个请求队列:当数据管道和UI赶上时,可能会有明显的延迟。

我们需要一个去保镖。对于那些不确定我的意思的人,我们需要控制由键盘/鼠标驱动的事件引起的组件刷新和对数据管道的调用次数。

去抖动是一种将这种影响降至最低的机制。常规技术使用计时器,该计时器在每次按键时重置,并且仅在计时器过期时执行数据管道请求:通常设置为300毫秒。快速键入uni,它只对i进行查找。慢慢输入它们,它会在每次按键时进行查找。

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

存储库

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

编码约定

  1. Nullable全局启用。Null错误处理依赖于它。
  2. Net 7.0
  3. C# 10
  4. 数据对象是不可变的:记录
  5. 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);
}

  1. 实例化仅限于static Create方法。没有办法只是“新建”一个实例。
  2. Func委托是调用以刷新数据的实际方法。方法模式为Task MethodName()
  3. 退避是最短更新退避期:默认值设置为300毫秒。
  4. 有两个private TaskCompletionSource全局变量用于跟踪正在运行和排队的请求。如果您以前没有遇到过TaskCompletionSource,它是一个提供手动创建和管理任务的对象。您将在代码中看到它的工作原理。
  5. _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表示)为:

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

自动完成组件

它具有:

  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();
}

调用绑定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

CountryDataProviderAPI获取数据并将其映射到应用程序数据对象。它是一个基础结构域对象。

提供程序在加载时从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

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值