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





5.00/5 (4投票s)
重新思考 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);
这个过程有两个问题。两者都不容易解决
-
设置参数是一项昂贵的任务,因为
ParameterView
使用反射来查找和分配参数值。 -
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
测试。
我的策略是
- 尽可能坚持使用不可变类型。
- 接受它。
- 如果一个组件被大量使用且性能是一个问题,请手动进行赋值和更改检查。您通常可以编写一些一旦初始赋值就不会改变的回调和 RenderFragments。
- 停止不必要的自顶向下的组件树渲染级联。请看下一策略。
不需要时不要渲染
是的,双重否定——您应该只在需要时渲染组件。不要默认执行,这是 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
这是最小功能核心组件。
它包含了什么
- 它继承自
IComponent
。 - 所有内部类字段都是
protected
,因此可以在子组件中访问和设置。 - 它没有 UI 事件处理程序来驱动自动渲染请求。在您想要请求渲染时调用
StateHasChanged
。 - 没有
AfterRender
基础结构。如果需要,请自行实现。 - 有两个
StateHasChanged
方法。StateHasChanged
与熟悉的StateHasChanged
相同。InvokeStateHasChanged
确保StateHasChanged
在 UI 线程上被调用。
- 没有生命周期事件。
- 一个
BuildRenderTree
方法,用于与 Razor 组件兼容。 - 它缓存
renderFragment
以提高效率。 - 一个
Hidden
参数,用于模拟可以从外部设置的隐藏 HTML 属性。 - 一个
hide
类字段,可以在子类中内部设置。 - 一个
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
。它
- 传入一个
bool
来指示是否是首次渲染。 - 期望一个返回
bool
来控制组件渲染。它将始终渲染一次。 - 是一个
ValueTask
以节省开销。
OnParametersChangedAsync
可用于
- 执行您在
OnInitialized{Async}
和OnParametersSet{Async}
中所做的一切。 - 检查哪些参数已设置,并决定是否需要渲染。
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
,而不管实际状态是否发生变化。除非您在这些组件中实现了停止策略,否则渲染将级联到整个树。
最小化此问题的核心方法是
- 使用带有事件的状态对象来驱动更新。
- 在渲染树的正确点调用
StateHasChanged
。 - 在树的顶部使用不自动触发渲染事件的基组件。
一些演示实现
计数器页面
此演示展示了如何重建计数器页面。
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 °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日:初始版本