ASP.NET Core Razor 组件虚拟化

注意

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

警告

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

重要

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

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

本文介绍如何在 ASP.NET Core Blazor 应用中使用组件虚拟化。

虚拟化

使用 Blazor 框架的内置虚拟化支持和 Virtualize<TItem> 组件提高组件呈现的感知性能。 虚拟化是一种技术,用于将 UI 呈现限制为仅当前可见的部分。 例如,当应用必须呈现项的长列表,并且在任何给定的时间只需要一小部分项可见时,虚拟化很有帮助。

使用 Virtualize<TItem> 组件的情形:

  • 在循环中呈现一组数据项。
  • 由于滚动,大多数项不可见。
  • 呈现的项的大小相同。

当用户滚动到 Virtualize<TItem> 组件的项列表中的任意点时,组件将计算要显示的可见项。 看不见的项将不会呈现。

如果不使用虚拟化,典型列表可能会使用 C# foreach 循环来呈现列表中的每一项。 如下示例中:

  • allFlights 是飞机航班的集合。
  • FlightSummary 组件显示有关每个航班的详细信息。
  • @key 指令属性按照航班 FlightId 保留每个 FlightSummary 组件与其呈现的航班之间的关系。
<div style="height:500px;overflow-y:scroll">
    @foreach (var flight in allFlights)
    {
        <FlightSummary @key="flight.FlightId" Details="@flight.Summary" />
    }
</div>

如果该集合包含数千个航班,则呈现这些航班会花费较长时间,并且用户会感觉到明显的 UI 滞后。 看不到大多数航班,因为它们超出了 <div> 元素的高度。

相反,不要一次性呈现整个航班列表,而是将上一个示例中的 foreach 循环替换为 Virtualize<TItem> 组件:

  • allFlights 指定为 Virtualize<TItem>.Items 的固定项源。 Virtualize<TItem> 组件仅呈现当前可见的航班。

    如果非泛型集合提供项(例如 DataRow 的集合),请按照项提供程序委托部分中的指导提供这些项。

  • 使用 Context 参数为每个航班指定上下文。 在下面的示例中,flight 用作上下文,负责提供对每个航班成员的访问。

<div style="height:500px;overflow-y:scroll">
    <Virtualize Items="allFlights" Context="flight">
        <FlightSummary @key="flight.FlightId" Details="@flight.Summary" />
    </Virtualize>
</div>

如果未使用 Context 参数指定上下文,可以在项内容模板中使用 context 的值来访问每个航班的成员:

<div style="height:500px;overflow-y:scroll">
    <Virtualize Items="allFlights">
        <FlightSummary @key="context.FlightId" Details="@context.Summary" />
    </Virtualize>
</div>

Virtualize<TItem> 组件:

  • 根据容器的高度和呈现的项的大小来计算要呈现的项数。
  • 随着用户滚动,重新计算并重新呈现项。
  • 当使用 ItemsProvider 而不是 Items 时,仅从外部 API 提取与当前可见区域相对应的记录切片,包括过度扫描(请参阅项提供程序委托部分)。

Virtualize<TItem> 组件的项内容可以包括:

  • 纯 HTML 和 Razor 代码,如前面的示例所示。
  • 一个或多个 Razor 组件。
  • HTML/Razor 和 Razor 组件的组合。

项提供程序委托

如果不想将所有项加载到内存中或者集合不是泛型 ICollection<T>,可向组件的 Virtualize<TItem>.ItemsProvider 参数指定项提供程序委托方法,以按需异步检索请求的项。 在下面的示例中,LoadEmployees 方法向 Virtualize<TItem> 组件提供项:

<Virtualize Context="employee" ItemsProvider="LoadEmployees">
    <p>
        @employee.FirstName @employee.LastName has the 
        job title of @employee.JobTitle.
    </p>
</Virtualize>

项提供程序接收 ItemsProviderRequest,它指定从特定起始索引开始的请求项数。 然后,项提供程序在数据库或其他服务中检索请求的项,并以 ItemsProviderResult<TItem> 形式将这些项与项总数一起返回。 项提供程序可以选择按每个请求检索项,也可以将项缓存以便后续使用。

Virtualize<TItem> 组件只能从其参数中接受一个项源,因此,请不要尝试在使用项提供程序的同时为 Items 分配集合。 如果同时分配两个,则在运行时设置组件的参数时,将引发 InvalidOperationException

以下示例从 EmployeeService(未显示)加载员工:

private async ValueTask<ItemsProviderResult<Employee>> LoadEmployees(
    ItemsProviderRequest request)
{
    var numEmployees = Math.Min(request.Count, totalEmployees - request.StartIndex);
    var employees = await EmployeesService.GetEmployeesAsync(request.StartIndex, 
        numEmployees, request.CancellationToken);

    return new ItemsProviderResult<Employee>(employees, totalEmployees);
}

在以下示例中,DataRow 的集合是非泛型集合,因此项提供程序委托用于虚拟化:

<Virtualize Context="row" ItemsProvider="GetRows">
    ...
</Virtualize>

@code{
    ...

    private ValueTask<ItemsProviderResult<DataRow>> GetRows(ItemsProviderRequest request) => 
        new(new ItemsProviderResult<DataRow>(
            dataTable.Rows.OfType<DataRow>().Skip(request.StartIndex).Take(request.Count),
            dataTable.Rows.Count));
}

Virtualize<TItem>.RefreshDataAsync 指示组件从其 ItemsProvider 重新请求数据。 当外部数据更改时,这很有用。 使用 Items 时,通常无需调用 RefreshDataAsync

RefreshDataAsync 会更新 Virtualize<TItem> 组件的数据,但不会造成重新呈现。 如果从 RefreshDataAsync 事件处理程序或组件生命周期方法调用 Blazor,则不需要触发呈现,因为在事件处理程序或生命周期方法结束时会自动触发呈现。 如果 RefreshDataAsync 独立于后台任务或事件(例如在以下 ForecastUpdated 委托中)触发,则请调用 StateHasChanged 以在后台任务或事件结束时更新 UI:

<Virtualize ... @ref="virtualizeComponent">
    ...
</Virtualize>

...

private Virtualize<FetchData>? virtualizeComponent;

protected override void OnInitialized()
{
    WeatherForecastSource.ForecastUpdated += async () => 
    {
        await InvokeAsync(async () =>
        {
            await virtualizeComponent?.RefreshDataAsync();
            StateHasChanged();
        });
    });
}

在上面的示例中:

占位符

由于从远程数据源请求项可能需要一些时间,你可选择呈现包含项内容的占位符:

<Virtualize Context="employee" ItemsProvider="LoadEmployees">
    <ItemContent>
        <p>
            @employee.FirstName @employee.LastName has the 
            job title of @employee.JobTitle.
        </p>
    </ItemContent>
    <Placeholder>
        <p>
            Loading&hellip;
        </p>
    </Placeholder>
</Virtualize>

内容为空

当组件已加载且 Items 为空或 ItemsProviderResult<TItem>.TotalItemCount 为零时,使用 EmptyContent 参数提供内容。

EmptyContent.razor

@page "/empty-content"

<PageTitle>Empty Content</PageTitle>

<h1>Empty Content Example</h1>

<Virtualize Items="stringList">
    <ItemContent>
        <p>
            @context
        </p>
    </ItemContent>
    <EmptyContent>
        <p>
            There are no strings to display.
        </p>
    </EmptyContent>
</Virtualize>

@code {
    private List<string>? stringList;

    protected override void OnInitialized() => stringList ??= new();
}
@page "/empty-content"

<PageTitle>Empty Content</PageTitle>

<h1>Empty Content Example</h1>

<Virtualize Items="stringList">
    <ItemContent>
        <p>
            @context
        </p>
    </ItemContent>
    <EmptyContent>
        <p>
            There are no strings to display.
        </p>
    </EmptyContent>
</Virtualize>

@code {
    private List<string>? stringList;

    protected override void OnInitialized() => stringList ??= new();
}

更改 OnInitialized 方法 lambda,查看组件显示字符串:

protected override void OnInitialized() =>
    stringList ??= new() { "Here's a string!", "Here's another string!" };

项大小

每个项的像素高度可以使用 Virtualize<TItem>.ItemSize 进行设置(默认值:50)。 下面的示例将每个项的高度从默认值 50 像素更改为 25 像素:

<Virtualize Context="employee" Items="employees" ItemSize="25">
    ...
</Virtualize>

Virtualize<TItem> 组件在初始呈现执行会测量各个项的呈现大小(高度)。 使用 ItemSize 来提前提供准确的项大小,以帮助实现准确的初始呈现性能,并确保页面重载时处于正确滚动位置。 如果默认 ItemSize 导致某些项在当前可见视图之外呈现,则会触发第二次重新呈现。 若要在虚拟化列表中正确保持浏览器的滚动位置,初始呈现必须正确。 否则,用户可能会看到错误的项。

溢出扫描计数

Virtualize<TItem>.OverscanCount 确定在可见区域之前和之后呈现的额外项数。 此设置有助于降低滚动期间的呈现频率。 但是,值越大,页面中呈现的元素越多(默认值:3)。 下面的示例将 OverscanCount 从默认值三个项更改为四个项:

<Virtualize Context="employee" Items="employees" OverscanCount="4">
    ...
</Virtualize>

状态更改

当对 Virtualize<TItem> 组件呈现的项进行更改时,将调用 StateHasChanged 来排队重新评估和重新呈现组件。 有关详细信息,请参阅 ASP.NET Core Razor 组件呈现

键盘滚动支持

若要允许用户使用键盘滚动虚拟化内容,请确保虚拟化元素或滚动容器本身可聚焦。 如果无法执行此步骤,则键盘滚动在基于 Chromium 的浏览器中不起作用。

例如,可在滚动容器上使用 tabindex 属性:

<div style="height:500px; overflow-y:scroll" tabindex="-1">
    <Virtualize Items="allFlights">
        <div class="flight-info">...</div>
    </Virtualize>
</div>

若要详细了解 tabindex-10 或其他值的含义,请参阅 tabindex(MDN 文档)

高级样式和滚动检测

根据设计,Virtualize<TItem> 组件仅支持特定的元素布局机制。 下面介绍了 Virtualize 如何检测哪些元素应显示在正确的位置,便于了解哪些元素布局可正常工作。

如果源代码如下所示:

<div style="height:500px; overflow-y:scroll" tabindex="-1">
    <Virtualize Items="allFlights" ItemSize="100">
        <div class="flight-info">Flight @context.Id</div>
    </Virtualize>
</div>

在运行时,Virtualize<TItem> 组件呈现如下所示的 DOM 结构:

<div style="height:500px; overflow-y:scroll" tabindex="-1">
    <div style="height:1100px"></div>
    <div class="flight-info">Flight 12</div>
    <div class="flight-info">Flight 13</div>
    <div class="flight-info">Flight 14</div>
    <div class="flight-info">Flight 15</div>
    <div class="flight-info">Flight 16</div>
    <div style="height:3400px"></div>
</div>

呈现的实际行数和间隔的大小因样式和 Items 集合大小而异。 但是,请注意,在内容之前和之后注入了间隔 div 元素。 这些服务有两个用途:

  • 在内容之前和之后提供偏移量,使当前可见的项目显示在滚动范围中正确位置和滚动范围本身,以表示所有内容的总大小。
  • 检测用户何时滚动超出当前可见范围,这意味着必须呈现不同的内容。

注意

若要了解如何控制间隔 HTML 元素标记,请参阅本文后面的控制间隔元素标记名称部分。

间隔元素在内部使用交集观察程序,以在其变得可见时接收通知。 Virtualize 取决于是否接收这些事件。

Virtualize 在以下情况下运行:

  • 所有呈现的内容项(包括 占位符内容)的高度相同。 这样就可计算哪项内容对应于给定的滚动位置,而无需先获取每个数据项和将数据呈现到 DOM 元素中。

  • 间隔和内容行都呈现在单个垂直堆栈中,每个项填充整个水平宽度。 在典型用例中,Virtualize 使用 div 元素。 如果使用 CSS 创建更高级的布局,请记住以下要求:

    • 滚动容器样式需要具有以下值之一的 display
      • blockdiv 的默认值)。
      • table-row-grouptbody 的默认值)。
      • flex,其中 flex-direction 设置为 column。 确保 Virtualize<TItem> 组件的直接子级不会在弹性规则下收缩。 例如,添加 .mycontainer > div { flex-shrink: 0 }
    • 内容行样式需要具有以下值之一的 display
      • blockdiv 的默认值)。
      • table-rowtr 的默认值)。
    • 请勿使用 CSS 干扰间隔元素的布局。 间隔元素的 display 值为 block,除非父元素是表行组。在表行组中,它们默认为 table-row。 不要试图影响间隔元素的宽度或高度,包括使它们具有边框或 content 伪元素。

阻止间隔和内容元素呈现为单个垂直堆栈或导致内容项高度变化的任何方法都会阻止 Virtualize<TItem> 组件正常运行。

根级虚拟化

Virtualize<TItem> 组件支持将文档本身用作滚动根,以替代另一种方法:使用具有 overflow-y: scroll 的某些其他元素。 在以下示例中,<html><body> 元素在具有 overflow-y: scroll 的组件中进行了样式设置:

<HeadContent>
    <style>
        html, body { overflow-y: scroll }
    </style>
</HeadContent>

Virtualize<TItem> 组件支持将文档本身用作滚动根,以替代另一种方法:使用具有 overflow-y: scroll 的某些其他元素。 使用文档作为滚动根时,请避免为具有 overflow-y: scroll<html><body> 元素设置样式,因为会导致交集观察程序将页面的完整可滚动高度视为可见区域,而不只是窗口视区。

可通过创建大型虚拟化列表(例如 100,000 个项)来重现此问题,并尝试在页面 CSS 样式中使用文档作为具有 html { overflow-y: scroll } 的滚动根。 尽管它有时可能正常工作,但浏览器在开始呈现时会尝试至少将 100,000 个项目全部呈现一次,这可能会导致浏览器选项卡锁定。

若要在发布 .NET 7 之前解决此问题,请避免为具有 overflow-y: scroll<html>/<body> 元素设置样式,或采用替代方法。 在以下示例中,<html> 元素的高度设置为比视区高度的 100% 稍高:

<HeadContent>
    <style>
        html { min-height: calc(100vh + 0.3px) }
    </style>
</HeadContent>

Virtualize<TItem> 组件支持将文档本身用作滚动根,以替代另一种方法:使用具有 overflow-y: scroll 的某些其他元素。 使用文档作为滚动根时,请避免为具有 overflow-y: scroll<html><body> 元素设置样式,因为会导致将页面的完整可滚动高度视为可见区域,而不只是窗口视区。

可通过创建大型虚拟化列表(例如 100,000 个项)来重现此问题,并尝试在页面 CSS 样式中使用文档作为具有 html { overflow-y: scroll } 的滚动根。 尽管它有时可能正常工作,但浏览器在开始呈现时会尝试至少将 100,000 个项目全部呈现一次,这可能会导致浏览器选项卡锁定。

若要在发布 .NET 7 之前解决此问题,请避免为具有 overflow-y: scroll<html>/<body> 元素设置样式,或采用替代方法。 在以下示例中,<html> 元素的高度设置为比视区高度的 100% 稍高:

<style>
    html { min-height: calc(100vh + 0.3px) }
</style>

控制间隔元素标记名称

如果将 Virtualize<TItem> 组件放在需要特定子标记名称的元素中,则 SpacerElement 可用于获取或设置虚拟化间隔标记名称。 默认值为 div。 对于以下示例,Virtualize<TItem> 组件在表主体元素 (tbody) 中呈现,因此将表行 (tr) 的相应子元素设置为间隔。

VirtualizedTable.razor

@page "/virtualized-table"

<PageTitle>Virtualized Table</PageTitle>

<HeadContent>
    <style>
        html, body {
            overflow-y: scroll
        }
    </style>
</HeadContent>

<h1>Virtualized Table Example</h1>

<table id="virtualized-table">
    <thead style="position: sticky; top: 0; background-color: silver">
        <tr>
            <th>Item</th>
            <th>Another column</th>
        </tr>
    </thead>
    <tbody>
        <Virtualize Items="fixedItems" ItemSize="30" SpacerElement="tr">
            <tr @key="context" style="height: 30px;" id="row-@context">
                <td>Item @context</td>
                <td>Another value</td>
            </tr>
        </Virtualize>
    </tbody>
</table>

@code {
    private List<int> fixedItems = Enumerable.Range(0, 1000).ToList();
}

在前面的示例中,文档根用作滚动容器,因此使用 overflow-y: scrollhtmlbody 元素设置样式。 有关详细信息,请参阅以下资源: