在 ASP.NET Core Blazor 中保留元素、组件和模型关系

注意

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

警告

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

重要

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

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

本文介绍了如何使用 @key 指令属性在呈现以及元素或组件相继更改时,保留元素、组件和模型的关系。

@key 指令属性的名称

在呈现元素或组件的列表并且元素或组件随后发生更改时,Blazor 必须决定保留哪些元素或组件,以及模型对象应如何映射到这些元素或组件。 通常,此过程是自动完成的,并且足以用于常规呈现,但在某些情况下,需要使用 @key 指令属性来控制进程。

请考虑以下示例,该示例演示了通过使用 @key 解决的集合映射问题。

对于下列组件:

  • Details 组件从父组件接收数据 (Data),它显示在 <input> 元素中。 当用户选择一个 <input> 元素时,显示的任何给定 <input> 元素都可以从用户接收页面焦点。
  • 父组件使用 Details 组件来创建人员对象列表以进行显示。 每三秒向集合中添加一个新人员。

此演示使你可以:

  • 从多个呈现的 Details 组件中选择一个 <input>
  • 随着人员集合自动增长,研究页面焦点的行为。

Details.razor:

<input value="@Data" />

@code {
    [Parameter]
    public string? Data { get; set; }
}
<input value="@Data" />

@code {
    [Parameter]
    public string? Data { get; set; }
}
<input value="@Data" />

@code {
    [Parameter]
    public string? Data { get; set; }
}
<input value="@Data" />

@code {
    [Parameter]
    public string? Data { get; set; }
}
<input value="@Data" />

@code {
    [Parameter]
    public string Data { get; set; }
}
<input value="@Data" />

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

在以下父组件中,在 OnTimerCallback 中添加一个人员的每个迭代都会导致 Blazor 重新生成整个集合。 页面的焦点保持在 <input> 元素的相同索引位置处,因此每次添加人员时,焦点都会移动。 将焦点从用户选择的内容移开是不可取的行为。 使用以下组件演示不良行为后,使用 @key 指令特性改善用户的体验。

People.razor:

@page "/people"
@using System.Timers
@implements IDisposable

<PageTitle>People</PageTitle>

<h1>People Example</h1>

@foreach (var person in people)
{
    <Details Data="@person.Data" />
}

@code {
    private Timer timer = new Timer(3000);

    public List<Person> people =
        new()
        {
            { new Person { Data = "Person 1" } },
            { new Person { Data = "Person 2" } },
            { new Person { Data = "Person 3" } }
        };

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

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            people.Insert(0,
                new Person
                {
                    Data = $"INSERTED {DateTime.Now.ToString("hh:mm:ss tt")}"
                });
            StateHasChanged();
        });
    }

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

    public class Person
    {
        public string? Data { get; set; }
    }
}

People.razor:

@page "/people"
@using System.Timers
@implements IDisposable

<PageTitle>People</PageTitle>

<h1>People Example</h1>

@foreach (var person in people)
{
    <Details Data="@person.Data" />
}

@code {
    private Timer timer = new Timer(3000);

    public List<Person> people =
        new()
        {
            { new Person { Data = "Person 1" } },
            { new Person { Data = "Person 2" } },
            { new Person { Data = "Person 3" } }
        };

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

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            people.Insert(0,
                new Person
                {
                    Data = $"INSERTED {DateTime.Now.ToString("hh:mm:ss tt")}"
                });
            StateHasChanged();
        });
    }

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

    public class Person
    {
        public string? Data { get; set; }
    }
}

PeopleExample.razor:

@page "/people-example"
@using System.Timers
@implements IDisposable

@foreach (var person in people)
{
    <Details Data="person.Data" />
}

@code {
    private Timer timer = new Timer(3000);

    public List<Person> people =
        new()
        {
            { new Person { Data = "Person 1" } },
            { new Person { Data = "Person 2" } },
            { new Person { Data = "Person 3" } }
        };

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

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            people.Insert(0,
                new Person
                {
                    Data = $"INSERTED {DateTime.Now.ToString("hh:mm:ss tt")}"
                });
            StateHasChanged();
        });
    }

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

    public class Person
    {
        public string? Data { get; set; }
    }
}

PeopleExample.razor:

@page "/people-example"
@using System.Timers
@implements IDisposable

@foreach (var person in people)
{
    <Details Data="person.Data" />
}

@code {
    private Timer timer = new Timer(3000);

    public List<Person> people =
        new()
        {
            { new Person { Data = "Person 1" } },
            { new Person { Data = "Person 2" } },
            { new Person { Data = "Person 3" } }
        };

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

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            people.Insert(0,
                new Person
                {
                    Data = $"INSERTED {DateTime.Now.ToString("hh:mm:ss tt")}"
                });
            StateHasChanged();
        });
    }

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

    public class Person
    {
        public string? Data { get; set; }
    }
}

PeopleExample.razor:

@page "/people-example"
@using System.Timers
@implements IDisposable

@foreach (var person in people)
{
    <Details Data="person.Data" />
}

@code {
    private Timer timer = new Timer(3000);

    public List<Person> people =
        new()
        {
            { new Person { Data = "Person 1" } },
            { new Person { Data = "Person 2" } },
            { new Person { Data = "Person 3" } }
        };

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

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            people.Insert(0,
                new Person
                {
                    Data = $"INSERTED {DateTime.Now.ToString("hh:mm:ss tt")}"
                });
            StateHasChanged();
        });
    }

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

    public class Person
    {
        public string Data { get; set; }
    }
}

PeopleExample.razor:

@page "/people-example"
@using System.Timers
@implements IDisposable

@foreach (var person in people)
{
    <Details Data="person.Data" />
}

@code {
    private Timer timer = new Timer(3000);

    public List<Person> people =
        new List<Person>()
        {
            { new Person { Data = "Person 1" } },
            { new Person { Data = "Person 2" } },
            { new Person { Data = "Person 3" } }
        };

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

    private void OnTimerCallback()
    {
        _ = InvokeAsync(() =>
        {
            people.Insert(0,
                new Person
                {
                    Data = $"INSERTED {DateTime.Now.ToString("hh:mm:ss tt")}"
                });
            StateHasChanged();
        });
    }

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

    public class Person
    {
        public string Data { get; set; }
    }
}

people 集合的内容会随插入、删除或重新排序的条目而更改。 重新呈现可能会导致可见的行为差异。 例如,每向 people 集合中插入一个人员时,就会有用户的焦点丢失。

可通过 @key 指令特性来控制元素或组件到集合的映射过程。 使用 @key 可保证基于键的值保留元素或组件。 如果前面示例中的 Details 组件在 person 项上键入,则 Blazor 会忽略重新呈现未更改的 Details 组件。

要修改父组件以将 @key 指令特性用于 people 集合,请将 <Details> 元素更新为以下内容:

<Details @key="person" Data="@person.Data" />

people 集合发生更改时,会保留 Details 实例与 person 实例之间的关联。 当在集合的开头插入 Person 时,会在相应位置插入一个新的 Details 实例。 其他实例保持不变。 因此,在将人员添加到集合时,用户的焦点不会丢失。

使用 @key 指令特性时,其他集合更新会表现出相同的行为:

  • 如果从集合中删除实例,则仅从 UI 中删除相应的组件实例。 其他实例保持不变。
  • 如果对集合项进行重新排序,则保留相应的组件实例,并在 UI 中重新排序。

重要

对于每个容器元素或组件而言,键是本地的。 不会在整个文档中全局地比较键。

何时使用 @key

通常,每当呈现列表(例如,在 foreach 块中)以及存在适当的值定义 @key 时,都可以使用 @key

在对象未更改时,也可以使用 @key 保留元素或组件子树,如下面的示例所示。

示例 1:

<li @key="person">
    <input value="@person.Data" />
</li>

示例 2:

<div @key="person">
    @* other HTML elements *@
</div>

如果 person 实例更改,则 @key 特性指令会强制 Blazor:

  • 放弃整个 <li><div> 及其后代。
  • 在 UI 中使用新元素和组件重新生成子树。

这有助于保证在子树中的集合发生更改时不保留 UI 状态。

@key 的范围

@key 属性指令的范围是其父级中自己的同级。

请考虑以下示例。 firstsecond 键在外部 <div> 元素的同一范围内相互比较:

<div>
    <div @key="first">...</div>
    <div @key="second">...</div>
</div>

以下示例演示位于其自己的范围内的 first 键和 second 键,它们彼此不相关且彼此没有影响。 每个 @key 范围仅适用于其父级 <div> 元素,不适用于整个父级 <div> 元素:

<div>
    <div @key="first">...</div>
</div>
<div>
    <div @key="second">...</div>
</div>

对于上述 Details 组件,以下示例显示位于同一 @key 范围的 person 数据,并演示 @key 的典型用例:

<div>
    @foreach (var person in people)
    {
        <Details @key="person" Data="@person.Data" />
    }
</div>
@foreach (var person in people)
{
    <div @key="person">
        <Details Data="@person.Data" />
    </div>
}
<ol>
    @foreach (var person in people)
    {
        <li @key="person">
            <Details Data="@person.Data" />
        </li>
    }
</ol>

以下示例将@key 的范围仅设为围绕每个 Details 组件实例的 <div><li> 元素。 因此,people 集合的每个成员的 person 数据的键不指向所呈现的 Details 组件中的每个 person 实例。 使用 @key 时,请避免以下模式:

@foreach (var person in people)
{
    <div>
        <Details @key="person" Data="@person.Data" />
    </div>
}
<ol>
    @foreach (var person in people)
    {
        <li>
            <Details @key="person" Data="@person.Data" />
        </li>
    }
</ol>

何时不使用 @key

使用 @key 进行呈现时,会产生性能成本。 性能成本不是很大,但仅在保留元素或组件对应用有利的情况下才指定 @key

即使未使用 @key,Blazor 也会尽可能地保留子元素和组件实例。 使用 @key 的唯一优点是控制如何将模型实例映射到保留的组件实例,而不是选择映射的 Blazor。

要用于 @key 的值

通常,为 @key 提供以下值之一:

  • 模型对象实例。 例如,在前面的示例中使用了 Person 实例 (person)。 这可确保基于对象引用相等性的保留。
  • 唯一标识符。 例如,唯一标识符可以基于类型为 intstringGuid 的主键值。

确保用于 @key 的值不冲突。 如果在同一父元素内检测到冲突值,则 Blazor 引发异常,因为它无法明确地将旧元素或组件映射到新元素或组件。 仅使用非重复值,例如对象实例或主键值。