ASP.NET Core Razor 组件呈现

注意

此版本不是本文的最新版本。 有关当前版本的信息,请参阅.NET 9 版本的本文。

警告

此版本的 ASP.NET Core 不再受支持。 有关详细信息,请参阅 .NET 和 .NET Core 支持策略。 有关当前版本的信息,请参阅.NET 9 版本的本文。

重要

此信息与预发布产品相关,相应产品在商业发布之前可能会进行重大修改。 Microsoft 对此处提供的信息不提供任何明示或暗示的保证。

有关当前版本的信息,请参阅.NET 9 版本的本文。

本文介绍 ASP.NET Core Razor 应用中的 Blazor 组件呈现,包括何时调用 StateHasChanged 以手动触发要呈现的组件。

ComponentBase 的呈现约定

当组件第一次通过父组件添加到组件层次结构时,它们必须呈现。 只有在这种情况下,组件才必须呈现。 在其他情况下,组件可以按照各自的逻辑和约定呈现。

Razor 组件继承自 ComponentBase 基类,该基类包含在以下时间触发重新呈现的逻辑:

如果以下任一条件成立,则继承自 ComponentBase 的组件会跳过因为参数更新导致的重新渲染:

控制渲染流程

在大多数情况下,ComponentBase 约定会在某个事件发生后导致重新呈现正确的组件子集。 开发人员通常不需要提供手动逻辑来告诉框架,要重新呈现哪些组件以及何时重新呈现它们。 框架约定的总体效果如下:接收事件的组件重新呈现自身,从而递归触发参数值可能已更改的后代组件的重新呈现。

若要详细了解框架约定的性能影响以及如何优化应用的组件层次结构进行呈现,请参阅 ASP.NET 核心 Blazor 呈现性能最佳做法

流式渲染

将流式呈现与静态服务器端呈现(静态 SSR)或预呈现结合使用,以在响应流中传输内容更新,并改善需要执行长期运行的异步任务才能完全呈现的组件的用户体验。

例如,假设在页面加载时,有一个组件进行长运行数据库查询或 Web API 调用来渲染数据。 通常,作为渲染服务器端组件其中一部分来执行的异步任务必须在发送渲染好的响应之前完成,这会导致页面加载延迟。 渲染页面时出现严重延迟会破坏用户体验。 为改善用户体验,流式渲染最开始使用占位符内容快速渲染整个页面,同时执行异步操作。 操作完成后,更新的内容将在同一响应连接上发送到客户端,并更新到 DOM 中。

流式呈现要求服务器避免缓冲输出。 响应数据必须在生成数据时流向客户端。 对于强制缓冲的主机,流式渲染会平稳降级,因此页面可以不使用流式渲染进行加载。

若要在使用静态服务器端呈现(静态 SSR)或预呈现时流式传输内容更新,请将 .NET 9 或更高版本中的 [StreamRendering] 属性(在 .NET 8 中使用 [StreamRendering(true)])应用于组件。 必须明确启用流式渲染,因为流媒体更新可能导致页面内容移动。 如果父组件使用此功能,则没有该属性的组件会自动采用流式渲染。 将 false 传递给子组件中的属性以那时禁用此功能,以及更下级的组件子树。 当对Razor类库提供的组件应用时,该属性可以正常运行。

以下示例是基于从Weather创建的应用中的Blazor Web App 组件。 调用“Task.Delay”会模拟异步检索天气数据。 该组件最开始不等待异步延迟完成就渲染占位符内容 ("Loading...")。 异步延迟完成并生成天气数据内容后,内容会流式传输到响应并修补到天气预报表格中。

Weather.razor:

@page "/weather"
@attribute [StreamRendering]

...

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        ...
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

@code {
    ...

    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        await Task.Delay(500);

        ...

        forecasts = ...
    }
}

禁止 UI 刷新 (ShouldRender)

每次呈现组件时都会调用 ShouldRender。 替代 ShouldRender 以管理 UI 刷新。 如果实现返回 true,则刷新 UI。

即使 ShouldRender 被替代,组件也始终在最初呈现。

ControlRender.razor:

@page "/control-render"

<PageTitle>Control Render</PageTitle>

<h1>Control Render Example</h1>

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender() => shouldRender;

    private void IncrementCount() => currentCount++;
}
@page "/control-render"

<PageTitle>Control Render</PageTitle>

<h1>Control Render Example</h1>

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender() => shouldRender;

    private void IncrementCount() => currentCount++;
}
@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}
@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}
@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}
@page "/control-render"

<label>
    <input type="checkbox" @bind="shouldRender" />
    Should Render?
</label>

<p>Current count: @currentCount</p>

<p>
    <button @onclick="IncrementCount">Click me</button>
</p>

@code {
    private int currentCount = 0;
    private bool shouldRender = true;

    protected override bool ShouldRender()
    {
        return shouldRender;
    }

    private void IncrementCount()
    {
        currentCount++;
    }
}

有关性能最佳做法的详细信息 ShouldRender,请参阅 ASP.NET 核心 Blazor 呈现性能最佳做法

StateHasChanged

调用 StateHasChanged 会将要在应用主线程空闲时发生的重新呈现操作排入队列。

组件会排入队列以便呈现,如果已经有待处理的重新呈现操作,则不会将它们再次排入队列。 如果组件在循环中连续调用 StateHasChanged 五次,则该组件只会呈现一次。 在 ComponentBase 中对此行为进行了编码,这会在将另一个重新呈现操作排入队列之前先检查它是否已经将重新呈现操作排队。

一个组件可以在同一个周期内多次呈现,这通常发生在组件有相互交互的子组件时:

  • 父组件可以呈现多个子组件。
  • 子组件可以对父组件呈现并触发更新。
  • 父组件以新状态重新呈现。

此设计允许在必要时调用 StateHasChanged,没有引入不必要的呈现风险。 你始终可以通过直接实现 IComponent,并在组件呈现时手动处理以在单个组件中控制此行为。

请考虑以下 IncrementCount 方法,该方法递增计数、调用 StateHasChanged 并再次递增计数:

private void IncrementCount()
{
    currentCount++;
    StateHasChanged();
    currentCount++;
}

在调试器中逐步执行代码,你可能会认为在调用 currentCount++ 之后会在 UI 中针对 StateHasChanged 立即进行更新。 但是,由于此方法的执行引发了同步处理,因此 UI 此时不会显示更新的计数。 在事件处理程序完成之前,呈现器没有机会呈现组件。 UI 会currentCount++显示这两项 执行的增加额。

如果在 currentCount++ 行之间等待某些内容,则等待的调用会给呈现器一个呈现的机会。 这会导致一些开发人员在其组件中调用 Delay 时会延迟一毫秒,以确保呈现操作能够发生,但我们不建议随意放慢应用速度来将呈现排入队列。

最佳方法是等待 Task.Yield,这会使组件强制异步处理代码,并在当前批处理过程中呈现;在生成的任务运行回调后,会在单独的批次中进行第二次呈现。

请考虑以下修改后的 IncrementCount 方法,该方法会更新两次 UI,因为在通过调用 StateHasChanged 来生成任务时,会执行 Task.Yield 排队的呈现:

private async Task IncrementCount()
{
    currentCount++;
    StateHasChanged();
    await Task.Yield();
    currentCount++;
}

但请注意避免不必要地调用 StateHasChanged,因为这是一个常见错误,会带来不必要的呈现成本。 对于以下情况,代码不需要调用 StateHasChanged

  • 定期处理事件(无论是同步还是异步),因为 ComponentBase 会触发大多数常规事件处理函数的渲染。
  • 实现 OnInitializedOnParametersSetAsync 等典型生命周期逻辑(无论是同步还是异步),因为 ComponentBase 会触发典型生命周期事件的呈现。

但是,在本文以下部分所述的情况下,可能适合调用 StateHasChanged

异步处理程序涉及多个异步阶段

由于在 .NET 中定义任务的方式,Task 的接收方只能观察到其最终完成状态,而观察不到中间异步状态。 因此,ComponentBase 仅在 Task 首次返回时和 Task 最终完成时才能触发重新呈现。 框架不知道是否在其他中间点重新呈现组件,例如当 IAsyncEnumerable<T> 在一系列中间 Task 中返回数据时。 如果要在中间点重新呈现,请在这些点调用 StateHasChanged

以下面的 CounterState1 组件为例,该组件在每次执行 IncrementCount 方法时都会更新计数四次:

  • 自动渲染在 currentCount 第一次和最后一次递增之后进行。
  • 当框架未在 StateHasChanged 递增的中间处理点自动触发重新呈现时,通过调用 currentCount 触发手动呈现。

CounterState1.razor:

@page "/counter-state-1"

<PageTitle>Counter State 1</PageTitle>

<h1>Counter State Example 1</h1>

<p>
    Current count: @currentCount
</p>

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

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<PageTitle>Counter State 1</PageTitle>

<h1>Counter State Example 1</h1>

<p>
    Current count: @currentCount
</p>

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

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

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

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

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

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

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

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}
@page "/counter-state-1"

<p>
    Current count: @currentCount
</p>

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

@code {
    private int currentCount = 0;

    private async Task IncrementCount()
    {
        currentCount++;
        // Renders here automatically

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        StateHasChanged();

        await Task.Delay(1000);
        currentCount++;
        // Renders here automatically
    }
}

从 Blazor 呈现和事件处理系统外部接收调用

ComponentBase 只知道其自身的生命周期方法和 Blazor 触发的事件。 ComponentBase 不知道代码中可能发生的其他事件。 例如,Blazor 不知道自定义数据存储引发的任何 C# 事件。 为了使此类事件触发重新呈现,从而在 UI 中显示已更新的值,请调用 StateHasChanged

以下面的 CounterState2 组件为例,该组件使用 System.Timers.Timer 定期更新计数并调用 StateHasChanged 来更新 UI:

  • OnTimerCallback 在 Blazor 管理的任何呈现流或事件通知之外运行。 因此,OnTimerCallback 必须调用 StateHasChanged,因为 Blazor 无法在回调中了解 currentCount 的更改。
  • 组件实现 IDisposable,其中的 Timer 会在框架调用 Dispose 方法时被释放。 有关详细信息,请参阅 ASP.NET Core Razor 组件处置

由于回调是在 Blazor 的同步上下文之外调用的,因此组件必须将 OnTimerCallback 的逻辑包装在 ComponentBase.InvokeAsync 中,以将其移到呈现器的同步上下文中。 这等效于封送到其他 UI 框架中的 UI 线程。 StateHasChanged 只能从呈现器的同步上下文调用,否则会引发异常:

System.InvalidOperationException:“当前线程未与 Dispatcher 相关联。 触发呈现或组件状态时,使用 InvokeAsync() 将执行切换到 Dispatcher。”

CounterState2.razor:

@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<PageTitle>Counter State 2</PageTitle>

<h1>Counter State Example 2</h1>

<p>
    This counter demonstrates <code>Timer</code> disposal.
</p>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<PageTitle>Counter State 2</PageTitle>

<h1>Counter State Example 2</h1>

<p>
    This counter demonstrates <code>Timer</code> disposal.
</p>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}
@page "/counter-state-2"
@using System.Timers
@implements IDisposable

<h1>Counter with <code>Timer</code> disposal</h1>

<p>
    Current count: @currentCount
</p>

@code {
    private int currentCount = 0;
    private Timer timer = new Timer(1000);

    protected override void OnInitialized()
    {
        timer.Elapsed += (sender, eventArgs) => OnTimerCallback();
        timer.Start();
    }

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            currentCount++;
            StateHasChanged();
        });
    }

    public void Dispose() => timer.Dispose();
}

在由特定事件重新呈现的子树之外呈现组件

UI 可能涉及:

  1. 向一个组件调度事件。
  2. 更改某种状态。
  3. 重新呈现完全不同的组件(该组件不是接收事件的组件的后代)。

处理此情况的一种方法是,提供状态管理类(通常作为依赖关系注入 (DI) 服务)注入到多个组件。 当一个组件在状态管理器上调用方法时,状态管理器引发 C# 事件,然后由独立组件接收该事件。

有关管理状态的方法,请参阅以下资源:

对于状态管理器方法,C# 事件位于 Blazor 呈现管道之外。 对要重新呈现的其他组件调用 StateHasChanged 以响应状态管理器的事件。

状态管理器方法与前面的 System.Timers.Timer 案例(上一部分中)类似。 由于执行调用堆栈通常保留在呈现器的同步上下文中,因此通常不需要调用 InvokeAsync。 只有当逻辑离开同步上下文时才需要调用 InvokeAsync,例如在 ContinueWith 上调用 Task 或使用 Task 等待 ConfigureAwait(false)。 有关详细信息,请参阅从 Blazor 呈现和事件处理系统外部接收调用部分。

Blazor Web App 的 WebAssembly 加载进度指示器

从 Blazor Web App 项目模板创建的应用中没有加载进度指示器。 为将来要发布的一个 .NET 版本规划了新的加载进度指示器功能。 同时,应用可采用自定义代码来创建加载进度指示器。 有关详细信息,请参阅 ASP.NET Core Blazor 启动