ASP.NET Core Blazor 性能最佳做法

Blazor 已针对最真实的应用程序 UI 方案中的高性能进行了优化。 但是,最佳性能取决于开发人员是否采用正确的模式和功能。

优化呈现速度

优化呈现速度以最大程度地减少呈现工作负载并提高 UI 响应能力,这可以使 UI 呈现速度提高十倍或更高。

避免不必要地呈现组件子树

发生事件时,通过跳过子组件子树的重新呈现,可以消除父组件的大部分呈现成本。 只应考虑跳过呈现子树,这些子树的呈现费用非常昂贵并且会导致 UI 延迟。

在运行时,组件存在于层次结构中。 根组件具有子组件。 反过来,根的子项也有其自己的子组件,依此类推。 发生事件(例如用户选择某个按钮)时,以下过程确定要重新呈现哪些组件:

  1. 事件将被调度到呈现事件处理程序的组件。 执行事件处理程序后,将重新呈现该组件。
  2. 每当重新呈现组件时,都会向其每个子组件提供参数值的新副本。
  3. 接收一组新的参数值后,每个组件都会决定是否要重新呈现。 默认情况下,如果参数值可能已更改(例如,如果它们是可变对象),则组件重新呈现。

前面序列的最后两个步骤以递归方式沿着组件层次结构继续向下。 在许多情况下,将重新呈现整个子树。 针对高级组件的事件可能会导致成本高昂的重新呈现,因为必须重新呈现高级组件下的所有组件。

若要防止将递归呈现到特定子树中,请使用以下方法之一:

  • 确保子组件参数为基元不可变类型,例如 stringintboolDateTime 和其他类似类型。 如果这些基元不可变参数值未更改,则用于检测更改的内置逻辑会自动跳过重新呈现。 如果使用 <Customer CustomerId="@item.CustomerId" /> 呈现子组件(其中 CustomerIdint 类型),则除非 item.CustomerId 更改,否则不会重新呈现 Customer 组件。
  • 替代 ShouldRender
    • 接受非基元参数值,例如复杂的自定义模型类型、事件回调或 RenderFragment 值。
    • 如果创作了一个仅限 UI 的组件,且该组件在最初呈现后不会更改,无论使用哪个参数值。

下面的航空公司搜索工具示例使用专用字段跟踪必要的信息以检测更改。 上一个入站航班标识符 (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 组件,用于创建任意规模的列表的外观和滚动行为,但只会呈现当前滚动视区内的列表项。 例如,组件可以呈现一个包含 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 开始变得缓慢。

可以使组件变得更轻量,以便你可以拥有更多组件。 但是,更强大技巧通常是要避免有太多要呈现的组件。 以下部分介绍了可以采用的两种方法。

将子组件嵌入到其父组件中

请考虑在循环中呈现子组件的父组件的以下部分:

<div class="chat">
    @foreach (var message in messages)
    {
        <ChatMessageDisplay Message="@message" />
    }
</div>

Shared/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 代码可跨多个组件重用,请声明 RenderFragmentpublicstatic

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 的父树时跳过呈现其子树,因为没有组件边界。 只有 Razor 组件文件 (.razor) 中支持分配给 RenderFragment 委托,并且不支持事件回叫

对于无法由字段初始化表达式引用的非静态字段、方法或属性(如以下示例中的 TitleTemplate),请使用属性,而不是 RenderFragment 的字段:

protected RenderFragment DisplayTitle =>
    @<div>
        @TitleTemplate
    </div>;

不接收太多参数

如果某个组件极频繁地重复(例如,数百或数千次),会产生传递和接收每个参数的开销。

很少有过多参数严格地限制性能,但这可能是一个因素。 对于在网格内呈现 1,000 次的 TableCell 组件,传递给该组件的每个额外参数可能会使总呈现开销增加大约 15 毫秒。 如果每个单元格接受 10 个参数,则每个组件传递参数将花费大约 150 毫秒,总呈现成本为 150,000 毫秒(150 秒),并导致 UI 呈现延迟。

若要减少参数负载,请捆绑自定义类中的多个参数。 例如,表单元格组件可能接受一个公共对象。 在下面的示例中,每个单元格的 Data 都是不同的,但 Options 在所有单元格实例中都是通用的:

@typeparam TItem

...

@code {
    [Parameter]
    public TItem? Data { get; set; }

    [Parameter]
    public GridOptions? Options { get; set; }
}

但是,考虑到不需要表单元格组件(如前面的示例所示),而是将其逻辑内联到父组件中可能是一种改进。

注意

当有多种方法可用于提高性能时,通常需要对这些方法进行基准测试,以确定哪种方法可产生最佳结果。

如需详细了解泛型类型参数 (@typeparam),请参阅以下资源:

确保级联参数是固定的

CascadingValue 组件具有可选的 IsFixed 参数:

  • 如果 IsFixedfalse(默认值),则级联值的每个接收方都会将订阅设置为接收更改通知。 由于订阅跟踪,每个 [CascadingParameter] 的开销大体上都要比常规 [Parameter] 昂贵。
  • 如果 IsFixedtrue(例如,<CascadingValue Value="@someValue" IsFixed="true">),则接收方会接收初始值,但不会将订阅设置为接收更新。 每个 [CascadingParameter] 都是轻型的,并不比常规 [Parameter] 昂贵。

如果有大量其他组件接收级联值,则将 IsFixed 设置为 true 可提高性能。 只要有可能,就应将级联值的 IsFixed 设置为 true。 当提供的值不会随时间而改变时,可以将 IsFixed 设置为 true

在组件将 this 作为级联值传递时,也可以将 IsFixed 设置为 true

<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 组件。

手动实现 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% 的呈现性能,但只应在本部分前面列出的极端方案中考虑使用此方法。

不要过快触发事件

某些浏览器事件极频繁地触发。 例如,onmousemoveonscroll 每秒可以触发数十或数百次。 在大多数情况下,不需要经常执行 UI 更新。 如果事件触发速度过快,可能会损害 UI 响应能力或消耗过多的 CPU 时间。

请考虑使用 JS 互操作来注册不太频繁触发的回调,而不是使用快速触发的本机事件。 例如,以下组件显示鼠标的位置,但每 500 毫秒最多只能更新一次:

@inject IJSRuntime JS
@implements IDisposable

<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 事件处理的行为。

若要防止重新呈现用于组件的所有事件处理程序,请实现 IHandleEvent 并提供调用事件处理程序的 IHandleEvent.HandleEventAsync 任务,而无需调用 StateHasChanged

在以下示例中,没有向组件添加任何事件处理程序会触发这类重新呈现,因此在调用时 HandleSelect 不会导致这种重新呈现。

Pages/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);
}

除了在组件中以全局方式触发事件处理程序之后阻止重新呈现,还可以通过使用以下实用工具方法,阻止在单个事件处理程序后重新呈现。

将以下 EventUntil 类添加到 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)。

Pages/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 事件分配一个委托,如果不存在多个要呈现的按钮,此操作很合适:

@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 在每次呈现按钮时重新生成所有按钮委托:

Pages/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 => { };
    }
}

优化 JavaScript 互操作速度

.NET 和 JavaScript 之间的调用需要额外的开销,因为:

  • 默认情况下,调用是异步的。
  • 默认情况下,参数和返回值已进行 JSON 序列化,以便在 .NET 和 JavaScript 类型之间提供一种易于理解的转换机制。

此外,在 Blazor Server 上,这些调用通过网络传递。

避免过度细化的调用

由于每个调用都涉及一些开销,因此减少调用次数可能会有用。 请考虑以下代码,该代码在浏览器的 localStorage 中存储项的集合:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    foreach (var item in items)
    {
        await JS.InvokeVoidAsync("localStorage.setItem", item.Id, 
            JsonSerializer.Serialize(item));
    }
}

前面的示例对每个项进行单独的 JS 互操作调用。 而以下方法则会将 JS 互操作减少为单个调用:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    await JS.InvokeVoidAsync("storeAllInLocalStorage", items);
}

相应的 JavaScript 函数将整个项集合存储在客户端上:

function storeAllInLocalStorage(items) {
  items.forEach(item => {
    localStorage.setItem(item.id, JSON.stringify(item));
  });
}

对于 Blazor WebAssembly 应用,如果组件进行大量的 JS 互操作调用,则将单独的 JS 互操作调用滚动到单个调用中通常会显著提高性能。

考虑使用同步调用

从 .NET 调用 JavaScript

本部分仅适用于 Blazor WebAssembly应用。

默认情况下,JS 互操作调用是异步的,无论调用的代码是同步还是异步。 默认情况下,调用是异步的,以确保组件在 Blazor 托管模型 Blazor Server 和 Blazor WebAssembly 之间兼容。 在 Blazor Server 上,所有 JS 互操作调用必须是异步的,因为它们是通过网络连接发送的。

如果你确定你的应用只在 Blazor WebAssembly 上运行,则可以选择执行同步 JS 互操作调用。 这比进行异步调用的开销略少,并且可能会导致呈现周期更少,因为在等待结果时没有中间状态。

若要在 Blazor WebAssembly 应用中进行从 .NET 到 JavaScript 的同步调用,请将 IJSRuntime 强制转换为 IJSInProcessRuntime 以进行 JS 互操作调用:

@inject IJSRuntime JS

...

@code {
    protected override void HandleSomeEvent()
    {
        var jsInProcess = (IJSInProcessRuntime)JS;
        var value = jsInProcess.Invoke<string>("javascriptFunctionIdentifier");
    }
}

在 ASP.NET Core 5.0 或更高版本 Blazor WebAssembly 应用中使用 IJSObjectReference 时,可以改为同步使用 IJSInProcessObjectReference

private IJSInProcessObjectReference module;

...

module = await JS.InvokeAsync<IJSInProcessObjectReference>("import", "./scripts.js");

从 JavaScript 调用 .NET

本部分仅适用于 Blazor WebAssembly应用。

默认情况下,JS 互操作调用是异步的,无论调用的代码是同步还是异步。 默认情况下,调用是异步的,以确保组件在 Blazor 托管模型 Blazor Server 和 Blazor WebAssembly 之间兼容。 在 Blazor Server 上,所有 JS 互操作调用必须是异步的,因为它们是通过网络连接发送的。

如果你确定你的应用只在 Blazor WebAssembly 上运行,则可以选择执行同步 JS 互操作调用。 这比进行异步调用的开销略少,并且可能会导致呈现周期更少,因为在等待结果时没有中间状态。

若要在 Blazor WebAssembly 应用中进行从 JavaScript 到 .NET 的同步调用,请使用 DotNet.invokeMethod 而不是 DotNet.invokeMethodAsync

同步调用在以下情况下起作用:

  • 应用在 Blazor WebAssembly(而不是 Blazor Server)上运行。
  • 调用的函数以同步方式返回值。 该函数不是 async 方法,不会返回 .NET Task 或 JavaScript Promise

使用 JavaScript [JSImport]/[JSExport] 互操作

与 ASP.NET Core 7.0 之前的框架版本中的 JS 互操作 API 相比,用于 Blazor WebAssembly 应用的 JavaScript [JSImport]/[JSExport] 互操作提供了改进的性能和稳定性。

有关详细信息,请参阅 JavaScript JSImport/JSExport 与 ASP.NET Core Blazor WebAssembly 互操作

预先 (AOT) 编译

预先 (AOT) 编译将 Blazor 应用的 .NET 代码直接编译到原生 WebAssembly 中,供浏览器直接执行。 AOT 编译的应用往往较大,下载用时更长,但 AOT 编译的应用通常可提供更佳的运行时性能,尤其是对于执行 CPU 密集型任务的应用。 有关详细信息,请参阅托管和部署 ASP.NET Core Blazor WebAssembly

最小化应用下载大小

运行时重新链接

有关运行时重新链接如何最大程度地减小应用下载大小的信息,请参阅托管和部署 ASP.NET Core Blazor WebAssembly

使用 System.Text.Json

Blazor 的 JS 互操作实现依赖于 System.Text.Json - 这是一个性能高但内存分配较低的 JSON 序列化库。 与添加一个或多个备用 JSON 库相比,使用 System.Text.Json 不应增加应用有效负载的大小。

有关迁移指南,请参阅如何从 Newtonsoft.Json 迁移到 System.Text.Json

中间语言 (IL) 剪裁

本部分仅适用于 应用。

从 Blazor WebAssembly 应用修剪未使用的程序集会通过删除应用的二进制文件中的未使用代码来减小应用的大小。 有关详细信息,请参阅配置适用于 ASP.NET Core Blazor 的裁边器

延迟加载程序集

本部分仅适用于 应用。

当路由需要程序集时,在运行时加载程序集。 有关详细信息,请参阅 ASP.NET Core Blazor WebAssembly 中的延迟加载程序集

压缩

本部分仅适用于 应用。

发布 Blazor WebAssembly 应用时,将在发布过程中对输出内容进行静态压缩,从而减小应用的大小,并免去运行时压缩的开销。 Blazor 依赖服务器来执行内容协商和提供静态压缩的文件。

部署应用后,请验证该应用是否提供压缩的文件。 检查浏览器开发人员工具中的“网络”选项卡,并验证文件是否具有 Content-Encoding: br(Brotli 压缩)或 Content-Encoding: gz(Gzip 压缩)。 如果主机未提供压缩的文件,请按照托管和部署 ASP.NET Core Blazor WebAssembly 中的说明操作。

禁用未使用的功能

本部分仅适用于 应用。

Blazor WebAssembly 的运行时包含以下 .NET 功能,可以为较小的有效负载大小禁用这些功能:

  • 包含数据文件来确保时区信息正确。 如果应用不需要此功能,请考虑通过将应用项目文件中的 BlazorEnableTimeZoneSupport MSBuild 属性设置为 false 来禁用它:

    <PropertyGroup>
      <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
    </PropertyGroup>
    
  • 默认情况下,Blazor WebAssembly 携带在用户区域性中显示值(如日期和货币)所需的全球化资源。 如果应用不需要本地化,你可以将应用配置为支持固定区域性,这基于 en-US 区域性。

优化呈现速度

优化呈现速度以最大程度地减少呈现工作负载并提高 UI 响应能力,这可以使 UI 呈现速度提高十倍或更高。

避免不必要地呈现组件子树

发生事件时,通过跳过子组件子树的重新呈现,可以消除父组件的大部分呈现成本。 只应考虑跳过呈现子树,这些子树的呈现费用非常昂贵并且会导致 UI 延迟。

在运行时,组件存在于层次结构中。 根组件具有子组件。 反过来,根的子项也有其自己的子组件,依此类推。 发生事件(例如用户选择某个按钮)时,以下过程确定要重新呈现哪些组件:

  1. 事件将被调度到呈现事件处理程序的组件。 执行事件处理程序后,将重新呈现该组件。
  2. 每当重新呈现组件时,都会向其每个子组件提供参数值的新副本。
  3. 接收一组新的参数值后,每个组件都会决定是否要重新呈现。 默认情况下,如果参数值可能已更改(例如,如果它们是可变对象),则组件重新呈现。

前面序列的最后两个步骤以递归方式沿着组件层次结构继续向下。 在许多情况下,将重新呈现整个子树。 针对高级组件的事件可能会导致成本高昂的重新呈现,因为必须重新呈现高级组件下的所有组件。

若要防止将递归呈现到特定子树中,请使用以下方法之一:

  • 确保子组件参数为基元不可变类型,例如 stringintboolDateTime 和其他类似类型。 如果这些基元不可变参数值未更改,则用于检测更改的内置逻辑会自动跳过重新呈现。 如果使用 <Customer CustomerId="@item.CustomerId" /> 呈现子组件(其中 CustomerIdint 类型),则除非 item.CustomerId 更改,否则不会重新呈现 Customer 组件。
  • 替代 ShouldRender
    • 接受非基元参数值,例如复杂的自定义模型类型、事件回调或 RenderFragment 值。
    • 如果创作了一个仅限 UI 的组件,且该组件在最初呈现后不会更改,无论使用哪个参数值。

下面的航空公司搜索工具示例使用专用字段跟踪必要的信息以检测更改。 上一个入站航班标识符 (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 组件,用于创建任意规模的列表的外观和滚动行为,但只会呈现当前滚动视区内的列表项。 例如,组件可以呈现一个包含 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 开始变得缓慢。

可以使组件变得更轻量,以便你可以拥有更多组件。 但是,更强大技巧通常是要避免有太多要呈现的组件。 以下部分介绍了可以采用的两种方法。

将子组件嵌入到其父组件中

请考虑在循环中呈现子组件的父组件的以下部分:

<div class="chat">
    @foreach (var message in messages)
    {
        <ChatMessageDisplay Message="@message" />
    }
</div>

Shared/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 代码可跨多个组件重用,请声明 RenderFragmentpublicstatic

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 的父树时跳过呈现其子树,因为没有组件边界。 只有 Razor 组件文件 (.razor) 中支持分配给 RenderFragment 委托,并且不支持事件回叫

对于无法由字段初始化表达式引用的非静态字段、方法或属性(如以下示例中的 TitleTemplate),请使用属性,而不是 RenderFragment 的字段:

protected RenderFragment DisplayTitle =>
    @<div>
        @TitleTemplate
    </div>;

不接收太多参数

如果某个组件极频繁地重复(例如,数百或数千次),会产生传递和接收每个参数的开销。

很少有过多参数严格地限制性能,但这可能是一个因素。 对于在网格内呈现 1,000 次的 TableCell 组件,传递给该组件的每个额外参数可能会使总呈现开销增加大约 15 毫秒。 如果每个单元格接受 10 个参数,则每个组件传递参数将花费大约 150 毫秒,总呈现成本为 150,000 毫秒(150 秒),并导致 UI 呈现延迟。

若要减少参数负载,请捆绑自定义类中的多个参数。 例如,表单元格组件可能接受一个公共对象。 在下面的示例中,每个单元格的 Data 都是不同的,但 Options 在所有单元格实例中都是通用的:

@typeparam TItem

...

@code {
    [Parameter]
    public TItem? Data { get; set; }

    [Parameter]
    public GridOptions? Options { get; set; }
}

但是,考虑到不需要表单元格组件(如前面的示例所示),而是将其逻辑内联到父组件中可能是一种改进。

注意

当有多种方法可用于提高性能时,通常需要对这些方法进行基准测试,以确定哪种方法可产生最佳结果。

如需详细了解泛型类型参数 (@typeparam),请参阅以下资源:

确保级联参数是固定的

CascadingValue 组件具有可选的 IsFixed 参数:

  • 如果 IsFixedfalse(默认值),则级联值的每个接收方都会将订阅设置为接收更改通知。 由于订阅跟踪,每个 [CascadingParameter] 的开销大体上都要比常规 [Parameter] 昂贵。
  • 如果 IsFixedtrue(例如,<CascadingValue Value="@someValue" IsFixed="true">),则接收方会接收初始值,但不会将订阅设置为接收更新。 每个 [CascadingParameter] 都是轻型的,并不比常规 [Parameter] 昂贵。

如果有大量其他组件接收级联值,则将 IsFixed 设置为 true 可提高性能。 只要有可能,就应将级联值的 IsFixed 设置为 true。 当提供的值不会随时间而改变时,可以将 IsFixed 设置为 true

在组件将 this 作为级联值传递时,也可以将 IsFixed 设置为 true

<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 组件。

手动实现 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% 的呈现性能,但只应在本部分前面列出的极端方案中考虑使用此方法。

不要过快触发事件

某些浏览器事件极频繁地触发。 例如,onmousemoveonscroll 每秒可以触发数十或数百次。 在大多数情况下,不需要经常执行 UI 更新。 如果事件触发速度过快,可能会损害 UI 响应能力或消耗过多的 CPU 时间。

请考虑使用 JS 互操作来注册不太频繁触发的回调,而不是使用快速触发的本机事件。 例如,以下组件显示鼠标的位置,但每 500 毫秒最多只能更新一次:

@inject IJSRuntime JS
@implements IDisposable

<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 事件处理的行为。

若要防止重新呈现用于组件的所有事件处理程序,请实现 IHandleEvent 并提供调用事件处理程序的 IHandleEvent.HandleEventAsync 任务,而无需调用 StateHasChanged

在以下示例中,没有向组件添加任何事件处理程序会触发这类重新呈现,因此在调用时 HandleSelect 不会导致这种重新呈现。

Pages/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);
}

除了在组件中以全局方式触发事件处理程序之后阻止重新呈现,还可以通过使用以下实用工具方法,阻止在单个事件处理程序后重新呈现。

将以下 EventUntil 类添加到 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)。

Pages/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 事件分配一个委托,如果不存在多个要呈现的按钮,此操作很合适:

@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 在每次呈现按钮时重新生成所有按钮委托:

Pages/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 => { };
    }
}

优化 JavaScript 互操作速度

.NET 和 JavaScript 之间的调用需要额外的开销,因为:

  • 默认情况下,调用是异步的。
  • 默认情况下,参数和返回值已进行 JSON 序列化,以便在 .NET 和 JavaScript 类型之间提供一种易于理解的转换机制。

此外,在 Blazor Server 上,这些调用通过网络传递。

避免过度细化的调用

由于每个调用都涉及一些开销,因此减少调用次数可能会有用。 请考虑以下代码,该代码在浏览器的 localStorage 中存储项的集合:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    foreach (var item in items)
    {
        await JS.InvokeVoidAsync("localStorage.setItem", item.Id, 
            JsonSerializer.Serialize(item));
    }
}

前面的示例对每个项进行单独的 JS 互操作调用。 而以下方法则会将 JS 互操作减少为单个调用:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    await JS.InvokeVoidAsync("storeAllInLocalStorage", items);
}

相应的 JavaScript 函数将整个项集合存储在客户端上:

function storeAllInLocalStorage(items) {
  items.forEach(item => {
    localStorage.setItem(item.id, JSON.stringify(item));
  });
}

对于 Blazor WebAssembly 应用,如果组件进行大量的 JS 互操作调用,则将单独的 JS 互操作调用滚动到单个调用中通常会显著提高性能。

考虑使用同步调用

从 .NET 调用 JavaScript

本部分仅适用于 Blazor WebAssembly应用。

默认情况下,JS 互操作调用是异步的,无论调用的代码是同步还是异步。 默认情况下,调用是异步的,以确保组件在 Blazor 托管模型 Blazor Server 和 Blazor WebAssembly 之间兼容。 在 Blazor Server 上,所有 JS 互操作调用必须是异步的,因为它们是通过网络连接发送的。

如果你确定你的应用只在 Blazor WebAssembly 上运行,则可以选择执行同步 JS 互操作调用。 这比进行异步调用的开销略少,并且可能会导致呈现周期更少,因为在等待结果时没有中间状态。

若要在 Blazor WebAssembly 应用中进行从 .NET 到 JavaScript 的同步调用,请将 IJSRuntime 强制转换为 IJSInProcessRuntime 以进行 JS 互操作调用:

@inject IJSRuntime JS

...

@code {
    protected override void HandleSomeEvent()
    {
        var jsInProcess = (IJSInProcessRuntime)JS;
        var value = jsInProcess.Invoke<string>("javascriptFunctionIdentifier");
    }
}

在 ASP.NET Core 5.0 或更高版本 Blazor WebAssembly 应用中使用 IJSObjectReference 时,可以改为同步使用 IJSInProcessObjectReference

private IJSInProcessObjectReference module;

...

module = await JS.InvokeAsync<IJSInProcessObjectReference>("import", "./scripts.js");

从 JavaScript 调用 .NET

本部分仅适用于 Blazor WebAssembly应用。

默认情况下,JS 互操作调用是异步的,无论调用的代码是同步还是异步。 默认情况下,调用是异步的,以确保组件在 Blazor 托管模型 Blazor Server 和 Blazor WebAssembly 之间兼容。 在 Blazor Server 上,所有 JS 互操作调用必须是异步的,因为它们是通过网络连接发送的。

如果你确定你的应用只在 Blazor WebAssembly 上运行,则可以选择执行同步 JS 互操作调用。 这比进行异步调用的开销略少,并且可能会导致呈现周期更少,因为在等待结果时没有中间状态。

若要在 Blazor WebAssembly 应用中进行从 JavaScript 到 .NET 的同步调用,请使用 DotNet.invokeMethod 而不是 DotNet.invokeMethodAsync

同步调用在以下情况下起作用:

  • 应用在 Blazor WebAssembly(而不是 Blazor Server)上运行。
  • 调用的函数以同步方式返回值。 该函数不是 async 方法,不会返回 .NET Task 或 JavaScript Promise

考虑使用已打乱的调用

本部分仅适用于 应用。

在 Blazor WebAssembly 上运行时,可以进行从 .NET 到 JavaScript 的已打乱调用。 这些是不执行参数或返回值的 JSON 序列化的同步调用。 内存管理以及 .NET 和 JavaScript 表示形式之间的转换的所有方面均留给开发人员处理。

警告

虽然使用 IJSUnmarshalledRuntime 这种 JS 互操作方法的开销最小,但与这些 API 交互所需的 JavaScript API 目前没有文档记录,而且可能会在将来的版本中出现中断性变更。

function jsInteropCall() {
  return BINDING.js_to_mono_obj("Hello world");
}
@inject IJSRuntime JS

@code {
    protected override void OnInitialized()
    {
        var unmarshalledJs = (IJSUnmarshalledRuntime)JS;
        var value = unmarshalledJs.InvokeUnmarshalled<string>("jsInteropCall");
    }
}

预先 (AOT) 编译

预先 (AOT) 编译将 Blazor 应用的 .NET 代码直接编译到原生 WebAssembly 中,供浏览器直接执行。 AOT 编译的应用往往较大,下载用时更长,但 AOT 编译的应用通常可提供更佳的运行时性能,尤其是对于执行 CPU 密集型任务的应用。 有关详细信息,请参阅托管和部署 ASP.NET Core Blazor WebAssembly

最小化应用下载大小

运行时重新链接

有关运行时重新链接如何最大程度地减小应用下载大小的信息,请参阅托管和部署 ASP.NET Core Blazor WebAssembly

使用 System.Text.Json

Blazor 的 JS 互操作实现依赖于 System.Text.Json - 这是一个性能高但内存分配较低的 JSON 序列化库。 与添加一个或多个备用 JSON 库相比,使用 System.Text.Json 不应增加应用有效负载的大小。

有关迁移指南,请参阅如何从 Newtonsoft.Json 迁移到 System.Text.Json

中间语言 (IL) 剪裁

本部分仅适用于 应用。

从 Blazor WebAssembly 应用修剪未使用的程序集会通过删除应用的二进制文件中的未使用代码来减小应用的大小。 有关详细信息,请参阅配置适用于 ASP.NET Core Blazor 的裁边器

延迟加载程序集

本部分仅适用于 应用。

当路由需要程序集时,在运行时加载程序集。 有关详细信息,请参阅 ASP.NET Core Blazor WebAssembly 中的延迟加载程序集

压缩

本部分仅适用于 应用。

发布 Blazor WebAssembly 应用时,将在发布过程中对输出内容进行静态压缩,从而减小应用的大小,并免去运行时压缩的开销。 Blazor 依赖服务器来执行内容协商和提供静态压缩的文件。

部署应用后,请验证该应用是否提供压缩的文件。 检查浏览器开发人员工具中的“网络”选项卡,并验证文件是否具有 Content-Encoding: br(Brotli 压缩)或 Content-Encoding: gz(Gzip 压缩)。 如果主机未提供压缩的文件,请按照托管和部署 ASP.NET Core Blazor WebAssembly 中的说明操作。

禁用未使用的功能

本部分仅适用于 应用。

Blazor WebAssembly 的运行时包含以下 .NET 功能,可以为较小的有效负载大小禁用这些功能:

  • 包含数据文件来确保时区信息正确。 如果应用不需要此功能,请考虑通过将应用项目文件中的 BlazorEnableTimeZoneSupport MSBuild 属性设置为 false 来禁用它:

    <PropertyGroup>
      <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
    </PropertyGroup>
    
  • 默认情况下,Blazor WebAssembly 携带在用户区域性中显示值(如日期和货币)所需的全球化资源。 如果应用不需要本地化,你可以将应用配置为支持固定区域性,这基于 en-US 区域性。

优化呈现速度

优化呈现速度以最大程度地减少呈现工作负载并提高 UI 响应能力,这可以使 UI 呈现速度提高十倍或更高。

避免不必要地呈现组件子树

发生事件时,通过跳过子组件子树的重新呈现,可以消除父组件的大部分呈现成本。 只应考虑跳过呈现子树,这些子树的呈现费用非常昂贵并且会导致 UI 延迟。

在运行时,组件存在于层次结构中。 根组件具有子组件。 反过来,根的子项也有其自己的子组件,依此类推。 发生事件(例如用户选择某个按钮)时,以下过程确定要重新呈现哪些组件:

  1. 事件将被调度到呈现事件处理程序的组件。 执行事件处理程序后,将重新呈现该组件。
  2. 每当重新呈现组件时,都会向其每个子组件提供参数值的新副本。
  3. 接收一组新的参数值后,每个组件都会决定是否要重新呈现。 默认情况下,如果参数值可能已更改(例如,如果它们是可变对象),则组件重新呈现。

前面序列的最后两个步骤以递归方式沿着组件层次结构继续向下。 在许多情况下,将重新呈现整个子树。 针对高级组件的事件可能会导致成本高昂的重新呈现,因为必须重新呈现高级组件下的所有组件。

若要防止将递归呈现到特定子树中,请使用以下方法之一:

  • 确保子组件参数为基元不可变类型,例如 stringintboolDateTime 和其他类似类型。 如果这些基元不可变参数值未更改,则用于检测更改的内置逻辑会自动跳过重新呈现。 如果使用 <Customer CustomerId="@item.CustomerId" /> 呈现子组件(其中 CustomerIdint 类型),则除非 item.CustomerId 更改,否则不会重新呈现 Customer 组件。
  • 替代 ShouldRender
    • 接受非基元参数值,例如复杂的自定义模型类型、事件回调或 RenderFragment 值。
    • 如果创作了一个仅限 UI 的组件,且该组件在最初呈现后不会更改,无论使用哪个参数值。

下面的航空公司搜索工具示例使用专用字段跟踪必要的信息以检测更改。 上一个入站航班标识符 (prevInboundFlightId) 和上一个出站航班标识符 (prevOutboundFlightId) 会跟踪下一次可能的组件更新的信息。 如果在 OnParametersSet 中设置组件的参数,并且任一航班标识符发生更改,则会重新呈现组件,因为 shouldRender 设置为了 true。 如果在检查航班标识符后 shouldRender 的计算结果为 false,则可以避免成本高昂的重新呈现过程:

@code {
    private int prevInboundFlightId;
    private int prevOutboundFlightId;
    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;
        prevOutboundFlightId = OutboundFlight.FlightId;
    }

    protected override bool ShouldRender() => shouldRender;
}

事件处理程序还可以将 shouldRender 设置为 true。 对于大多数组件,通常不需要在单个事件处理程序级别确定重新呈现。

有关更多信息,请参见以下资源:

虚拟化

在循环中呈现大量 UI(例如,具有数千个条目的列表或网格)时,呈现操作的极大数量可能会导致 UI 呈现延迟。 假设用户只能在不滚动的情况下同时查看少量元素,则花费时间呈现当前不可见的元素通常太浪费了。

Blazor 提供了 Virtualize 组件,用于创建任意规模的列表的外观和滚动行为,但只会呈现当前滚动视区内的列表项。 例如,组件可以呈现一个包含 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 开始变得缓慢。

可以使组件变得更轻量,以便你可以拥有更多组件。 但是,更强大技巧通常是要避免有太多要呈现的组件。 以下部分介绍了可以采用的两种方法。

将子组件嵌入到其父组件中

请考虑在循环中呈现子组件的父组件的以下部分:

<div class="chat">
    @foreach (var message in messages)
    {
        <ChatMessageDisplay Message="@message" />
    }
</div>

Shared/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。 根据需要多次从任意位置呈现片段:

<h1>Hello, world!</h1>

@RenderWelcomeInfo

<p>Render the welcome info a second time:</p>

@RenderWelcomeInfo

@code {
    private RenderFragment RenderWelcomeInfo = __builder =>
    {
        <p>Welcome to your new app!</p>
    };
}

如前面的示例中所示,组件可通过其 @code 块内外的代码发出标记。 RenderFragment 委托必须接受名为 __builder、类型为 RenderTreeBuilder 的参数,以便 Razor 编译器可以为片段生成呈现说明。

注意

只有 Razor 组件文件 (.razor) 中支持分配给 RenderFragment 委托,并且不支持事件回叫

要使 RenderTreeBuilder 代码可跨多个组件重用,请将 RenderFragment 委托声明为 publicstatic

public static RenderFragment SayHello = __builder =>
{
    <h1>Hello!</h1>
};

可以从不相关的组件调用前面示例中的 SayHello。 此方法可用于生成无需每组件开销即可呈现的可重用标记段库。

RenderFragment 委托也可以接受参数。 以下组件将消息 (message) 传递给 RenderFragment 委托:

<div class="chat">
    @foreach (var message in messages)
    {
        @ChatMessageDisplay(message)
    }
</div>

@code {
    private RenderFragment<ChatMessage> ChatMessageDisplay = message => __builder =>
    {
        <div class="chat-message">
            <span class="author">@message.Author</span>
            <span class="text">@message.Text</span>
        </div>
    };
}

前面的方法提供了无需每组件开销即可重复使用呈现逻辑的好处。 但是,既无法单独刷新 UI 的子树,也无法在呈现 UI 的父树时跳过呈现其子树,因为没有组件边界。

对于无法由字段初始化表达式引用的非静态字段、方法或属性(如以下示例中的 TitleTemplate),请在 RenderFragment 委托中使用属性而不是字段:

protected RenderFragment DisplayTitle => __builder =>
{
    <div>
        @TitleTemplate
    </div>
};

不接收太多参数

如果某个组件极频繁地重复(例如,数百或数千次),会产生传递和接收每个参数的开销。

很少有过多参数严格地限制性能,但这可能是一个因素。 对于在网格内呈现 1,000 次的 TableCell 组件,传递给该组件的每个额外参数可能会使总呈现开销增加大约 15 毫秒。 如果每个单元格接受 10 个参数,则每个组件呈现传递参数将花费大约 150 毫秒,因此可能需要 150,000 毫秒(150 秒),并导致 UI 呈现延迟。

若要减少参数负载,请捆绑自定义类中的多个参数。 例如,表单元格组件可能接受一个公共对象。 在下面的示例中,每个单元格的 Data 都是不同的,但 Options 在所有单元格实例中都是通用的:

@typeparam TItem

...

@code {
    [Parameter]
    public TItem Data { get; set; }

    [Parameter]
    public GridOptions Options { get; set; }
}

但是,考虑到不需要表单元格组件(如前面的示例所示),而是将其逻辑内联到父组件中可能是一种改进。

注意

当有多种方法可用于提高性能时,通常需要对这些方法进行基准测试,以确定哪种方法可产生最佳结果。

如需详细了解泛型类型参数 (@typeparam),请参阅以下资源:

确保级联参数是固定的

CascadingValue 组件具有可选的 IsFixed 参数:

  • 如果 IsFixedfalse(默认值),则级联值的每个接收方都会将订阅设置为接收更改通知。 由于订阅跟踪,每个 [CascadingParameter] 的开销大体上都要比常规 [Parameter] 昂贵。
  • 如果 IsFixedtrue(例如,<CascadingValue Value="@someValue" IsFixed="true">),则接收方会接收初始值,但不会将订阅设置为接收更新。 每个 [CascadingParameter] 都是轻型的,并不比常规 [Parameter] 昂贵。

如果有大量其他组件接收级联值,则将 IsFixed 设置为 true 可提高性能。 只要有可能,就应将级联值的 IsFixed 设置为 true。 当提供的值不会随时间而改变时,可以将 IsFixed 设置为 true

在组件将 this 作为级联值传递时,也可以将 IsFixed 设置为 true

<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 组件。

手动实现 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% 的呈现性能,但只应在本部分前面列出的极端方案中考虑使用此方法。

不要过快触发事件

某些浏览器事件极频繁地触发。 例如,onmousemoveonscroll 每秒可以触发数十或数百次。 在大多数情况下,不需要经常执行 UI 更新。 如果事件触发速度过快,可能会损害 UI 响应能力或消耗过多的 CPU 时间。

请考虑使用 JS 互操作来注册不太频繁触发的回调,而不是使用快速触发的本机事件。 例如,以下组件显示鼠标的位置,但每 500 毫秒最多只能更新一次:

@inject IJSRuntime JS
@implements IDisposable

<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 事件处理的行为。

若要防止重新呈现用于组件的所有事件处理程序,请实现 IHandleEvent 并提供调用事件处理程序的 IHandleEvent.HandleEventAsync 任务,而无需调用 StateHasChanged

在以下示例中,没有向组件添加任何事件处理程序会触发这类重新呈现,因此在调用时 HandleSelect 不会导致这种重新呈现。

Pages/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);
}

除了在组件中以全局方式触发事件处理程序之后阻止重新呈现,还可以通过使用以下实用工具方法,阻止在单个事件处理程序后重新呈现。

将以下 EventUntil 类添加到 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)。

Pages/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 事件分配一个委托,如果不存在多个要呈现的按钮,此操作很合适:

@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 在每次呈现按钮时重新生成所有按钮委托:

Pages/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; }
    }
}

优化 JavaScript 互操作速度

.NET 和 JavaScript 之间的调用需要额外的开销,因为:

  • 默认情况下,调用是异步的。
  • 默认情况下,参数和返回值已进行 JSON 序列化,以便在 .NET 和 JavaScript 类型之间提供一种易于理解的转换机制。

此外,在 Blazor Server 上,这些调用通过网络传递。

避免过度细化的调用

由于每个调用都涉及一些开销,因此减少调用次数可能会有用。 请考虑以下代码,该代码在浏览器的 localStorage 中存储项的集合:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    foreach (var item in items)
    {
        await JS.InvokeVoidAsync("localStorage.setItem", item.Id, 
            JsonSerializer.Serialize(item));
    }
}

前面的示例对每个项进行单独的 JS 互操作调用。 而以下方法则会将 JS 互操作减少为单个调用:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    await JS.InvokeVoidAsync("storeAllInLocalStorage", items);
}

相应的 JavaScript 函数将整个项集合存储在客户端上:

function storeAllInLocalStorage(items) {
  items.forEach(item => {
    localStorage.setItem(item.id, JSON.stringify(item));
  });
}

对于 Blazor WebAssembly 应用,如果组件进行大量的 JS 互操作调用,则将单独的 JS 互操作调用滚动到单个调用中通常会显著提高性能。

考虑使用同步调用

从 .NET 调用 JavaScript

本部分仅适用于 Blazor WebAssembly应用。

默认情况下,JS 互操作调用是异步的,无论调用的代码是同步还是异步。 默认情况下,调用是异步的,以确保组件在 Blazor 托管模型 Blazor Server 和 Blazor WebAssembly 之间兼容。 在 Blazor Server 上,所有 JS 互操作调用必须是异步的,因为它们是通过网络连接发送的。

如果你确定你的应用只在 Blazor WebAssembly 上运行,则可以选择执行同步 JS 互操作调用。 这比进行异步调用的开销略少,并且可能会导致呈现周期更少,因为在等待结果时没有中间状态。

若要在 Blazor WebAssembly 应用中进行从 .NET 到 JavaScript 的同步调用,请将 IJSRuntime 强制转换为 IJSInProcessRuntime 以进行 JS 互操作调用:

@inject IJSRuntime JS

...

@code {
    protected override void HandleSomeEvent()
    {
        var jsInProcess = (IJSInProcessRuntime)JS;
        var value = jsInProcess.Invoke<string>("javascriptFunctionIdentifier");
    }
}

在 ASP.NET Core 5.0 或更高版本 Blazor WebAssembly 应用中使用 IJSObjectReference 时,可以改为同步使用 IJSInProcessObjectReference

private IJSInProcessObjectReference module;

...

module = await JS.InvokeAsync<IJSInProcessObjectReference>("import", "./scripts.js");

从 JavaScript 调用 .NET

本部分仅适用于 Blazor WebAssembly应用。

默认情况下,JS 互操作调用是异步的,无论调用的代码是同步还是异步。 默认情况下,调用是异步的,以确保组件在 Blazor 托管模型 Blazor Server 和 Blazor WebAssembly 之间兼容。 在 Blazor Server 上,所有 JS 互操作调用必须是异步的,因为它们是通过网络连接发送的。

如果你确定你的应用只在 Blazor WebAssembly 上运行,则可以选择执行同步 JS 互操作调用。 这比进行异步调用的开销略少,并且可能会导致呈现周期更少,因为在等待结果时没有中间状态。

若要在 Blazor WebAssembly 应用中进行从 JavaScript 到 .NET 的同步调用,请使用 DotNet.invokeMethod 而不是 DotNet.invokeMethodAsync

同步调用在以下情况下起作用:

  • 应用在 Blazor WebAssembly(而不是 Blazor Server)上运行。
  • 调用的函数以同步方式返回值。 该函数不是 async 方法,不会返回 .NET Task 或 JavaScript Promise

考虑使用已打乱的调用

本部分仅适用于 应用。

在 Blazor WebAssembly 上运行时,可以进行从 .NET 到 JavaScript 的已打乱调用。 这些是不执行参数或返回值的 JSON 序列化的同步调用。 内存管理以及 .NET 和 JavaScript 表示形式之间的转换的所有方面均留给开发人员处理。

警告

虽然使用 IJSUnmarshalledRuntime 这种 JS 互操作方法的开销最小,但与这些 API 交互所需的 JavaScript API 目前没有文档记录,而且可能会在将来的版本中出现中断性变更。

function jsInteropCall() {
  return BINDING.js_to_mono_obj("Hello world");
}
@inject IJSRuntime JS

@code {
    protected override void OnInitialized()
    {
        var unmarshalledJs = (IJSUnmarshalledRuntime)JS;
        var value = unmarshalledJs.InvokeUnmarshalled<string>("jsInteropCall");
    }
}

最小化应用下载大小

使用 System.Text.Json

Blazor 的 JS 互操作实现依赖于 System.Text.Json - 这是一个性能高但内存分配较低的 JSON 序列化库。 与添加一个或多个备用 JSON 库相比,使用 System.Text.Json 不应增加应用有效负载的大小。

有关迁移指南,请参阅如何从 Newtonsoft.Json 迁移到 System.Text.Json

中间语言 (IL) 剪裁

本部分仅适用于 应用。

从 Blazor WebAssembly 应用修剪未使用的程序集会通过删除应用的二进制文件中的未使用代码来减小应用的大小。 有关详细信息,请参阅配置适用于 ASP.NET Core Blazor 的裁边器

延迟加载程序集

本部分仅适用于 应用。

当路由需要程序集时,在运行时加载程序集。 有关详细信息,请参阅 ASP.NET Core Blazor WebAssembly 中的延迟加载程序集

压缩

本部分仅适用于 应用。

发布 Blazor WebAssembly 应用时,将在发布过程中对输出内容进行静态压缩,从而减小应用的大小,并免去运行时压缩的开销。 Blazor 依赖服务器来执行内容协商和提供静态压缩的文件。

部署应用后,请验证该应用是否提供压缩的文件。 检查浏览器开发人员工具中的“网络”选项卡,并验证文件是否具有 Content-Encoding: br(Brotli 压缩)或 Content-Encoding: gz(Gzip 压缩)。 如果主机未提供压缩的文件,请按照托管和部署 ASP.NET Core Blazor WebAssembly 中的说明操作。

禁用未使用的功能

本部分仅适用于 应用。

Blazor WebAssembly 的运行时包含以下 .NET 功能,可以为较小的有效负载大小禁用这些功能:

  • 包含数据文件来确保时区信息正确。 如果应用不需要此功能,请考虑通过将应用项目文件中的 BlazorEnableTimeZoneSupport MSBuild 属性设置为 false 来禁用它:

    <PropertyGroup>
      <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
    </PropertyGroup>
    
  • 默认情况下,Blazor WebAssembly 携带在用户区域性中显示值(如日期和货币)所需的全球化资源。 如果应用不需要本地化,你可以将应用配置为支持固定区域性,这基于 en-US 区域性。

优化呈现速度

优化呈现速度以最大程度地减少呈现工作负载并提高 UI 响应能力,这可以使 UI 呈现速度提高十倍或更高。

避免不必要地呈现组件子树

发生事件时,通过跳过子组件子树的重新呈现,可以消除父组件的大部分呈现成本。 只应考虑跳过呈现子树,这些子树的呈现费用非常昂贵并且会导致 UI 延迟。

在运行时,组件存在于层次结构中。 根组件具有子组件。 反过来,根的子项也有其自己的子组件,依此类推。 发生事件(例如用户选择某个按钮)时,以下过程确定要重新呈现哪些组件:

  1. 事件将被调度到呈现事件处理程序的组件。 执行事件处理程序后,将重新呈现该组件。
  2. 每当重新呈现组件时,都会向其每个子组件提供参数值的新副本。
  3. 接收一组新的参数值后,每个组件都会决定是否要重新呈现。 默认情况下,如果参数值可能已更改(例如,如果它们是可变对象),则组件重新呈现。

前面序列的最后两个步骤以递归方式沿着组件层次结构继续向下。 在许多情况下,将重新呈现整个子树。 针对高级组件的事件可能会导致成本高昂的重新呈现,因为必须重新呈现高级组件下的所有组件。

若要防止将递归呈现到特定子树中,请使用以下方法之一:

  • 确保子组件参数为基元不可变类型,例如 stringintboolDateTime 和其他类似类型。 如果这些基元不可变参数值未更改,则用于检测更改的内置逻辑会自动跳过重新呈现。 如果使用 <Customer CustomerId="@item.CustomerId" /> 呈现子组件(其中 CustomerIdint 类型),则除非 item.CustomerId 更改,否则不会重新呈现 Customer 组件。
  • 替代 ShouldRender
    • 接受非基元参数值,例如复杂的自定义模型类型、事件回调或 RenderFragment 值。
    • 如果创作了一个仅限 UI 的组件,且该组件在最初呈现后不会更改,无论使用哪个参数值。

下面的航空公司搜索工具示例使用专用字段跟踪必要的信息以检测更改。 上一个入站航班标识符 (prevInboundFlightId) 和上一个出站航班标识符 (prevOutboundFlightId) 会跟踪下一次可能的组件更新的信息。 如果在 OnParametersSet 中设置组件的参数,并且任一航班标识符发生更改,则会重新呈现组件,因为 shouldRender 设置为了 true。 如果在检查航班标识符后 shouldRender 的计算结果为 false,则可以避免成本高昂的重新呈现过程:

@code {
    private int prevInboundFlightId;
    private int prevOutboundFlightId;
    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;
        prevOutboundFlightId = OutboundFlight.FlightId;
    }

    protected override bool ShouldRender() => shouldRender;
}

事件处理程序还可以将 shouldRender 设置为 true。 对于大多数组件,通常不需要在单个事件处理程序级别确定重新呈现。

有关更多信息,请参见以下资源:

创建轻型且优化的组件

大多数 Razor 组件不需要主动的优化工作,因为大多数组件并不会在 UI 中重复,也不会高频率重新呈现。 例如,带有 @page 指令的可路由组件和用于呈现高级 UI 块(如对话框或窗体)的组件在大多数情况下一次仅显示一个,并且仅在响应用户手势时重新呈现。 这些组件通常不会产生较高的呈现工作负载,因此你可以自由地使用任意框架功能组合,而不必太担心呈现性能。

但是,在一些常见场景中,组件会大规模重复,并且经常会导致 UI 性能不佳:

  • 包含数百个单独元素(例如输入或标签)的大型嵌套窗体。
  • 包含数百行或数千个单元格的网格。
  • 包含数百万个数据点的散点图。

如果将每个元素、单元格或数据点建模为单独的组件实例,则通常会有很多呈现性能变得至关重要的实例。 本部分提供了有关使此类组件变得轻量,以便 UI 保持快速且响应迅速的建议。

避免上千个组件实例

每个组件都是单独的,可以独立于其父组件和子组件进行呈现。 通过选择如何将 UI 拆分为组件的层次结构,你将控制 UI 呈现的粒度。 这可能会导致性能良好或性能不佳。

将 UI 拆分为单独的组件后,当发生事件时,可以有较小部分的 UI 重新呈现。 在包含许多行且每行都有一个按钮的表格中,你可以通过使用子组件来仅重新呈现单行,而不是整页或整个表。 但是,每个组件都需要额外的内存和 CPU 开销,用于处理其独立状态和呈现生命周期。

由 ASP.NET Core 产品单元工程师执行的测试显示,Blazor WebAssembly 应用中每个组件实例的呈现开销约为 0.06 毫秒。 测试应用呈现了一个接受三个参数的简单组件。 在内部,开销很大程度上取决于从字典中检索每个组件的状态以及传递和接收参数。 通过成倍增加,你可以看到添加 2,000 个额外的组件实例会使呈现时间增加 0.12 秒,并且用户会觉得 UI 开始变得缓慢。

可以使组件变得更轻量,以便你可以拥有更多组件。 但是,更强大技巧通常是要避免有太多要呈现的组件。 以下部分介绍了可以采用的两种方法。

将子组件嵌入到其父组件中

请考虑在循环中呈现子组件的父组件的以下部分:

<div class="chat">
    @foreach (var message in messages)
    {
        <ChatMessageDisplay Message="@message" />
    }
</div>

Shared/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。 根据需要多次从任意位置呈现片段:

<h1>Hello, world!</h1>

@RenderWelcomeInfo

<p>Render the welcome info a second time:</p>

@RenderWelcomeInfo

@code {
    private RenderFragment RenderWelcomeInfo = __builder =>
    {
        <p>Welcome to your new app!</p>
    };
}

如前面的示例中所示,组件可通过其 @code 块内外的代码发出标记。 RenderFragment 委托必须接受名为 __builder、类型为 RenderTreeBuilder 的参数,以便 Razor 编译器可以为片段生成呈现说明。

注意

只有 Razor 组件文件 (.razor) 中支持分配给 RenderFragment 委托,并且不支持事件回叫

要使 RenderTreeBuilder 代码可跨多个组件重用,请将 RenderFragment 委托声明为 publicstatic

public static RenderFragment SayHello = __builder =>
{
    <h1>Hello!</h1>
};

可以从不相关的组件调用前面示例中的 SayHello。 此方法可用于生成无需每组件开销即可呈现的可重用标记段库。

RenderFragment 委托也可以接受参数。 以下组件将消息 (message) 传递给 RenderFragment 委托:

<div class="chat">
    @foreach (var message in messages)
    {
        @ChatMessageDisplay(message)
    }
</div>

@code {
    private RenderFragment<ChatMessage> ChatMessageDisplay = message => __builder =>
    {
        <div class="chat-message">
            <span class="author">@message.Author</span>
            <span class="text">@message.Text</span>
        </div>
    };
}

前面的方法提供了无需每组件开销即可重复使用呈现逻辑的好处。 但是,既无法单独刷新 UI 的子树,也无法在呈现 UI 的父树时跳过呈现其子树,因为没有组件边界。

对于无法由字段初始化表达式引用的非静态字段、方法或属性(如以下示例中的 TitleTemplate),请在 RenderFragment 委托中使用属性而不是字段:

protected RenderFragment DisplayTitle => __builder =>
{
    <div>
        @TitleTemplate
    </div>
};

不接收太多参数

如果某个组件极频繁地重复(例如,数百或数千次),会产生传递和接收每个参数的开销。

很少有过多参数严格地限制性能,但这可能是一个因素。 对于在网格内呈现 1,000 次的 TableCell 组件,传递给该组件的每个额外参数可能会使总呈现开销增加大约 15 毫秒。 如果每个单元格接受 10 个参数,则每个组件呈现传递参数将花费大约 150 毫秒,因此可能需要 150,000 毫秒(150 秒),并导致 UI 呈现延迟。

若要减少参数负载,请捆绑自定义类中的多个参数。 例如,表单元格组件可能接受一个公共对象。 在下面的示例中,每个单元格的 Data 都是不同的,但 Options 在所有单元格实例中都是通用的:

@typeparam TItem

...

@code {
    [Parameter]
    public TItem Data { get; set; }

    [Parameter]
    public GridOptions Options { get; set; }
}

但是,考虑到不需要表单元格组件(如前面的示例所示),而是将其逻辑内联到父组件中可能是一种改进。

注意

当有多种方法可用于提高性能时,通常需要对这些方法进行基准测试,以确定哪种方法可产生最佳结果。

如需详细了解泛型类型参数 (@typeparam),请参阅以下资源:

确保级联参数是固定的

CascadingValue 组件具有可选的 IsFixed 参数:

  • 如果 IsFixedfalse(默认值),则级联值的每个接收方都会将订阅设置为接收更改通知。 由于订阅跟踪,每个 [CascadingParameter] 的开销大体上都要比常规 [Parameter] 昂贵。
  • 如果 IsFixedtrue(例如,<CascadingValue Value="@someValue" IsFixed="true">),则接收方会接收初始值,但不会将订阅设置为接收更新。 每个 [CascadingParameter] 都是轻型的,并不比常规 [Parameter] 昂贵。

如果有大量其他组件接收级联值,则将 IsFixed 设置为 true 可提高性能。 只要有可能,就应将级联值的 IsFixed 设置为 true。 当提供的值不会随时间而改变时,可以将 IsFixed 设置为 true

在组件将 this 作为级联值传递时,也可以将 IsFixed 设置为 true

<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 组件。

手动实现 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% 的呈现性能,但只应在本部分前面列出的极端方案中考虑使用此方法。

不要过快触发事件

某些浏览器事件极频繁地触发。 例如,onmousemoveonscroll 每秒可以触发数十或数百次。 在大多数情况下,不需要经常执行 UI 更新。 如果事件触发速度过快,可能会损害 UI 响应能力或消耗过多的 CPU 时间。

请考虑使用 JS 互操作来注册不太频繁触发的回调,而不是使用快速触发的本机事件。 例如,以下组件显示鼠标的位置,但每 500 毫秒最多只能更新一次:

@inject IJSRuntime JS
@implements IDisposable

<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 事件处理的行为。

若要防止重新呈现用于组件的所有事件处理程序,请实现 IHandleEvent 并提供调用事件处理程序的 IHandleEvent.HandleEventAsync 任务,而无需调用 StateHasChanged

在以下示例中,没有向组件添加任何事件处理程序会触发这类重新呈现,因此在调用时 HandleSelect 不会导致这种重新呈现。

Pages/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);
}

除了在组件中以全局方式触发事件处理程序之后阻止重新呈现,还可以通过使用以下实用工具方法,阻止在单个事件处理程序后重新呈现。

将以下 EventUntil 类添加到 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)。

Pages/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 事件分配一个委托,如果不存在多个要呈现的按钮,此操作很合适:

@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 在每次呈现按钮时重新生成所有按钮委托:

Pages/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; }
    }
}

优化 JavaScript 互操作速度

.NET 和 JavaScript 之间的调用需要额外的开销,因为:

  • 默认情况下,调用是异步的。
  • 默认情况下,参数和返回值已进行 JSON 序列化,以便在 .NET 和 JavaScript 类型之间提供一种易于理解的转换机制。

此外,在 Blazor Server 上,这些调用通过网络传递。

避免过度细化的调用

由于每个调用都涉及一些开销,因此减少调用次数可能会有用。 请考虑以下代码,该代码在浏览器的 localStorage 中存储项的集合:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    foreach (var item in items)
    {
        await JS.InvokeVoidAsync("localStorage.setItem", item.Id, 
            JsonSerializer.Serialize(item));
    }
}

前面的示例对每个项进行单独的 JS 互操作调用。 而以下方法则会将 JS 互操作减少为单个调用:

private async Task StoreAllInLocalStorage(IEnumerable<TodoItem> items)
{
    await JS.InvokeVoidAsync("storeAllInLocalStorage", items);
}

相应的 JavaScript 函数将整个项集合存储在客户端上:

function storeAllInLocalStorage(items) {
  items.forEach(item => {
    localStorage.setItem(item.id, JSON.stringify(item));
  });
}

对于 Blazor WebAssembly 应用,如果组件进行大量的 JS 互操作调用,则将单独的 JS 互操作调用滚动到单个调用中通常会显著提高性能。

考虑使用同步调用

本部分仅适用于 应用。

默认情况下,JS 互操作调用是异步的,无论调用的代码是同步还是异步。 默认情况下,调用是异步的,以确保组件在 Blazor 托管模型 Blazor Server 和 Blazor WebAssembly 之间兼容。 在 Blazor Server 上,所有 JS 互操作调用必须是异步的,因为它们是通过网络连接发送的。

如果你确定你的应用只在 Blazor WebAssembly 上运行,则可以选择执行同步 JS 互操作调用。 这比进行异步调用的开销略少,并且可能会导致呈现周期更少,因为在等待结果时没有中间状态。

若要在 Blazor WebAssembly 应用中进行从 .NET 到 JavaScript 的同步调用,请将 IJSRuntime 强制转换为 IJSInProcessRuntime 以进行 JS 互操作调用:

@inject IJSRuntime JS

...

@code {
    protected override void HandleSomeEvent()
    {
        var jsInProcess = (IJSInProcessRuntime)JS;
        var value = jsInProcess.Invoke<string>("javascriptFunctionIdentifier");
    }
}

在 ASP.NET Core 5.0 或更高版本 Blazor WebAssembly 应用中使用 IJSObjectReference 时,可以改为同步使用 IJSInProcessObjectReference

private IJSInProcessObjectReference module;

...

module = await JS.InvokeAsync<IJSInProcessObjectReference>("import", "./scripts.js");

最小化应用下载大小

使用 System.Text.Json

Blazor 的 JS 互操作实现依赖于 System.Text.Json - 这是一个性能高但内存分配较低的 JSON 序列化库。 与添加一个或多个备用 JSON 库相比,使用 System.Text.Json 不应增加应用有效负载的大小。

有关迁移指南,请参阅如何从 Newtonsoft.Json 迁移到 System.Text.Json

中间语言 (IL) 链接

本部分仅适用于 应用。

通过链接 Blazor WebAssembly 应用,可剪裁应用二进制文件中未使用的代码来减小应用的大小。 默认情况下,仅在 Release 配置中生成时才启用中间语言 (IL) 链接器。 要从此中受益,请使用 dotnet publish 命令发布应用用于部署,并将 -c|--configuration 选项设置为 Release

dotnet publish -c Release

延迟加载程序集

本部分仅适用于 应用。

当路由需要程序集时,在运行时加载程序集。 有关详细信息,请参阅 ASP.NET Core Blazor WebAssembly 中的延迟加载程序集

压缩

本部分仅适用于 应用。

发布 Blazor WebAssembly 应用时,将在发布过程中对输出内容进行静态压缩,从而减小应用的大小,并免去运行时压缩的开销。 Blazor 依赖服务器来执行内容协商和提供静态压缩的文件。

部署应用后,请验证该应用是否提供压缩的文件。 检查浏览器开发人员工具中的“网络”选项卡,并验证文件是否具有 Content-Encoding: br(Brotli 压缩)或 Content-Encoding: gz(Gzip 压缩)。 如果主机未提供压缩的文件,请按照托管和部署 ASP.NET Core Blazor WebAssembly 中的说明操作。

禁用未使用的功能

本部分仅适用于 应用。

Blazor WebAssembly 的运行时包含以下 .NET 功能,可以为较小的有效负载大小禁用这些功能:

  • 包含数据文件来确保时区信息正确。 如果应用不需要此功能,请考虑通过将应用项目文件中的 BlazorEnableTimeZoneSupport MSBuild 属性设置为 false 来禁用它:

    <PropertyGroup>
      <BlazorEnableTimeZoneSupport>false</BlazorEnableTimeZoneSupport>
    </PropertyGroup>
    
  • 包括排序规则信息来确保 StringComparison.InvariantCultureIgnoreCase 之类的 API 正常工作。 如果确定应用不需要排序规则数据,请考虑通过将应用项目文件中的 BlazorWebAssemblyPreserveCollationData MSBuild 属性设置为 false 来禁用它:

    <PropertyGroup>
      <BlazorWebAssemblyPreserveCollationData>false</BlazorWebAssemblyPreserveCollationData>
    </PropertyGroup>
    
  • 默认情况下,Blazor WebAssembly 携带在用户区域性中显示值(如日期和货币)所需的全球化资源。 如果应用不需要本地化,你可以将应用配置为支持固定区域性,这基于 en-US 区域性。