在 ASP.NET Core Blazor 中保留元素、元件和模型關聯性

注意

這不是這篇文章的最新版本。 如需目前版本,請參閱本文的 .NET 8 版本

重要

這些發行前產品的相關資訊在產品正式發行前可能會有大幅修改。 Microsoft 對此處提供的資訊,不做任何明確或隱含的瑕疵擔保。

如需目前版本,請參閱本文的 .NET 8 版本

本文說明如何在轉譯時使用 @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; }
}

在下列父代元件中,每次在 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; }
    }
}

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>

下列範例示範 firstsecond 索引鍵在自己範圍內的情形,兩者既無關聯,也不會影響彼此。 每個 @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 便會擲回例外狀況,因為其無法明確地將舊元素或元件對應至新的元素或元件。 請只使用相異值,例如物件執行個體或主索引鍵值。