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

构建更精简、更高效、更环保的 Blazor 组件

starIconstarIconstarIconstarIconstarIcon

5.00/5 (4投票s)

2022年10月31日

CPOL

7分钟阅读

viewsIcon

9598

重新思考 Blazor 组件

引言

Blazor 提供了一个通用的开发人员“Component”。如果您添加一个 Razor 文件,它默认会继承自它。

ComponentBase 主宰着 Blazor UI 的世界。您不一定非要使用它,但实际上 99.x% 的开发人员构建的组件要么直接继承自它,要么间接继承自它。

在一个多元化的世界里,我们却拥有一个“一刀切”的瑞士军刀解决方案。一个万金油,却一无所长。

大多数文章将 ComponentBase 和“Blazor Component”视为同义词。

ComponentBase 应该只是您工具箱中的一件工具,而不是整个工具箱。我可能是一个少数派,但我很少使用它。

仓库

本文的仓库地址是:Blazr.Components

为什么?

一个合理的问题。我的应用程序使用 ComponentBase 运行得很好。我开发的许多应用程序也是如此,但这并不是必须使用它的理由。

考虑一下

  • 组件内存占用的大部分代码实际上从未运行过。它们只是臃肿的软件:占用了内存但什么也没做。
  • 组件生成的绝大多数渲染事件都没有导致 UI 发生任何变化。CPU 周期被用于实现零成果。
  • 它没有解决一些关键的继承问题。

总结一下为什么不使用它:它占用了它并未使用的内存空间,并消耗了不必要的 CPU 周期。这等于金钱和能源的浪费。

您真的在编写精简、高效、环保的代码吗?

让我来阐述我的观点。

这是一个“简单”的组件。它是一个 Bootstrap 容器。

<div class="container">
    @ChildContent
</div>
@code {
    [Parameter] public RenderFragment? ChildContent { get; set; }
}

看起来非常简单,很可能会通过代码审查。

现在来看看这个?我没有展示那 150 多行代码——我可不想让您看得太累!

public abstract class ComponentBase : IComponent, IHandleEvent, IHandleAfterRender
{
    // 150+ lines
    // See Appendix for the 150+ lines
}

这就是上面那个组件实际上是什么样子。您认为这会通过代码审查吗?

那么为什么每个人都使用 ComponentBase?

从未思考过,未问过正确的问题,懒惰,不知道更好的方法。组件库供应商——不知道,那些组织里有很多聪明人。他们可能宣扬环保理念,但每次渲染他们的组件时,都会消耗比应有的更多的能量。

如果您的每个组件都派生自 ComponentBase,您需要认真思考原因。

引用 ComponentBase 的源代码:

大多数面向开发人员的组件生命周期概念都封装在这个基类中。核心组件渲染系统并不知道它们(它只知道 IComponent)。这给了我们灵活更改生命周期概念的自由,或者让开发人员设计自己的生命周期作为不同的基类。

我认为该评论的作者从未预料到 ComponentBase 会主导 Blazor UI。环顾组件生态系统,您能发现任何不同的基类吗?

以下是市面上两个流行 Blazor 库的基类。

public class RadzenComponent : ComponentBase, IDisposable
public abstract class MudComponentBase : ComponentBase

理解 ComponentBase 的优秀开发人员正在质疑组件的使用。他们认为简单的组件太“昂贵”。它们开销太大,负担太重。他们编写重复的代码来避免在一个页面上构建过多的组件。

我的答案是:不要扔掉组件:编写适合目的的基组件。

我有两个主要的基组件。它们基于我称之为精简高效环保组件——从现在开始称为 LMGC——我将在下面详细介绍。

精简、高效、环保的策略

简化生命周期过程

您有多少组件使用了完整的生命周期方法? 1%,最多。简化并移除大量不必要的代码和昂贵的 Task 构建。

管理参数更改

当组件渲染时,渲染器必须决定是否需要重新渲染任何子组件。它通过一个 ParametersView 对象来管理组件的参数状态。它检查是否有子组件的参数发生变化,如果有,则调用 SetParametersAsync 并传入 ParametersView 对象。

SetParametersAsync 的第一行使用 ParametersView 来设置组件的参数。

parameters.SetParameterProperties(this);

这个过程有两个问题。两者都不容易解决

  1. 设置参数是一项昂贵的任务,因为 ParameterView 使用反射来查找和分配参数值。

  2. ParameterView 检测状态变化的方法虽然经过优化,但相对粗糙。

这是代码

public static bool MayHaveChanged<T1, T2>(T1 oldValue, T2 newValue)
{
    var oldIsNotNull = oldValue != null;
    var newIsNotNull = newValue != null;

    // Only one is null so different
    if (oldIsNotNull != newIsNotNull)
        return true;

    var oldValueType = oldValue!.GetType();
    var newValueType = newValue!.GetType();

    if (oldValueType != newValueType)
        return true;

    if (!IsKnownImmutableType(oldValueType))
        return true;

    return !oldValue.Equals(newValue);
}

private static bool IsKnownImmutableType(Type type)
    => type.IsPrimitive
        || type == typeof(string)
        || type == typeof(DateTime)
        || type == typeof(Type)
        || type == typeof(decimal)
        || type == typeof(Guid);

回调和 RenderFragment 是对象,总是无法通过 IsKnownImmutableType 测试。

我的策略是

  1. 尽可能坚持使用不可变类型。
  2. 接受它。
  3. 如果一个组件被大量使用且性能是一个问题,请手动进行赋值和更改检查。您通常可以编写一些一旦初始赋值就不会改变的回调和 RenderFragments。
  4. 停止不必要的自顶向下的组件树渲染级联。请看下一策略。

不需要时不要渲染

是的,双重否定——您应该只在需要时渲染组件。不要默认执行,这是 ComponentBase 的做法。

这是 ComponentBase 的 UI 事件处理程序

Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
{
    var task = callback.InvokeAsync(arg);
    var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
        task.Status != TaskStatus.Canceled;

    StateHasChanged();

    return shouldAwaitTask ?
        CallStateHasChangedOnAsyncCompletion(task) :
        Task.CompletedTask;
}

如果您没有实现 IHandleEvent,那么您需要负责在需要时调用 StateHasChanged

您需要 AfterRender 吗?

ComponentBase 实现了一系列渲染后事件。

Task IHandleAfterRender.OnAfterRenderAsync()
{
    var firstRender = !_hasCalledOnAfterRender;
    _hasCalledOnAfterRender |= true;

    OnAfterRender(firstRender);

    return OnAfterRenderAsync(firstRender);
}

也许 99% 的组件都不需要它们。所以在偶尔需要时,手动实现 IHandleAfterRender

精简、高效、环保的组件

基于我们上面的讨论,我们可以构建一套新的基组件。

UIBase

这是最小功能核心组件。

它包含了什么

  1. 它继承自 IComponent
  2. 所有内部类字段都是 protected,因此可以在子组件中访问和设置。
  3. 它没有 UI 事件处理程序来驱动自动渲染请求。在您想要请求渲染时调用 StateHasChanged
  4. 没有 AfterRender 基础结构。如果需要,请自行实现。
  5. 有两个 StateHasChanged 方法。
    1. StateHasChanged 与熟悉的 StateHasChanged 相同。
    2. InvokeStateHasChanged 确保 StateHasChanged 在 UI 线程上被调用。
  6. 没有生命周期事件。
  7. 一个 BuildRenderTree 方法,用于与 Razor 组件兼容。
  8. 它缓存 renderFragment 以提高效率。
  9. 一个 Hidden 参数,用于模拟可以从外部设置的隐藏 HTML 属性。
  10. 一个 hide 类字段,可以在子类中内部设置。
  11. 一个 ChildContent 参数,用于组件内容。

Hidden/hide 在此级别构建,以便在组件 renderFragment 中高效实现。

public abstract class UIBase : IComponent
{
    protected RenderFragment renderFragment;
    protected internal RenderHandle renderHandle;
    protected bool hasPendingQueuedRender = false;
    protected internal bool hasNeverRendered = true;
    protected bool hide;

    [Parameter] public RenderFragment? ChildContent { get; set; }

    [Parameter] public bool Hidden { get; set; } = false;

    public UIBase()
    {
        renderFragment = builder =>
        {
            hasPendingQueuedRender = false;
            hasNeverRendered = false;
            if (!(Hidden | hide))
                BuildRenderTree(builder);
        };
    }

    protected virtual void BuildRenderTree(RenderTreeBuilder builder) { }

    protected void StateHasChanged()
    {
        if (hasPendingQueuedRender)
            return;

        hasPendingQueuedRender = true;
        renderHandle.Render(renderFragment);
    }

    protected void InvokeStateHasChanged()
        => renderHandle.Dispatcher.InvokeAsync(StateHasChanged);

    public void Attach(RenderHandle renderHandle)
        => this.renderHandle = renderHandle;

    public virtual Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        StateHasChanged();
        return Task.CompletedTask;
    }
}

UIComponentBase

UIComponentBase 添加了一个生命周期事件 OnParametersChangedAsync。它

  1. 传入一个 bool 来指示是否是首次渲染。
  2. 期望一个返回 bool 来控制组件渲染。它将始终渲染一次。
  3. 是一个 ValueTask 以节省开销。

OnParametersChangedAsync 可用于

  1. 执行您在 OnInitialized{Async}OnParametersSet{Async} 中所做的一切。
  2. 检查哪些参数已设置,并决定是否需要渲染。
public abstract class UIComponentBase : UIBase
{
    protected bool initialized;

    protected virtual ValueTask<bool> OnParametersChangedAsync(bool firstRender)
        => ValueTask.FromResult(true);

    public override async Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);

        var dorender = await this.OnParametersChangedAsync(!initialized)
            || hasNeverRendered
            || !hasPendingQueuedRender;

        if (dorender)
            this.StateHasChanged();

        this.initialized = true;
    }
}

添加自动 UI 渲染

如果您需要自动 UI 渲染,请实现 IHandleEvent

单次渲染

@implements IHandleEvent

//...
@code {
    public async Task HandleEventAsync(EventCallbackWorkItem callback, object? arg)
    {
        await callback.InvokeAsync(arg);
        StateHasChanged();
    }
}

双重事件

@implements IHandleEvent

//...
@code {
    public async Task HandleEventAsync(EventCallbackWorkItem callback, object? arg)
    {
        var task = callback.InvokeAsync(arg);
        if (task.Status != TaskStatus.RanToCompletion && 
            task.Status != TaskStatus.Canceled)
        {
            StateHasChanged();
            await task;
        }
        StateHasChanged();
    }
}

添加 OnAfterRender

如果您需要实现 OnAfterRender 事件,请实现 IHandleAfterRender

@implements IHandleAfterRender

//...

@code {
    private bool _hasCalledOnAfterRender;

    public Task OnAfterRenderAsync()
    {
        var firstRender = !_hasCalledOnAfterRender;
        _hasCalledOnAfterRender |= true;

        // your code here

        return Task.CompletedTask;
    }
}

渲染级联

最重要的策略之一是避免渲染级联。

如果您渲染一个包含子组件(这些子组件具有对象参数)的组件,渲染器将调用子组件的 SetParametersAsync,而不管实际状态是否发生变化。除非您在这些组件中实现了停止策略,否则渲染将级联到整个树。

最小化此问题的核心方法是

  1. 使用带有事件的状态对象来驱动更新。
  2. 在渲染树的正确点调用 StateHasChanged
  3. 在树的顶部使用不自动触发渲染事件的基组件。

一些演示实现

计数器页面

此演示展示了如何重建计数器页面。

CounterState

我们需要一个状态对象来跟踪计数器状态。

public class CounterState
{
    public int Counter { get; private set; }

    public Action<int>? CounterUpdated;

    public void IncrementCounter()
    {
        this.Counter++;
        this.CounterUpdated?.Invoke(this.Counter);
    }
}

CounterComponent.razor

CounterComponent 显示计数器。它继承自 UIComponentBase 并实现 IDisposable

它比标准组件稍微复杂一些,但还是很好理解的。

@namespace Blazr.Components
@implements IDisposable
@inherits UIComponentBase

<div class="alert alert-info">
    @this.Counter
</div>

@code {
    [CascadingParameter] private CounterState State { get; set; } = default!;
    private int Counter;

    protected override ValueTask<bool> OnParametersChangedAsync(bool firstRender)
    {
        if (firstRender)
        {
            if (this.State is null)
                throw new NullReferenceException
                ($"State cannot be null in Component {this.GetType().Name}");

            this.State.CounterUpdated += this.OnCounterUpdated;
        }
        return ValueTask.FromResult(true);
    }

    private void OnCounterUpdated(int counter)
    {
        this.Counter = counter;
        this.StateHasChanged();
    }

    public void Dispose()
        => this.State.CounterUpdated -= this.OnCounterUpdated;
}

Counter.Razor

Counter 实现 UIBase:它不需要生命周期事件。它创建一个 CounterState 实例,进行级联,并在按钮点击时更新它。有三个 CounterComponent 实例用于演示事件的多播功能。

我保留了旧的计数器代码,这样您可以看到它不再更新。IncrementCounter 不再触发路由组件的渲染,因此也不再触发渲染级联。

@page "/counter"
@inherits UIBase
<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @currentCount</p>
<CascadingValue Value="this.counterState">
    <CounterComponent />
    <CounterComponent />
    <CounterComponent />
</CascadingValue>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

@code {
    private int currentCount = 0;
    private CounterState counterState = new CounterState();

    private void IncrementCount()
    {
        currentCount++;
        this.counterState.IncrementCounter();
    }
}

天气记录查看器

这演示了 SetParametersAsync 中的选择性渲染。前进和后退按钮会在记录集上上下移动并重新加载路由。组件使用 _id 来跟踪当前记录,并在 OnParametersChangedAsync 中检查更新的参数 Id。只有当 Id 发生变化时,它才会渲染(返回 true)。

@page "/WeatherView/{Id:int}"
@inherits UIComponentBase
@inject NavigationManager NavManager

<h3>WeatherViewer</h3>

<div class="row mb-2">
    <div class="col-3">
        Date
    </div>
    <div class="col-3">
        @this.record.Date
    </div>
</div>
<div class="row mb-2">
    <div class="col-3">
        Temperature &deg;C
    </div>
    <div class="col-3">
        @this.record.TemperatureC
    </div>
</div>
<div class="row mb-2">
    <div class="col-3">
        Summary
    </div>
    <div class="col-6">
        @this.record.Summary
    </div>
</div>
<div class="m-2">
    <button class="btn btn-dark" @onclick="() => this.Move(-1)">Previous</button> 
    <button class="btn btn-primary" @onclick="() => this.Move(1)">Next</button>
</div>

@code {
    private int _id;
    private WeatherForecast record = new();

    [Parameter] public int Id { get; set; } = 0;

    protected override async ValueTask<bool> OnParametersChangedAsync(bool firstRender)
    {
        var recordChanged = !this.Id.Equals(_id);

        if (recordChanged)
        {
            _id = this.Id;
            this.record = await GetForecast(this.Id);
        }

        return recordChanged;
    }

    private static async ValueTask<WeatherForecast> GetForecast(int id)
    {
        await Task.Delay(100);
        return new WeatherForecast
            {
                Date = DateOnly.FromDateTime(DateTime.Now.AddDays(id)),
                TemperatureC = id,
                Summary = "Testing"
            };
    }

    private void Move(int value)
        => this.NavManager.NavigateTo($"/WeatherView/{_id + value}");
}

结论

如果这篇文章不能唤醒严肃的 Blazor 开发人员重新思考组件,那我就失败了!

要摆脱 ComponentBase 的舒适区需要多长时间?您正在基于一个万金油、一无所长的基类构建整个 UI。它几乎包含了所有内容,以应对几乎所有可能的情况。

它非常适合帮助您入门。了解基本原理,深入研究。但之后,请继续前进。

后续将有更多文章介绍如何从这些基组件构建表单和组件库。

附录

ComponentBase

这是您构建的每个继承自 ComponentBase 的组件都会加载的代码。

public abstract class ComponentBase : IComponent, IHandleEvent, IHandleAfterRender
{
    private readonly RenderFragment _renderFragment;
    private RenderHandle _renderHandle;
    private bool _initialized;
    private bool _hasNeverRendered = true;
    private bool _hasPendingQueuedRender;
    private bool _hasCalledOnAfterRender;

    public ComponentBase()
    {
        _renderFragment = builder =>
        {
            _hasPendingQueuedRender = false;
            _hasNeverRendered = false;
            BuildRenderTree(builder);
        };
    }

    protected virtual void BuildRenderTree(RenderTreeBuilder builder) { }
    protected virtual void OnInitialized() { }
    protected virtual Task OnInitializedAsync() => Task.CompletedTask;
    protected virtual void OnParametersSet() { }
    protected virtual Task OnParametersSetAsync() => Task.CompletedTask;
    protected virtual bool ShouldRender() => true;
    protected virtual void OnAfterRender(bool firstRender) { }
    protected virtual Task OnAfterRenderAsync(bool firstRender) => Task.CompletedTask;
    protected Task InvokeAsync(Action workItem) => 
                               _renderHandle.Dispatcher.InvokeAsync(workItem);
    protected Task InvokeAsync(Func<Task> workItem) => 
                               _renderHandle.Dispatcher.InvokeAsync(workItem);

    protected void StateHasChanged()
    {
        if (_hasPendingQueuedRender)
            return;

        if (_hasNeverRendered || ShouldRender() || 
                                 _renderHandle.IsRenderingOnMetadataUpdate)
        {
            _hasPendingQueuedRender = true;

            try
            {
                _renderHandle.Render(_renderFragment);
            }
            catch
            {
                _hasPendingQueuedRender = false;
                throw;
            }
        }
    }

    void IComponent.Attach(RenderHandle renderHandle)
    {
        if (_renderHandle.IsInitialized)
            throw new InvalidOperationException
            ($"The render handle is already set. Cannot initialize a 
            {nameof(ComponentBase)} more than once.");

        _renderHandle = renderHandle;
    }

    public virtual Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        if (!_initialized)
        {
            _initialized = true;

            return RunInitAndSetParametersAsync();
        }
        else
            return CallOnParametersSetAsync();
    }

    private async Task RunInitAndSetParametersAsync()
    {
        OnInitialized();
        var task = OnInitializedAsync();

        if (task.Status != TaskStatus.RanToCompletion && 
            task.Status != TaskStatus.Canceled)
        {
            StateHasChanged();

            try
            {
                await task;
            }
            catch
            {
                if (!task.IsCanceled)
                    throw;
            }
        }

        await CallOnParametersSetAsync();
    }

    private Task CallOnParametersSetAsync()
    {
        OnParametersSet();
        var task = OnParametersSetAsync();

        var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
            task.Status != TaskStatus.Canceled;

        StateHasChanged();

        return shouldAwaitTask ?
            CallStateHasChangedOnAsyncCompletion(task) :
            Task.CompletedTask;
    }

    private async Task CallStateHasChangedOnAsyncCompletion(Task task)
    {
        try
        {
            await task;
        }
        catch 
        {
            if (task.IsCanceled)
                return;

            throw;
        }

        StateHasChanged();
    }

    Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
    {
        var task = callback.InvokeAsync(arg);
        var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
            task.Status != TaskStatus.Canceled;

        StateHasChanged();

        return shouldAwaitTask ?
            CallStateHasChangedOnAsyncCompletion(task) :
            Task.CompletedTask;
    }

    Task IHandleAfterRender.OnAfterRenderAsync()
    {
        var firstRender = !_hasCalledOnAfterRender;
        _hasCalledOnAfterRender |= true;

        OnAfterRender(firstRender);

        return OnAfterRenderAsync(firstRender);
    }
}

历史

  • 2022年10月31日:初始版本
© . All rights reserved.