延迟的 OnParametersSetAsync 任务触发子组件的重复 OnParametersSet

Delayed OnParametersSetAsync task triggers duplicate OnParametersSet of child components

当父 page/component 有一个“长” 运行 OnParametersSet 事件时,我试图避免在子组件中触发重复的 OnParametersSet 事件。例如,这是一个包含一些子组件的基本页面。

@page "/test"

<Node>
    <Node>
        <Node></Node>
    </Node>
</Node>

@code {
    protected override async Task OnInitializedAsync()
    {
        Console.WriteLine("Page: OnInitializedAsync - start");
        await Task.Delay(2000);
        Console.WriteLine("Page: OnInitializedAsync - finish");
    }
}

节点组件也很简单:

<div>Node: @GetHashCode()</div>
@ChildContent

@code {
    [Parameter] public RenderFragment ChildContent { get; set; }

    protected override void OnParametersSet()
    {
        Console.WriteLine("Node {0}: OnParametersSet", GetHashCode());
    }
}

这是我在控制台中看到的内容。请注意,在页面完成其 OnParametersSet 事件后,三个子组件中的两个会再次调用 OnParametersSet。

Page: OnInitializedAsync - start
Node 924945978: OnParametersSet
Node 1026183343: OnParametersSet
Node 213373360: OnParametersSet
Page: OnInitializedAsync - finish
Node 924945978: OnParametersSet
Node 1026183343: OnParametersSet

这只是 Blazor 的缺陷还是有更好的方法来避免这些额外事件?节点组件与页面本身的内容无关。在现实世界中,我可能有很多子组件,每个子组件都试图异步获取数据,所以我想防止触发这些额外事件。

我发现的唯一解决方法是用 @if(pageSetParametersEventHasFinished) 语句包装节点块,以防止节点组件在页面“准备好”之前进行初始化。

这是设计使然。请注意,2 个外部节点渲染了两次,因为它们具有 ChildContent。内节点已经是'stable'.

Blazor 'errs on the safe side',它不能保证 ChildContent 中没有任何变化。

但是OnParametersSet是(应该是)轻操作,本身不是问题。担心它后面的 Render 动作。

我的参数最佳实践

  • 在组件中保留一份副本
  • 只有当新值与副本不同时,才做fetch-data或其他工作
  • 对于重组件,使用 ShouldRender() 来最小化 re-rendering。
    看起来像这样
bool shouldRender = true;

protected override void OnAfterRender(bool firstRender)
{
   ...
   shouldRender = false;
}

protected override bool ShouldRender()
{
    return shouldRender;
}

protected override void OnParametersSet()
{        
    if (myCopy != Param)
    {
      myCopy = Param;      
      shouldRender = true;
      ...  // fetch or process data
    }
}

只有 'heavy' 渲染 and/or 处理数据的组件才值得付出努力。

修改后的答案

首先,您可以停止渲染 sub-components,直到使用简单的检查器加载数据。

@page "/"

@if (loaded)
{
    <Node>
        <Node>
            <Node></Node>
        </Node>
    </Node>
}

@code {
    private bool loaded;

    protected override async Task OnInitializedAsync()
    {
        Console.WriteLine("Page: OnInitializedAsync - start");
        await Task.Delay(2000);
        loaded = true;
        Console.WriteLine("Page: OnInitializedAsync - finish");
    }
}

在组件渲染事件期间,Renderer 在任何 sub-component 上调用 SetParametersAsync,其中一个参数已更改,或者其中一个参数是对象。没有标准的对象相等性检查器。

ChildContent 和任何其他 RenderFragment 是特例。这些是委托 - 对在父上下文中执行的父代码块的引用。如果父级需要渲染,那么任何引用 RenderFragment 委托的组件也需要渲染。所以渲染器总是 re-renders sub-components 包含有效的 RenderFragments.

在您的代码中:

    <Node>
        <Node>
            <Node></Node>
        </Node>
    </Node>
        <Node>
            <Node></Node>
        </Node>

是一个 RenderFragment 这就是为什么外部和中间 Nodes 得到 re-rendered.

如果您没有 RenderFragements,那么您可以使用 Record 将数据传递到组件并进行手动相等性检查。

以下代码对此进行了演示:

保存数据的记录

public record MyComponentData
{
    public string? Message { get; set; }
}

带有内联注释的简单组件


<div class="m-s p-2">
    Data : @this.ComponentData.Message
</div>

<div class="m-s p-2">
    On Params Called : @this.OnParamsCalled
</div>

@code {
    [Parameter] public MyComponentData ComponentData { get; set; } = new();

    private MyComponentData CurrentComponentData { get; set; } = new();

    private int OnParamsCalled = 0;

    private bool isInit = true;

    // SetParametersAsync is the method the Renderer calls on the Component to init an update
    public async override Task SetParametersAsync(ParameterView parameters)
    {
        //  ALWAYS do this first - it releases the ParameterView back to the Renderer
        parameters.SetParameterProperties(this);
        
        // As we are using records we can do a equality check
        var isupdate = this.CurrentComponentData != this.ComponentData;
        
        if (isInit | isupdate)
        {
            // If this is the first time or we have a record change runs the full ComponentBase process
            // by calling the base SetParametersAsync.  
            // Note we pass it ParameterView.Empty as we have already set the parameters in the first line
            await base.SetParametersAsync(ParameterView.Empty);

            // Set the record to the new record
            this.CurrentComponentData = ComponentData;
        }

        // set IsInit to false
        isInit = false;
    }

    protected override void OnParametersSet()
    {
        OnParamsCalled++;
        Debug.WriteLine("Component: OnParametersSet - called ");
        base.OnParametersSet();
    }
}

还有一个测试页:

@page "/"

<MyComponent ComponentData=this.myComponentData />

<div class="m-2">
    <button class="btn btn-success" @onclick=UpdateData>Update Message</button>
    <button class="btn btn-danger" @onclick=UpdateNothing>Update Message</button>
</div>

@code {
    private MyComponentData myComponentData = new MyComponentData();

    private void UpdateData()
    {
        myComponentData = myComponentData with { Message = $"Updated at {DateTime.Now.ToLongTimeString()}" };
    }

    private async Task UpdateNothing()
    {
        await Task.Delay(500);
    }


    protected override async Task OnInitializedAsync()
    {
        Debug.WriteLine("Page: OnInitializedAsync - start");
        await Task.Delay(2000);
        Debug.WriteLine("Page: OnInitializedAsync - finish");
    }
}

原答案

Is this just a flaw with Blazor

为什么有缺陷?您是在暗示 Blazor 的基本设计存在缺陷吗?

产生的任何 OnInitializedAsync 都有两个渲染事件。

  1. OnInitializedAsync 中的方法产生时。
  2. OnParametersSetAsync 完成时。

这是设计使然。通常这用于在组件中显示“正在加载”消息,直到加载与该方法关联的数据。

渲染进程只是更新 DOM 的渲染器版本。只有对 DOM 的更改才会传输到浏览器 DOM.

使用如下处理参数更改的抽象组件。

public abstract class WatchComponent : ComponentBase
{
    private bool _isDirty = true;
    private bool _shouldRender = true;

    protected abstract Task OnParametersChangedAsync();

    protected override async Task OnParametersSetAsync()
    {
        if (!_isDirty) return;

        await OnParametersChangedAsync();
        _isDirty = false;
    }

    protected override void OnAfterRender(bool firstRender)
    {
        _shouldRender = false; // disable if child component has RenderFragment parameter
        Console.WriteLine("{0}: OnAfterRender", GetHashCode());
        base.OnAfterRender(firstRender);
    }

    protected override bool ShouldRender() => _shouldRender;

    protected void SetField<T>(ref T field, T value)
    {
        if (EqualityComparer<T>.Default.Equals(field, value)) return;
        Console.WriteLine("parameter changed from {0} to {1}", field, value);
        field = value;
        _isDirty = true;
        _shouldRender = true;
    }
}

将它与上面的演示一起使用,我尝试了以下操作,一切似乎都有效。

更新页面:

@page "/test"

<Node Value="@_value1">
    <Node Value="@_value2">
        <Node></Node>
    </Node>
</Node>

@code {
    private string _value1 = "v1";
    private string _value2 = "v2";

    protected override async Task OnInitializedAsync()
    {
        Console.WriteLine("Page: OnInitializedAsync - start");
        await Task.Delay(2000);
        Console.WriteLine("Page: OnInitializedAsync - finish");
        _value1 = "v1.1";
        _value2 = "v2";
    }
}

更新节点组件:

@inherits WatchComponent

<div>Node: @GetHashCode(), Value="@Value"</div>

@ChildContent

@code {

    private string _value;
    [Parameter]
    public string Value
    {
        get => _value;
        set => SetField(ref _value, value);
    }

    [Parameter] public RenderFragment ChildContent { get; set; }

    protected override Task OnParametersChangedAsync()
    {
        // load something async here
        return Task.CompletedTask;
    }
}

控制台输出:

Page: OnInitializedAsync - start
parameter changed from  to v1
parameter changed from  to v2
833522111: OnAfterRender
854912238: OnAfterRender
668729564: OnAfterRender
Page: OnInitializedAsync - finish
parameter changed from v1 to v1.1
833522111: OnAfterRender