ASP.NET Core Razor 组件呈现

注意

此版本不是本文的最新版本。 对于当前版本,请参阅此文的 .NET 8 版本

警告

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

重要

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

对于当前版本,请参阅此文的 .NET 8 版本

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

ComponentBase 的呈现约定

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

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

如果满足以下任一条件,则继承自 ComponentBase 的组件会跳过因参数更新而触发的重新呈现:

  • 所有参数都是一组已知类型† 或自设置上一组参数以来未更改的任何基元类型

    † Blazor 的框架使用一组内置规则和显式参数类型检查进行更改检测。 这些规则和类型随时可能发生变化。 有关详细信息,请参阅 ASP.NET Core 参考源中的 ChangeDetection API

    注意

    指向 .NET 参考源的文档链接通常会加载存储库的默认分支,该分支表示针对下一个 .NET 版本的当前开发。 若要为特定版本选择标记,请使用“切换分支或标记”下拉列表。 有关详细信息,请参阅如何选择 ASP.NET Core 源代码的版本标记 (dotnet/AspNetCore.Docs #26205)

  • 组件 ShouldRender 方法的重写返回 false(默认 ComponentBase 实现始终返回 true)。

控制呈现流

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

有关框架约定对性能的影响,以及如何针对呈现优化应用的组件层次结构的详细信息,请参阅 ASP.NET Core Blazor 性能最佳做法

流式渲染

将流式呈现与静态服务器端呈现(静态 SSR)或预呈现一起使用,流式传输响应流上的内容更新,并改善执行长期运行的异步任务来实现完整呈现的组件的用户体验

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

流式呈现要求服务器避免缓冲输出。 响应数据必须在生成数据时流向客户端。 对于强制缓冲的主机,流式呈现会适当降级,页面会加载而不进行流式呈现。

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

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

Weather.razor

@page "/weather"
@attribute [StreamRendering(true)]

...

@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 Core Blazor 性能最佳做法

StateHasChanged

调用 StateHasChanged 会在应用的主线程空闲时,将重新呈现加入队列。

组件会排队等待呈现,并且如果已经有待处理的重新呈现,则不会再次排队。 如果组件在循环中连续调用 StateHasChanged 五次,则该组件只会呈现一次。 此行为在 ComponentBase 中进行了编码,它会先检查是否已排队等待重新呈现,然后再排队等待额外的重新呈现。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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,当框架调用 Dispose 方法时,其中的 Timer 将释放。 有关详细信息,请参阅 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,例如,对 Task 调用 ContinueWith 或使用 ConfigureAwait(false) 等待 Task。 有关详细信息,请参阅从 Blazor 呈现和事件处理系统外部接收调用部分。

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

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