ASP.NET Core에서 요소, 구성 요소 및 모델 관계 유지 Blazor

이 문서에서는 렌더링할 때 지시문 특성을 사용하여 @key 요소, 구성 요소 및 모델 관계를 유지하고 나중에 요소 또는 구성 요소가 변경되는 방법을 설명합니다.

@key 지시문 특성 사용

요소 또는 구성 요소 목록을 렌더링하고 이후에 요소 또는 구성 요소가 변경되는 경우 Blazor는 유지하는 이전 요소 또는 구성 요소와 모델 개체가 이러한 요소 또는 구성 요소에 매핑되는 방법을 결정해야 합니다. 일반적으로 이 프로세스는 자동이며, 일반 렌더링에 충분하지만 @key 지시문 특성을 사용하여 프로세스를 제어해야 하는 경우가 종종 있습니다.

@key을 사용하여 해결되는 컬렉션 매핑 문제를 보여 주는 다음 예제를 살펴보겠습니다.

다음 구성 요소의 경우:

  • Details 구성 요소는 요소에 표시되는 부모 구성 요소에서 데이터(Data)를 <input> 받습니다. 표시되는 모든 <input> 요소는 <input> 요소 중 하나를 선택할 때 사용자로부터 페이지의 포커스를 받을 수 있습니다.
  • 부모 구성 요소는 구성 요소를 사용하여 Details 표시할 사람 개체 목록을 만듭니다. 3초마다 새 사용자가 컬렉션에 추가됩니다.

이 데모에서는 다음을 수행할 수 있습니다.

  • 렌더링된 여러 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; }
}

다음 부모 구성 요소에서 사용자를 추가하는 각 반복은 OnTimerCallbackBlazor 전체 컬렉션을 다시 빌드합니다. 페이지의 포커스는 <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 구성 요소의 다시 렌더링을 무시합니다.

컬렉션에서 지시문 특성을 people 사용하도록 @key 부모 구성 요소를 수정하려면 요소를 다음으로 업데이트 <Details> 합니다.

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

people 컬렉션이 변경되면 Details 인스턴스와 person 인스턴스 간 연결이 유지됩니다. Person이 컬렉션의 시작 부분에 삽입되면 새로운 Details 인스턴스 하나가 해당 위치에 삽입됩니다. 다른 인스턴스는 변경되지 않은 상태로 유지됩니다. 따라서 컬렉션에 사용자가 추가될 때 사용자의 포커스를 잃지 않습니다.

@key 지시문 특성이 사용되는 경우 다른 컬렉션 업데이트도 동일한 동작을 보여 줍니다.

  • 컬렉션에서 인스턴스가 삭제되면 해당 구성 요소 인스턴스만 UI에서 제공됩니다. 다른 인스턴스는 변경되지 않은 상태로 유지됩니다.
  • 컬렉션 항목의 순서가 변경되면 해당 구성 요소 인스턴스는 유지되고 UI에서 순서가 변경됩니다.

Important

키는 각 컨테이너 요소 또는 구성 요소에 대해 로컬입니다. 문서 전체에서 키가 전역적으로 비교되지 않습니다.

@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 특성 지시문은 부모 내의 자신의 형제로 범위가 지정됩니다.

아래 예제를 고려해 보세요. first 키와 second 키는 외부 <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)가 이전 예제에서 사용되었습니다. 이렇게 하면 개체 참조 같음에 따라 보존이 이루어집니다.
  • 고유 식별자. 예를 들어 고유 식별자는 int, string 또는 Guid 형식의 기본 키 값을 기반으로 할 수 있습니다.

@key에 사용되는 값이 충돌하지 않는지 확인합니다. 동일한 부모 요소 내에서 충돌하는 값이 감지되면 이전 요소나 구성 요소를 새 요소나 구성 요소에 확정적으로 매핑할 수 없으므로 Blazor는 예외를 throw합니다. 개체 인스턴스 또는 기본 키 값과 같은 고유 값만 사용합니다.