注释
此版本不是本文的最新版本。 要查看当前版本,请参阅本文的.NET 9 版本。
警告
此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 要查看当前版本,请参阅本文的.NET 9 版本。
优化呈现速度,以最大程度地减少呈现工作负载并提高 UI 响应能力,这可以提高 UI 呈现速度的 十倍或更高 。
避免呈现不必要的组件子树
在发生事件时,可以通过跳过子组件子树的重新呈现来删除大部分父组件的呈现成本。 应只关注跳过重新呈现子树,这些子树的呈现成本特别高,并导致 UI 滞后。
在运行时,组件存在于层次结构中。 根组件(加载的第一个组件)具有子组件。 反过来,根的子级具有自己的子组件,依此而行。 发生事件(例如用户选择按钮)时,以下过程确定要重新呈现的组件:
- 该事件将传递到渲染事件处理程序的组件。 执行事件处理程序后,将重新呈现组件。
- 重新呈现组件时,它会向每个子组件提供参数值的新副本。
- 收到一组新的参数值后, Blazor 决定是否重新呈现组件。 组件会重新呈现,如果
ShouldRender
返回true
,这是默认行为,除非被重写。并且参数值可能会发生变化,例如,它们是可变对象时。
上述序列的最后两个步骤在组件层次结构中以递归方式继续。 在许多情况下,将重新呈现整个子树。 面向高级组件的事件可能会导致成本高昂的重新呈现,因为高级组件下面的每个组件都必须重新呈现。
若要防止将递归呈现到特定的子树中,请使用以下任一方法:
- 确保子组件参数具有特定的不可变类型†,例如
string
,int
和bool
DateTime
。 用于检测更改的内置逻辑会在不变的参数值没有变化的情况下,自动跳过界面的重新呈现。 如果呈现一个子组件<Customer CustomerId="item.CustomerId" />
,其中CustomerId
是一种int
类型,那么除非Customer
发生变化,否则item.CustomerId
组件不会重新呈现。 - 重写
ShouldRender
,返回false
:- 如果参数是非特权类型或不支持的不可变类型†(如复杂的自定义模型类型或 RenderFragment 值),并且参数值尚未更改,
- 如果编写一个仅限用户界面的组件,并且该组件在初始渲染后不随参数值的变化而改变。
†有关详细信息,请参阅Blazor的参考源(ChangeDetection.cs
)中的更改检测逻辑。
注释
指向 .NET 引用源的文档链接通常会加载存储库的默认分支,该分支代表正在进行的 .NET 下一版本的开发。 若要为特定版本选择标记,请使用“切换分支或标记”下拉菜单。 有关详细信息,请参阅如何选择 ASP.NET Core 源代码的版本标记 (dotnet/AspNetCore.Docs #26205)。
以下航空公司航班搜索工具示例使用专用字段来跟踪检测更改所需的信息。 以前的入站航班标识符(prevInboundFlightId
)和以前的出站航班标识符(prevOutboundFlightId
)跟踪下一个潜在组件更新的信息。 如果在设置 OnParametersSet
组件参数时航班标识符中的任一发生更改,则会重新渲染该组件,因为 shouldRender
设置为 true
。 如果在检查航班标识符后,shouldRender
评估结果为 false
,则可避免成本高昂的重新渲染。
@code {
private int prevInboundFlightId = 0;
private int prevOutboundFlightId = 0;
private bool shouldRender;
[Parameter]
public FlightInfo? InboundFlight { get; set; }
[Parameter]
public FlightInfo? OutboundFlight { get; set; }
protected override void OnParametersSet()
{
shouldRender = InboundFlight?.FlightId != prevInboundFlightId
|| OutboundFlight?.FlightId != prevOutboundFlightId;
prevInboundFlightId = InboundFlight?.FlightId ?? 0;
prevOutboundFlightId = OutboundFlight?.FlightId ?? 0;
}
protected override bool ShouldRender() => shouldRender;
}
事件处理程序还可以将 shouldRender
设置为 true
。 对于大多数组件,通常不需要在单个事件处理程序级别确定重新呈现。
有关详细信息,请参阅以下资源:
虚拟化
在循环中呈现大量 UI 时,例如一个包含数千个条目的列表或网格,这种大规模的渲染操作可能会导致 UI 呈现出现滞后。 鉴于用户一次只能看到少量元素而不滚动,因此花费时间呈现当前不可见的元素通常很浪费。
Blazor 提供组件 Virtualize<TItem> ,用于创建任意大型列表的外观和滚动行为,同时仅呈现当前滚动视区中的列表项。 例如,组件可以呈现包含 100,000 个条目的列表,但只支付 20 个可见项的呈现成本。
有关详细信息,请参阅 ASP.NET Core Razor 组件虚拟化。
创建轻型优化组件
大多数 Razor 组件不需要积极的优化工作,因为大多数组件不会在 UI 中重复,也不会以高频率重新呈现。 例如,具有 @page
指令的可路由组件以及用于呈现高级 UI 部分(如对话框或表单)的组件,很可能一次仅显示一个,并且仅在响应用户手势时重新呈现。 这些组件通常不会创建高渲染工作负荷,因此可以自由使用框架功能的任意组合,而不必担心渲染性能。
但是,在某些情况下,组件大规模重复,并且通常会导致 UI 性能不佳:
- 包含数百个单个元素的大型嵌套表单,例如文本输入框或标签。
- 包含数百行或数千个单元格的网格。
- 具有数百万个数据点的散点图。
如果将每个元素、单元格或数据点建模为单独的组件实例,通常数量会很多,以至于它们的渲染性能变得至关重要。 本部分提供有关使此类组件变得轻量的建议,以便 UI 保持快速且响应迅速。
避免数千个组件实例
每个组件都是一个单独的岛屿,可以独立渲染,不依赖于其父组件和子组件。 通过选择如何将 UI 拆分为组件的层次结构,可以控制 UI 呈现的粒度。 这可能会导致性能好或差。
通过将 UI 拆分为单独的组件,可以在事件发生时重新呈现 UI 的较小部分。 在包含每行中具有按钮的表中,你可能只能使用子组件(而不是整个页面或表)重新呈现该单行。 但是,每个组件都需要额外的内存和 CPU 开销来处理其独立状态和呈现生命周期。
在由 ASP.NET Core 产品单元工程师执行的测试中,Blazor WebAssembly 应用中每个组件实例的呈现开销约为 0.06 毫秒。 测试应用呈现了接受三个参数的简单组件。 在内部,开销主要是由于从字典检索每组件状态以及传递和接收参数。 通过乘法,可以看到,添加 2,000 个额外的组件实例会增加 0.12 秒的呈现时间,UI 开始对用户感觉很慢。
可以使组件更加轻量化,以便能拥有更多组件。 但是,更强大的方法通常是避免要呈现这么多元素。 以下各节介绍两种可以采用的方法。
有关内存管理的详细信息,请参阅 管理已部署 ASP.NET 核心服务器端 Blazor 应用中的内存。
将子组件直接内联到其父组件中: 请考虑父组件的以下部分,该部分循环呈现子组件:
<div class="chat">
@foreach (var message in messages)
{
<ChatMessageDisplay Message="message" />
}
</div>
ChatMessageDisplay.razor
:
<div class="chat-message">
<span class="author">@Message.Author</span>
<span class="text">@Message.Text</span>
</div>
@code {
[Parameter]
public ChatMessage? Message { get; set; }
}
如果一次未显示数千条消息,则前面的示例表现良好。 若要同时显示数千条消息,请考虑 不 分离单独的 ChatMessageDisplay
组件。 不如将子组件嵌入到父组件中。 以下方法避免了呈现大量子组件时的每个组件的开销,但代价是失去能够独立重新渲染每个子组件标记的能力:
<div class="chat">
@foreach (var message in messages)
{
<div class="chat-message">
<span class="author">@message.Author</span>
<span class="text">@message.Text</span>
</div>
}
</div>
RenderFragments
你可能会将子组件提取出来,纯粹作为重用渲染逻辑的一种方法。 如果是这种情况,则可以创建可重用的呈现逻辑,而无需实现其他组件。 在任何组件的 @code
块中,定义一个 RenderFragment。 根据需要可以从任意位置多次呈现该片段:
@RenderWelcomeInfo
<p>Render the welcome content a second time:</p>
@RenderWelcomeInfo
@code {
private RenderFragment RenderWelcomeInfo = @<p>Welcome to your new app!</p>;
}
为了使RenderTreeBuilder代码在多个组件中可重复使用,需要声明RenderFragmentpublic
和static
:
public static RenderFragment SayHello = @<h1>Hello!</h1>;
可以从不相关的组件调用前面的示例中的 SayHello
。 此技术可用于创建可重用标记片段库,这些片段无需单个组件的额外开销即可呈现。
RenderFragment 委托可以接受参数。 以下组件将消息 (message
) 传递给 RenderFragment 委托:
<div class="chat">
@foreach (var message in messages)
{
@ChatMessageDisplay(message)
}
</div>
@code {
private RenderFragment<ChatMessage> ChatMessageDisplay = message =>
@<div class="chat-message">
<span class="author">@message.Author</span>
<span class="text">@message.Text</span>
</div>;
}
上述方法重用渲染逻辑,而无须每个组件的额外开销。 但是,此方法不允许单独刷新 UI 的子树,也不能在 UI 的父树呈现时跳过呈现 UI 的子树,因为没有组件边界。 对RenderFragment委托的分配仅在Razor组件文件(.razor
)中受支持。
对于无法由字段初始值设定项引用的非静态字段、方法或属性,例如TitleTemplate
在以下示例中,请使用属性而不是字段:RenderFragment
protected RenderFragment DisplayTitle =>
@<div>
@TitleTemplate
</div>;
不要接收过多参数
如果组件非常频繁地重复,例如数百次或数千次,则传递和接收每个参数的开销会累积起来。
极少数参数严重限制性能,但可能是一个因素。 当TableCell
组件在网格中呈现4,000次时,传递给该组件的每个参数将增加大约15毫秒的渲染开销。 传递十个参数大约需要 150 毫秒,会导致 UI 呈现延迟。
若要减少参数负载,请将多个参数捆绑到自定义类中。 例如,表单元组件可能接受一个通用对象。 在以下示例中, Data
每个单元格不同,但 Options
在所有单元格实例中很常见:
@typeparam TItem
...
@code {
[Parameter]
public TItem? Data { get; set; }
[Parameter]
public GridOptions? Options { get; set; }
}
但是,请记住,将基元参数捆绑到类并不总是一个优势。 虽然它可以减少参数计数,但同时也会影响变化检测和呈现行为。 传递非基元参数始终会触发重新呈现,因为 Blazor 不知道任意对象是否具有内部可变状态,而传递基元参数只会触发重新呈现(如果它们的值实际发生更改)。
此外,请考虑没有表格单元格组件可能是一个改进,如前面的示例所示,而是将其逻辑内联至父组件。
注释
当有多个方法可用于提高性能时,通常需要对方法进行基准测试,以确定哪种方法产生最佳结果。
有关泛型类型参数的详细信息(@typeparam
),请参阅以下资源:
确保级联参数已固定
该CascadingValue
组件具有可选IsFixed
参数:
-
IsFixed
如果是false
(默认值),则级联值的每个收件人都会设置一个订阅,以接收更改通知。 由于订阅跟踪,每个成本[CascadingParameter]
比常规要[Parameter]
。 - 如果
IsFixed
是true
(例如<CascadingValue Value="someValue" IsFixed="true">
),收件人会收到初始值,但不会设置订阅来接收更新。 每个[CascadingParameter]
都是轻量级的,不比常规[Parameter]
更贵。
将IsFixed
设置为true
可以提高性能,特别是在存在大量接收级联值的其他组件的情况下。 尽可能将IsFixed
设置为true
的级联值。 当提供的值随时间推移不更改时,可以将IsFixed
设置为true
。
如果组件作为级联值传递 this
, IsFixed
也可以设置为 true
,因为在 this
组件的生命周期内永远不会发生更改:
<CascadingValue Value="this" IsFixed="true">
<SomeOtherComponents>
</CascadingValue>
有关详细信息,请参阅 ASP.NET Core Blazor 级联值和参数。
避免使用CaptureUnmatchedValues
进行属性展开
组件可以选择使用 CaptureUnmatchedValues 标志接收“不匹配”参数值:
<div @attributes="OtherAttributes">...</div>
@code {
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? OtherAttributes { get; set; }
}
此方法允许将任意附加属性传递给元素。 但是,此方法成本高昂,因为呈现器必须:
- 将所有提供的参数与一组已知参数匹配以生成字典。
- 跟踪同一属性的多个版本如何覆盖彼此。
使用 CaptureUnmatchedValues 组件呈现性能不重要的地方,例如不频繁重复的组件。 对于需要大规模渲染的组件(例如大型列表中的每个项或网格单元格中的内容),请尽量避免属性溢出。
有关详细信息,请参阅 ASP.NET Core Blazor 属性展开和任意参数。
手动实现SetParametersAsync
单个组件渲染的开销的一个重要来源是将传入的参数值写入 [Parameter]
属性。 呈现器使用 反射 来写入参数值,这可能会导致大规模性能不佳。
在一些极端情况下,你可能希望避免反射,并手动实现自己的参数设置逻辑。 这在以下情况下可能适用:
- 例如,当 UI 中组件有数百或数千个副本时,组件会非常频繁地呈现。
- 组件接受许多参数。
- 你会发现接收参数的开销对 UI 响应能力有明显的影响。
在极端情况下,您可以重写组件的虚拟 SetParametersAsync 方法,并实现您自己的组件特定逻辑。 以下示例故意避免字典查找:
@code {
[Parameter]
public int MessageId { get; set; }
[Parameter]
public string? Text { get; set; }
[Parameter]
public EventCallback<string> TextChanged { get; set; }
[Parameter]
public Theme CurrentTheme { get; set; }
public override Task SetParametersAsync(ParameterView parameters)
{
foreach (var parameter in parameters)
{
switch (parameter.Name)
{
case nameof(MessageId):
MessageId = (int)parameter.Value;
break;
case nameof(Text):
Text = (string)parameter.Value;
break;
case nameof(TextChanged):
TextChanged = (EventCallback<string>)parameter.Value;
break;
case nameof(CurrentTheme):
CurrentTheme = (Theme)parameter.Value;
break;
default:
throw new ArgumentException($"Unknown parameter: {parameter.Name}");
}
}
return base.SetParametersAsync(ParameterView.Empty);
}
}
在前面的代码中,返回基类 SetParametersAsync 运行正常生命周期方法,而无需再次分配参数。
如前面的代码所示,重写 SetParametersAsync 和提供自定义逻辑非常复杂且费力,因此我们通常不建议采用此方法。 在极端情况下,它可以将渲染性能提高 20-25%,但在本节前面列出的极端方案中,你应该只在这些极端情境中考虑此方法。
不要太快地触发事件
某些事件在浏览器中会频繁触发。 例如,onmousemove
和 onscroll
每秒可以触发数十次或数百次。 在大多数情况下,无需频繁执行 UI 更新。 如果事件触发速度过快,可能会损害 UI 响应能力,或者消耗过多的 CPU 时间。
请考虑使用 JS 互操作来注册一个触发频率较低的回调,而不是使用快速触发的本机事件。 例如,以下组件显示鼠标的位置,但每 500 毫秒最多只更新一次:
@implements IDisposable
@inject IJSRuntime JS
<h1>@message</h1>
<div @ref="mouseMoveElement" style="border:1px dashed red;height:200px;">
Move mouse here
</div>
@code {
private ElementReference mouseMoveElement;
private DotNetObjectReference<MyComponent>? selfReference;
private string message = "Move the mouse in the box";
[JSInvokable]
public void HandleMouseMove(int x, int y)
{
message = $"Mouse move at {x}, {y}";
StateHasChanged();
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
selfReference = DotNetObjectReference.Create(this);
var minInterval = 500;
await JS.InvokeVoidAsync("onThrottledMouseMove",
mouseMoveElement, selfReference, minInterval);
}
}
public void Dispose() => selfReference?.Dispose();
}
相应的 JavaScript 代码注册用于鼠标移动的 DOM 事件侦听器。 在此示例中,事件侦听器使用 Lodash 的 throttle
函数 来限制调用速率:
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.20/lodash.min.js"></script>
<script>
function onThrottledMouseMove(elem, component, interval) {
elem.addEventListener('mousemove', _.throttle(e => {
component.invokeMethodAsync('HandleMouseMove', e.offsetX, e.offsetY);
}, interval));
}
</script>
避免在处理事件后重新呈现,而无需更改状态
组件继承自 ComponentBase,该组件在调用组件的事件处理程序后自动调用 StateHasChanged 。 在某些情况下,在调用事件处理程序后,触发重新呈现可能是不必要的或不需要的。 例如,事件处理程序可能无法修改组件状态。 在这些方案中,应用可以利用 IHandleEvent 接口来控制 Blazor 的事件处理行为。
注释
本部分中的方法不会将异常传递到 错误边界。 有关通过调用ComponentBase.DispatchExceptionAsync支持错误边界功能的详细信息和演示代码,请参阅 AsNonRenderingEventHandler + ErrorBoundary = 意料之外的行为(dotnet/aspnetcore
#54543)。
若要防止组件的所有事件处理程序重新渲染,请实现 IHandleEvent 并提供一个任务,该任务调用事件处理程序但不调用 IHandleEvent.HandleEventAsync。
在以下示例中,未向组件添加任何事件处理程序触发重新呈现,因此 HandleSelect
在调用时不会导致重新呈现。
HandleSelect1.razor
:
@page "/handle-select-1"
@using Microsoft.Extensions.Logging
@implements IHandleEvent
@inject ILogger<HandleSelect1> Logger
<p>
Last render DateTime: @dt
</p>
<button @onclick="HandleSelect">
Select me (Avoids Rerender)
</button>
@code {
private DateTime dt = DateTime.Now;
private void HandleSelect()
{
dt = DateTime.Now;
Logger.LogInformation("This event handler doesn't trigger a rerender.");
}
Task IHandleEvent.HandleEventAsync(
EventCallbackWorkItem callback, object? arg) => callback.InvokeAsync(arg);
}
除了在全局范围内防止组件中的事件处理器触发后重新渲染外,还可以使用以下工具方法防止单个事件处理程序触发后的重新渲染。
将以下 EventUtil
类添加到 Blazor 应用。
EventUtil
类顶部的静态动作和函数提供处理程序,这些处理程序涵盖了多个参数和返回类型的组合,Blazor 在处理事件时使用这些组合。
EventUtil.cs
:
using System;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components;
public static class EventUtil
{
public static Action AsNonRenderingEventHandler(Action callback)
=> new SyncReceiver(callback).Invoke;
public static Action<TValue> AsNonRenderingEventHandler<TValue>(
Action<TValue> callback)
=> new SyncReceiver<TValue>(callback).Invoke;
public static Func<Task> AsNonRenderingEventHandler(Func<Task> callback)
=> new AsyncReceiver(callback).Invoke;
public static Func<TValue, Task> AsNonRenderingEventHandler<TValue>(
Func<TValue, Task> callback)
=> new AsyncReceiver<TValue>(callback).Invoke;
private record SyncReceiver(Action callback)
: ReceiverBase { public void Invoke() => callback(); }
private record SyncReceiver<T>(Action<T> callback)
: ReceiverBase { public void Invoke(T arg) => callback(arg); }
private record AsyncReceiver(Func<Task> callback)
: ReceiverBase { public Task Invoke() => callback(); }
private record AsyncReceiver<T>(Func<T, Task> callback)
: ReceiverBase { public Task Invoke(T arg) => callback(arg); }
private record ReceiverBase : IHandleEvent
{
public Task HandleEventAsync(EventCallbackWorkItem item, object arg) =>
item.InvokeAsync(arg);
}
}
调用 EventUtil.AsNonRenderingEventHandler
来调用一个不会触发渲染的事件处理程序。
在下面的示例中:
- 选择第一个按钮以调用
HandleClick1
,会触发重新渲染。 - 选择第二个按钮(调用
HandleClick2
)不会触发重新渲染。 - 选择第三个按钮,该按钮调用
HandleClick3
,不会触发重新渲染,并使用事件参数(MouseEventArgs)。
HandleSelect2.razor
:
@page "/handle-select-2"
@using Microsoft.Extensions.Logging
@inject ILogger<HandleSelect2> Logger
<p>
Last render DateTime: @dt
</p>
<button @onclick="HandleClick1">
Select me (Rerenders)
</button>
<button @onclick="EventUtil.AsNonRenderingEventHandler(HandleClick2)">
Select me (Avoids Rerender)
</button>
<button @onclick="EventUtil.AsNonRenderingEventHandler<MouseEventArgs>(HandleClick3)">
Select me (Avoids Rerender and uses <code>MouseEventArgs</code>)
</button>
@code {
private DateTime dt = DateTime.Now;
private void HandleClick1()
{
dt = DateTime.Now;
Logger.LogInformation("This event handler triggers a rerender.");
}
private void HandleClick2()
{
dt = DateTime.Now;
Logger.LogInformation("This event handler doesn't trigger a rerender.");
}
private void HandleClick3(MouseEventArgs args)
{
dt = DateTime.Now;
Logger.LogInformation(
"This event handler doesn't trigger a rerender. " +
"Mouse coordinates: {ScreenX}:{ScreenY}",
args.ScreenX, args.ScreenY);
}
}
除了实现 IHandleEvent 接口之外,利用本文中所述的其他最佳做法还可以帮助减少处理事件后不需要的呈现。 例如,在目标组件的子组件中重写 ShouldRender 可用于控制重新呈现。
避免为重复的多个元素或组件重新创建代理
Blazor在循环中为元素或组件重建lambda 表达式委托可能会导致性能不佳。
以下组件在事件处理文章中显示,并呈现一组按钮。 每个按钮为其 @onclick
事件分配一个委托,如果呈现的按钮不多,这样是合适的。
EventHandlerExample5.razor
:
@page "/event-handler-example-5"
<h1>@heading</h1>
@for (var i = 1; i < 4; i++)
{
var buttonNumber = i;
<p>
<button @onclick="@(e => UpdateHeading(e, buttonNumber))">
Button #@i
</button>
</p>
}
@code {
private string heading = "Select a button to learn its position";
private void UpdateHeading(MouseEventArgs e, int buttonNumber)
{
heading = $"Selected #{buttonNumber} at {e.ClientX}:{e.ClientY}";
}
}
@page "/event-handler-example-5"
<h1>@heading</h1>
@for (var i = 1; i < 4; i++)
{
var buttonNumber = i;
<p>
<button @onclick="@(e => UpdateHeading(e, buttonNumber))">
Button #@i
</button>
</p>
}
@code {
private string heading = "Select a button to learn its position";
private void UpdateHeading(MouseEventArgs e, int buttonNumber)
{
heading = $"Selected #{buttonNumber} at {e.ClientX}:{e.ClientY}";
}
}
如果使用上述方法呈现大量按钮,呈现速度将受到不利影响,从而导致用户体验不佳。 为了呈现大量按钮并为单击事件提供回调,以下示例使用一组按钮对象,这些按钮对象将每个按钮的 @onclick
委托分配给一个 Action。 每次呈现按钮时,以下方法不需要 Blazor 重新生成所有按钮委托:
LambdaEventPerformance.razor
:
@page "/lambda-event-performance"
<h1>@heading</h1>
@foreach (var button in Buttons)
{
<p>
<button @key="button.Id" @onclick="button.Action">
Button #@button.Id
</button>
</p>
}
@code {
private string heading = "Select a button to learn its position";
private List<Button> Buttons { get; set; } = new();
protected override void OnInitialized()
{
for (var i = 0; i < 100; i++)
{
var button = new Button();
button.Id = Guid.NewGuid().ToString();
button.Action = (e) =>
{
UpdateHeading(button, e);
};
Buttons.Add(button);
}
}
private void UpdateHeading(Button button, MouseEventArgs e)
{
heading = $"Selected #{button.Id} at {e.ClientX}:{e.ClientY}";
}
private class Button
{
public string? Id { get; set; }
public Action<MouseEventArgs> Action { get; set; } = e => { };
}
}