Blazor教程 第七课:组件的高级知识:组件间的生命周期

Blazor

什么是组件?在前面几篇文章的描述里,组件是*.razor文件,是转译后的C#类。在这篇文章中,我们要把视角,从“类”,转向“这个类的实例是怎么被构造出来的”,以及“这个类的实例什么时候被析构”。

“组件生命周期”这个概念并不是Blazor创造的,这个概念在React等前端框架中也存在。简单来说,就是框架在实例化组件以及析构组件实例的过程中,调用的不仅仅只有构造函数与析构函数(这里的析构说的并不是真正的析构,而是类似于对IDisposible接口的实现),还调用了其它一些函数。而框架把这些额外的函数就称为“生命周期函数”。作为框架的使用者,程序员如若想在组件实例化与析构的过程中添加一些别的动作,可以通过覆写相应的函数来实现。

我们其实并不会了解,也没必要了解框架在具体实现过程中是如何从细节实例化一个组件的,作为框架的使用者,我们只需要了解:

  1. 生命周期函数都有哪些
  2. 这些生命周期函数分别都在什么时机会被框架调用

所以本篇文章介绍的有关组件实例化的过程模型,都是经过简化后的模型,并且随着dotnet版本的更迭,其内部实现可能会发生变更。

说实话,生命周期不是很好讲,我这篇稿子开头的500字已经重新写了四篇了,最终我决定还是放弃先讲概念再讲代码的方式,而是直接上一个例子,然后以走读代码的形式来直观的给大家讲明白。

1. 让我们先来写一个特殊的组件

这个组件唯一的作用就是用来讲解生命周期函数,位于Components/LifeCycle.razor,代码如下:

@inject HttpClient httpClient

<h3>LifeCycle: parameter == @this.param</h3>

@code {
    [Parameter]
    public string param { get; set; } = default!;

    public override Task SetParametersAsync(ParameterView parameters)
    {
        Console.WriteLine($"Entering SetParametersAsync(ParameterView parameters), before call base.SetParametersAsync(parameters): parameters == {parameters}, this.httpClient == {this.httpClient}, this.param == {this.param}");
        var res = base.SetParametersAsync(parameters);
        Console.WriteLine($"Leaving SetParametersAsync(ParameterView parameters), after call base.SetParametersAsync(parameters): parameters == {parameters}, this.httpClient == {this.httpClient}, this.param == {this.param}");
        return res;
    }

    protected override void OnInitialized()
    {
        Console.WriteLine($"Entering OnInitialized(), before call base.OnInitialized(): this.httpClient == {this.httpClient}, this.param == {this.param}");
        base.OnInitialized();
        Console.WriteLine($"Leaving OnInitialized(), after call base.OnInitialized(): this.httpClient == {this.httpClient}, this.param == {this.param}");
    }

    protected override Task OnInitializedAsync()
    {
        Console.WriteLine($"Entering OnInitializedAsync(), before call base.OnInitializedAsync(): this.httpClient == {this.httpClient}, this.param == {this.param}");
        var res = base.OnInitializedAsync();
        Console.WriteLine($"Leaving OnInitializedAsync(), after call base.OnInitializedAsync(): this.httpClient == {this.httpClient}, this.param == {this.param}");
        return res;
    }

    protected override void OnParametersSet()
    {
        Console.WriteLine($"Entering OnParametersSet(), before call base.OnParametersSet(): this.httpClient == {this.httpClient}, this.param == {this.param}");
        base.OnParametersSet();
        Console.WriteLine($"Leaving OnParametersSet(), after call base.OnParametersSet(): this.httpClient == {this.httpClient}, this.param == {this.param}");
    }

    protected override Task OnParametersSetAsync()
    {
        Console.WriteLine($"Entering OnParametersSetAsync(), before call base.OnParametersSetAsync(): this.httpClient == {this.httpClient}, this.param == {this.param}");
        var res =  base.OnParametersSetAsync();
        Console.WriteLine($"Leaving OnParametersSetAsync(), after call base.OnParametersSetAsync(): this.httpClient == {this.httpClient}, this.param == {this.param}");
        return res;
    }

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"Entering OnAfterRender(bool firstRender), before call base.OnAfterRender(firstRender): firstRender == {firstRender}, this.httpClient == {this.httpClient}, this.param == {this.param}");
        base.OnAfterRender(firstRender);
        Console.WriteLine($"Leaving OnAfterRender(bool firstRender), after call base.OnAfterRender(firstRender): firstRender == {firstRender}, this.httpClient == {this.httpClient}, this.param == {this.param}");
    }

    protected override Task OnAfterRenderAsync(bool firstRender)
    {
        Console.WriteLine($"Entering OnAfterRenderAsync(bool firstRender), before call base.OnAfterRenderAsync(firstRender): firstRender == {firstRender}, this.httpClient == {this.httpClient}, this.param == {this.param}");
        var res = base.OnAfterRenderAsync(firstRender);
        Console.WriteLine($"Leaving OnAfterRenderAsync(bool firstRender), after call base.OnAfterRenderAsync(firstRender): firstRender == {firstRender}, this.httpClient == {this.httpClient}, this.param == {this.param}");

        return res;
    }
}

这个组件非常简单

  1. 让框架注入一个HttpClient实例,但实际上并没有用到这个实例。我们主要关心的是DI注入发生在什么时候
  2. 声明了一个组件参数
  3. 就是在@code{}代码块中复写了七个生命周期函数,具体细节我们后面会慢慢讲

然后在Pages/Index.razor中如下使用这个组件:

@page "/"

@using HelloComponents.Client.Components

<LifeCycle param=@this.param/>

<button @onclick=@this.AppendDotToParam>Append dot to parameter which will pass to LifeCycle component</button>

@code {
    private string param = "string param assigned in Index";

    private void AppendDotToParam()
    {
        this.param += ".";
    }
}

我们在Index组件中:

  1. 声明了一个私有字段param,然后把这个字段当成组件参数传递给<LifeCycle>组件
  2. 还写了个按钮,每次按一下,this.param的值就会变更,按我们之前学到的知识,按钮的事件处理会导致Index重新被框架渲染,而this.param值的改变会让框架也重新递归渲染<LifeCycle>组件

接下来,我来把这个程序运行起来,效果如下:

life_cycle

如果看图看得比较费劲,那么我再描述一下运行结果:

首先,在初次渲染时,控制台上输出的日志内容是:

Entering SetParametersAsync(ParameterView parameters), before call base.SetParametersAsync(parameters): parameters == Microsoft.AspNetCore.Components.ParameterView, this.httpClient == System.Net.Http.HttpClient, this.param == 
Entering OnInitialized(), before call base.OnInitialized(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Leaving OnInitialized(), after call base.OnInitialized(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Entering OnInitializedAsync(), before call base.OnInitializedAsync(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Leaving OnInitializedAsync(), after call base.OnInitializedAsync(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Entering OnParametersSet(), before call base.OnParametersSet(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Leaving OnParametersSet(), after call base.OnParametersSet(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Entering OnParametersSetAsync(), before call base.OnParametersSetAsync(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Leaving OnParametersSetAsync(), after call base.OnParametersSetAsync(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Leaving SetParametersAsync(ParameterView parameters), after call base.SetParametersAsync(parameters): parameters == Microsoft.AspNetCore.Components.ParameterView, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Entering OnAfterRender(bool firstRender), before call base.OnAfterRender(firstRender): firstRender == True, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Leaving OnAfterRender(bool firstRender), after call base.OnAfterRender(firstRender): firstRender == True, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Entering OnAfterRenderAsync(bool firstRender), before call base.OnAfterRenderAsync(firstRender): firstRender == True, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Leaving OnAfterRenderAsync(bool firstRender), after call base.OnAfterRenderAsync(firstRender): firstRender == True, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index

其次,在点击按钮之后,控制台新增的日志输出内容是:

Entering SetParametersAsync(ParameterView parameters), before call base.SetParametersAsync(parameters): parameters == Microsoft.AspNetCore.Components.ParameterView, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index
Entering OnParametersSet(), before call base.OnParametersSet(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Leaving OnParametersSet(), after call base.OnParametersSet(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Entering OnParametersSetAsync(), before call base.OnParametersSetAsync(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Leaving OnParametersSetAsync(), after call base.OnParametersSetAsync(): this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Leaving SetParametersAsync(ParameterView parameters), after call base.SetParametersAsync(parameters): parameters == Microsoft.AspNetCore.Components.ParameterView, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Entering OnAfterRender(bool firstRender), before call base.OnAfterRender(firstRender): firstRender == False, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Leaving OnAfterRender(bool firstRender), after call base.OnAfterRender(firstRender): firstRender == False, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Entering OnAfterRenderAsync(bool firstRender), before call base.OnAfterRenderAsync(firstRender): firstRender == False, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.
Leaving OnAfterRenderAsync(bool firstRender), after call base.OnAfterRenderAsync(firstRender): firstRender == False, this.httpClient == System.Net.Http.HttpClient, this.param == string param assigned in Index.

目前,我们仅依靠日志,能得出的结论是,这七个生命周期函数的运行逻辑应当如下所示:

|---> ctor
|---> DI injection
|
\---> SetParametersAsync(ParameterView parameters)
|       |
|       |    /-----------------------------\
|       |    | if(firstRender) {           |
|       |    |     OnInitialized();        |
|       |    |     OnInitializedAsync();   |
|       \--> | }                           |
|            | OnParametersSet()           |
|            | OnParametersSetAsync()      |
|            \-----------------------------/
|
\--> OnAfterRender(bool firstRender)
\--> OnAfterRenderAsync(bool firstRender)

用语言描述的话,就如下:

  1. 第一步肯定是构造函数被调用
  2. 第二步应该是依赖注入机制向httpClient字段进行了赋值
  3. 第三步,框架开始调用生命周期函数,调用的第一个生命周期函数就是SetParametersAsync,并且在该函数内部,按顺序调用了更多的函数
    1. 如果组件是初次被渲染,那么在SetParametersAsync内部,会按顺序调用OnInitializedOnInitializedAsync两个生命周期函数。
      • 虽然从命名上看不出来,但从官方文档的介绍中,框架其实承诺了,在OnInitialized{Async}被调用时,所有组件参数都处于可用状态
    2. 依次调用OnParametersSetOnParametersSetAsync
      • 框架承诺在这两个方法被调用时,所有组件参数都处于可用状态
  4. 第四步,在SetParametersAsync返回后(注意这是个异步方法,方法返回不代表Task执行结束,有关Task的细节我们后面再聊),调用OnAfterRender方法
  5. 第五步,在OnAfterRender返回后,调用OnAfterRenderAsync方法

2. 各生命周期函数的设计意图是什么

其实上面那个非常简洁明了的例子已经几乎介绍了85%的知识了,Blazor生命周期函数的名字已经非常有自解释性了,但我们这里还是要结合框架代码,正式介绍一下各个生命周期函数的用途:

2.1 SetParametersAsync

我们现在接触到的七个生命周期函数,除了SetParametersAsync外,另外六个都是两两配对:分别有一个同步版本、一个异步版本。只有SetParametersAsync比较特殊:它只有异步版本。

为什么只有它没有同步版本?为什么其它生命周期都有异步版本?为什么需要给其它生命周期函数分别做同步异步两个版本呢?这三个问题的正确答案,我不清楚,但在文章后面我们可以做一些猜想。

我们先来观察这个生命周期函数的签名,如下:

Task SetParametersAsync(ParameterView parameters);

它接受一个类型为ParameterView的参数,然后在内部做了一些事情,具体是什么事情呢?我们不看代码,而是看一眼官方文档给出的注释:

Definition: 
    Sets parameters supplied by the component's parent in the render tree.
Remarks: 
    Parameters are passed when SetParametersAsync(ParameterView) is called. It is not required that the caller supply a parameter value for all of the parameters that are logically understood by the component.

    The default implementation of SetParametersAsync(ParameterView) will set the value of each property decorated with ParameterAttribute or CascadingParameterAttribute that has a corresponding value in the ParameterView. Parameters that do not have a corresponding value will be unchanged.

也就是说,框架是通过调用SetParametersAsync来完成父组件向子组件传递组件参数这个动作的。框架会将要传递的参数打包成一个ParameterView对象。它的默认实现就是把ParameterView中的内容,一个个的放在对应的、有[Parameter][CascadingParameter]修饰的子组件的属性中去。

另外,框架的默认实现中,并不要求所有子组件的被[{Cascading}Parameter]修饰的属性都有值拿,换句话说,Blazor框架默认上并没有实现一个机制,将“组件参数”标记为“调用方必须赋值”。

我们再看一眼这个方法的默认实现:

    public virtual Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        if (!_initialized)
        {
            _initialized = true;

            return RunInitAndSetParametersAsync();
        }
        else
        {
            return CallOnParametersSetAsync();
        }
    }

我们不需要去仔细一层层的跟源代码看下去去了解细节,dotnet的实现代码中,命名还是比较讲究的,基本99%的命名都能够做到自解释。那么从上面几行代码我们就能看出:显然parameters.SetParameterProperties(this)这句调用,背后的实现,就是去给子组件中的“组件参数”赋值。

好,暂停,了解到这里就足够了,那么下一个问题就来了:什么情况下,我们需要去覆写这个生命周期函数?

简单的答案是:当框架默认的“组件参数传递机制”不满足你的需求的时候。这方面有两个比较常见的需求:

需要实现一个子组件,在父组件调用该子组件时,强制要求部分,或全部“组件参数”都由父组件传值,如果参数有缺失,子组件就扔个异常出来。或者对参数有除了类型之外更严苛的要求之类的,这时,我们就可以如下实现这个子组件:

...

@code {
    public override Task SetParametersAsync(ParameterView parameters)
    {
        if(!CheckParameters(parameters))
        {
            throw new Exception("...")
        }

        base.SetParametersAsync(parameters);
    }
}

另外一个比较常见的需求,就是从其它地方获取参数,或者为非[{Cascading}Parameter]修饰的属性赋值。比如将路由参数扒出来放在某个字段、属性中之类的。就可以如下实现

...
@code {
    public override Task SetParametersAsync(ParameterView parameters)
    {
        ParseDataFromSomewhereElse(this);
        base.SetParametersAsync(parameters);
    }
}

当然,实际开发过程中可能会有一些其它场景,可以使用SetParametersAsync来实现,这个就看具体情况了。不过这里需要强调的一点是:千万不要在覆写实现的时候,忘记对base.SetParametersAsync(parameters)的调用,原因有二:

  1. 第一层原因,就是如果你想改写传参数的行为的话,你可以去检查parameters对象中的内容,或者就地根据传入的parameters,新建一个符合你要求的新ParameterView对象传递给框架的默认实现去。但除非你是万中无一的武学奇才,否则重新实现一遍parameters.SetParameterProperties(this)是非常没有意义的事情
  2. 第二层原因,就在于SetParametersAsync的框架默认实现,除了传递参数之外,还触发了后续两个生命周期函数的调用,如果忘记调用默认实现的话,会导致其它生命周期函数不被框架调用,从而引发一些奇怪的后果。

总结:

  1. SetParametersAsync的设计意图,就是放了个口子让程序员去自定义参数传递的行为
  2. 除非你是绝对的专家,否则在覆写SetParametersAsync的时候,不要忘记调用base.SetParametersAsync(parameters)
  3. SetParametersAsync是七个生命周期函数中第一个被框架调用的(但不是第一个返回的)。其内部除了实现组件参数的传递功能外,还负责触发另外两/四个生命周期函数的调用:OnInitialized{Async}OnParametersSet{Async}
  4. 依赖注入是先于生命周期函数执行的,即即便SetParametersAsync是头一个被调用的生命周期函数,在它内部也是可以安全访问被@inject注入的各种属性的。

2.2 OnInitialized{Async}

OnInitialized{Async}这两个生命周期函数最大的特点就是:它们只会在组件第一次渲染时被框架调用。在Blazor WASM场景下,请务必时刻铭记:WASM项目是运行在浏览器中的!这意味着两个不同的浏览器Tab其实是互相隔离的,即便打开的是同一个页面,两个Tab中也是各自跑了一套完全独立的WASM前端程序。

框架保证,在OnInitialized{Async}调用时,所有组件参数都处于可用状态。这一点其实我们从SetParametersAsync的默认实现上也能看出来。这里我们再回顾一下SetParametersAsync的默认实现:

    public virtual Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        if (!_initialized)
        {
            _initialized = true;

            return RunInitAndSetParametersAsync();
        }
        else
        {
            return CallOnParametersSetAsync();
        }
    }

上面代码片段中的_initialized字段用来判断组件是初次渲染,还是被二次重新渲染。我们追进去看RunInitAndSetParametersAsync()的实现,如下:

    private async Task RunInitAndSetParametersAsync()
    {
        OnInitialized();
        var task = OnInitializedAsync();

        if (task.Status != TaskStatus.RanToCompletion && task.Status != TaskStatus.Canceled)
        {
            // Call state has changed here so that we render after the sync part of OnInitAsync has run
            // and wait for it to finish before we continue. If no async work has been done yet, we want
            // to defer calling StateHasChanged up until the first bit of async code happens or until
            // the end. Additionally, we want to avoid calling StateHasChanged if no
            // async work is to be performed.
            StateHasChanged();

            try
            {
                await task;
            }
            catch // avoiding exception filters for AOT runtime support
            {
                // Ignore exceptions from task cancellations.
                // Awaiting a canceled task may produce either an OperationCanceledException (if produced as a consequence of
                // CancellationToken.ThrowIfCancellationRequested()) or a TaskCanceledException (produced as a consequence of awaiting Task.FromCanceled).
                // It's much easier to check the state of the Task (i.e. Task.IsCanceled) rather than catch two distinct exceptions.
                if (!task.IsCanceled)
                {
                    throw;
                }
            }

            // Don't call StateHasChanged here. CallOnParametersSetAsync should handle that for us.
        }

        await CallOnParametersSetAsync();
    }

而框架本身是没有为OnInitialized{Async}两个方法写实际逻辑的,它们的默认实现都是空函数:

    /// <summary>
    /// Method invoked when the component is ready to start, having received its
    /// initial parameters from its parent in the render tree.
    /// </summary>
    protected virtual void OnInitialized()
    {
    }

    /// <summary>
    /// Method invoked when the component is ready to start, having received its
    /// initial parameters from its parent in the render tree.
    ///
    /// Override this method if you will perform an asynchronous operation and
    /// want the component to refresh when that operation is completed.
    /// </summary>
    /// <returns>A <see cref="Task"/> representing any asynchronous operation.</returns>
    protected virtual Task OnInitializedAsync()
        => Task.CompletedTask;

正是在这里,框架的默认实现调用了OnInitialized{Async}两个方法,而上面的代码其实也非常值得一说,从默认实现写的注释就能看出来:

  1. 从代码流程上来看,SetParametersAsync中,先调用了parameters.SetParameterProperties(this)来完成对组件参数的赋值。然后才去调用RunInitAndSetParametersAsync
    • 这就是为什么框架承诺,在OnInitialized{Async}执行期间,组件参数是可用的
  2. 框架没有为OnInitialized{Async}两个方法写具体实现,而是完全留空了
    • 显然这个设计是留给程序员发挥的,从名字上也能看出来,如果程序员需要完成一些额外的初始化工作,这部分工作应当在组件参数就绪之后,但v-dom还未更新之前完成,就应当把这部分工作放进OnInitialized{Async}
  3. 那么哪些初始化工作应当写进OnInitialized中,哪些应当写进OnInitializedAsync中呢?这就有意思了
    • 首先来看框架在RunInitAndSetParametersAsync中的实现:是先调用了同步版本的OnInitialized,然后紧接着调用了异步版本的OnInitializedAsync。注意在异步版本OnInitializedAsync返回的那个时刻:同步版本OnInitialized中的动作一定是执行完毕的异步版本OnInitializedAsync中的动作只是刚开始执行,并不一定执行结束
    • 如果OnInitializedAsync中确实包含了一些耗时的异步操作的话,那么框架会接下来先调用一次StateHasChanged()来更新v-dom,然后再去await异步操作
    • 从注释上能看出来,在异步操作await成功之后,框架内部还会再调用一次StateHasChanged()来更新v-dom,但不是在RunInitAndSetParametersAsync中显式调用的,而是在后续生命周期函数中调用的

这是什么意思呢?作为程序员,如果组件有一些初始化动作,我们要做的第一件事,就是把那些不怎么耗时的操作,与耗时很难确定的操作分离出来。比如数据格式转换就属于不耗时的操作,而调用外部API、读取IO获得数据就属于耗时很难确定的操作。把前者放在OnInitialized中,把后者放在OnInitializedAsync中。

框架保证了,在OnInitialized执行结束后,v-dom会得到一次更新,其实在这里说“更新”也不准确,因为这是组件初次被渲染,也就是v-dom上有关该组件的枝桠初次被创建,或者说,是完整的v-dom树被初次创建。在OnInitializedAsync被成功await后,v-dom还会得到一次更新。

下面我们来写一个例子来生动的展示一下OnInitialized{Async}的功能与区别。我们先写一个组件Components/InitExample.razor,代码如下。然后直接在Pages/Index.razor中调用它。

<pre>
    @this.content
</pre>
@code {
    private string content = "line 1 : default content";

    protected override void OnInitialized()
    {
        Console.WriteLine($"{DateTime.Now}: Entering OnInitialized");
        Thread.Sleep(5000);
        content +=Environment.NewLine +  "line 2 : appended by OnInitialized";
        Console.WriteLine($"{DateTime.Now}: Leaving OnInitialized");
    }

    protected override async Task OnInitializedAsync()
    {
        Console.WriteLine($"{DateTime.Now}: Entering OnInitializedAsync");
        this.content += Environment.NewLine + "line 3 : appended by OnInitializedAsync when the method returned";
        await Task.Run(() => { 
            Thread.Sleep(5000);
            this.content += Environment.NewLine + "line 4 : appended by OnInitializedAsync when the async task completed";
        });
        Console.WriteLine($"{DateTime.Now}: Leaving OnInitializedAsync");
    }
}

在这个组件中,我们分别复写了两个版本的OnInitialized{Async}

  • 在同步版本中,我们让线程等了5秒,5秒后改写字段content的值。
    • 按我们上面的理解,在OnInitialized执行结束之前,v-dom树上有关该组件的枝桠都未被创建,换句话说就是v-dom树在这个OnInitialized返回前都处于不完整状态
    • 这意味着,浏览器页面上初次能展示出内容的时候,就是"line 1 : xxx""line 2 : xxx"一起展示的。在展示之前,浏览器还需要等v-dom构建,要等5秒,在此期间,页面会一直显示默认的“Loading”
  • 在异步版本中,我们模拟异步获取数据的操作,假定从外部获取数据需要花5秒
    • 按我们上面的理解,OnInitializedAsync函数返回后,但Task未执行完之前,组件在v-dom树中初次创建枝桠的StateHasChanged()会被调用。这意味着"Line 3 : xxx"其实也会出现在首次渲染的页面上,即v-dom初次构建的时候就有"Line 3 : xxx"
    • OnInitializedAsync背后的Task需要执行5秒,5秒后content会被再次更新,而按我们之前的理解,框架在await OnInitializedAsync()后还会调用一次StateHasChanged()来更新v-dom。这意味着页面在5秒后会自动更新。

而实际的运行效果,确实如下所示:

init_example

虽然"Line 1"是构造时就有的值,"Line 2"需要等框架调用OnInitialized,还需要等5秒,"Line 3"需要框架调用OnInitializedAsync,但在页面上,是足足等一5秒后,"line 1/2/3"才一起出现的

  • 这是因为,内存中值的变量,要反映到页面上,首先得要反映到v-dom中去。在OnInitializedOnInitializedAsync返回(注意返回不代表异步Task执行结束)之后,框架才会为这个组件第一次调用StateHasChanged(),即第一次构建它在v-dom中的枝桠
  • 整颗v-dom树是递归构建的,App构建结束了递归构建IndexIndex构建结束了递归构建InitExample。在InitExample卡的那五秒钟里,整个v-dom树都处于未构建完成的状态,这就是为什么页面上长时间保持Loading的原因

理解OnInitialized{Async}的调用时机与v-dom初次构建时机,就是理解OnInitialized{Async}的关键。另外我们再提一嘴剩余几个不太重要的知识点:

  1. v-dom的构建是自上向下递归构建的,自然各层组件的OnInitialized{Async}的调用也是先后调用的。那么显然,对于同步版本的OnInitialized,父组件的调用、执行、返回是一定在子组件前面的。对于异步版本的OnInitializedAsync,父组件的调用、返回,一定是先于子组件的,但具体Task的执行情况,就不一定了。
  2. 特别注意:由于框架默认对于OnInitialized{Async}并没有默认实现,所以在覆写OnInitialized{Async}的时候,不需要特别显式的去调用base.OnInitialized{Async}()。这一点跟SetParametersAsync是不同的。
  3. 在事件处理触发的组件重新渲染过程中,OnInitialized{Async}是不会被第二次执行的。这个我们上面已经提到过了,这也是它起名为Initialized的原因

关于OnInitializedAsync,还有一个偏门知识点,说它重要吧,也没那么重要,说它不重要吧,保不齐你什么时候就会踩坑里去,这里我们也提一嘴,后续在相关章节再仔细介绍:

  • 预渲染特性开启的情况下,OnInitializedAsync可能会被调用两次
    • 一次是服务端预渲染页面的时候,运行在服务端
    • 一次是浏览器正式渲染组件的时候,运行在用户浏览器上

那么什么是预渲染特性呢?我们上面的页面不是加载了5秒,让用户等了5秒的loading吗?预渲染特性就是在服务端预先把页面渲染成HTML文档,在用户首次请求的时候直接把这个渲染结果扔给用户,然后同时在背后暗搓搓的下载WASM项目,然后在浏览器上运行,最终无缝将预渲染页面切换到本地渲染的WASM程序上来。

Blazor有很多有意思的特性,我们后续在介绍完Blazor的主要知识点后,会一一介绍这些特性,现在只需要知道有这么个事就行了。

2.3 OnParametersSet{Async}

从功能上来说,OnParametersSet{Async}OnInitialized{Async}是高度相似的:

  1. 框架均保证在它们调用时,组件参数已经可用
  2. 都分同步异步版本:不耗时的操作写在同步版本中,耗时不确定的操作写在异步版本中
  3. 框架本身对这四个方法的实现均是空的,所以在覆写这四个方法时,不需要刻意的去调用base.On[ParametersSet|Initialized]{Async}()
  4. 父组件的OnParametersSet{Async}也一定先于子组件的返回,对于异步版本,具体Task完成的先后顺序,与OnInitialized{Async}一样,就要看具体情况了。

不同的点在于:

  • OnInitialized{Async}只会在组件初次渲染时被调用,而OnParametersSet{Async}在每次重新渲染时都会被调用

而在调用时机上,我们再从头回看一眼SetParametersAsync的框架默认实现:

    public virtual Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);
        if (!_initialized)
        {
            _initialized = true;

            return RunInitAndSetParametersAsync();
        }
        else
        {
            return CallOnParametersSetAsync();
        }
    }

    private async Task RunInitAndSetParametersAsync()
    {
        OnInitialized();
        var task = OnInitializedAsync();

        if (task.Status != TaskStatus.RanToCompletion && task.Status != TaskStatus.Canceled)
        {
            StateHasChanged();

            try
            {
                await task;
            }
            catch
            {
                if (!task.IsCanceled)
                {
                    throw;
                }
            }
        }

        await CallOnParametersSetAsync();
    }

    private Task CallOnParametersSetAsync()
    {
        OnParametersSet();
        var task = OnParametersSetAsync();
        var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
            task.Status != TaskStatus.Canceled;

        StateHasChanged();

        return shouldAwaitTask ?
            CallStateHasChangedOnAsyncCompletion(task) :
            Task.CompletedTask;
    }

    private async Task CallStateHasChangedOnAsyncCompletion(Task task)
    {
        try
        {
            await task;
        }
        catch
        {
            if (task.IsCanceled)
            {
                return;
            }

            throw;
        }

        StateHasChanged();
    }

我们来再捋一次:

如果是组件初次渲染:

  1. 框架调用SetParametersAsync,并在其内部:
    • 先调用parameters.SetParameterProperties(this)来为组件参数赋值
    • 再调用RunInitAndSetParametersAsync(),在其内部
      • 先调用OnInitialized(),再调用OnInitializedAsync()
      • 随后调用StateHasChanged()初次构建v-dom
      • await OnInitializedAsync()后,调用CallOnParametersSetAsync(),并在其内部
        • 先调用OnParametersSet(),再调用OnParametersSetAsync()
        • 随后调用StateHasChanged()更新v-dom
        • await OnParametersSetAsync()后,再次调用StateHasChanged()更新v-dom
  2. SetParametersAsync返回

如果组件是二次渲染

  1. 框架调用SetParametersAsync,并在其内部:
    • 先调用parameters.SetParameterProperties(this)来为组件参数赋值
    • 再调用CallOnParametersSetAsync(),并在其内部
      • 先调用OnParametersSet(),再调用OnParametersSetAsync()
      • 随后调用StateHasChanged()更新v-dom
      • await OnParametersSetAsync()后,再次调用StateHasChanged()更新v-dom
  2. SetParametersAsync返回

所以说,极端情况下,即四个生命周期函数都覆盖,且组件为初次渲染,那么StateHasChanged()会被调用:

  1. OnInitialized{Async}函数返回,但异步版本的Task还未执行完毕时,会调用StateHasChanged()进行初次v-dom构建
  2. await OnInitializedAsync完成后,接着连续调用OnParametersSet{Async}且返回,但异步版本的OnParametersSetAsync还未执行完毕时,会调用StateHasChanged()进行v-dom更新
  3. await OnParametersSetAsync完成后,会再调用一次StateHasChanged()更新v-dom

在这种情况下,异步版本的await OnInitializedAsync的数据变更,会和同步版本的OnParametersSet的数据变更合并在一起,只执行一次StateHasChanged()来更新v-dom

并且,无论是同步版本还是异步版本的OnParametersSet{Async},其调用时机,都是位于await OnInitializedAsync执行结束之后进行的。这两套生命周期函数之间有严格的时间顺序。

我们可以再写一个测试组件来证明上面的三次StateHasChanged(),新建组件Components/OnParamSetExample.razor,然后在Pages/Index中直接调用它。内容如下:

<pre>
    @this.content
</pre>
@code {
    private string content = "line 1 : default content";

    protected override void OnInitialized()
    {
        content +=Environment.NewLine +  "line 2 : appended by OnInitialized";
    }

    protected override async Task OnInitializedAsync()
    {
        this.content += Environment.NewLine + "line 3 : appended by OnInitializedAsync when the method returned";
        await Task.Run(() => {
            Thread.Sleep(3000);
            this.content += Environment.NewLine + "line 4 : appended by OnInitializedAsync when the async task completed";
        });
    }

    protected override void OnParametersSet()
    {
        this.content += Environment.NewLine + "line 5 : appended by OnParametersSet";
    }

    protected override async Task OnParametersSetAsync()
    {
        this.content += Environment.NewLine + "line 6 : appended by OnParametersSetAsync when the method returned";
        await Task.Run(() => { 
            Thread.Sleep(7000);
            this.content += Environment.NewLine + "line 7 : appended by OnParametersSetAsync when the async task completed";
        });
    }
}

按我们上面的理解,页面首次加载应当显示 :

line 1 : default content
line 2 : appended by OnInitialized
line 3 : appended by OnInitializedAsync when the method returned

3秒后,第二次StateHasChanged()被调用,页面应当更新至如下状态:

 line 1 : default content
 line 2 : appended by OnInitialized
 line 3 : appended by OnInitializedAsync when the method returned
+line 4 : appended by OnInitializedAsync when the async task completed
+line 5 : appended by OnParametersSet
+line 6 : appended by OnParametersSetAsync when the method returned

再经过7秒,第三次StateHasChanged()被调用,页面应当更新至如下状态:

 line 1 : default content
 line 2 : appended by OnInitialized
 line 3 : appended by OnInitializedAsync when the method returned
 line 4 : appended by OnInitializedAsync when the async task completed
 line 5 : appended by OnParametersSet
 line 6 : appended by OnParametersSetAsync when the method returned
+line 7 : appended by OnParametersSetAsync when the async task completed

但,实际程序运行起来,页面只更新了一次,如下所示:

onparam_example

实际运行效果是,页面首次加载之后,经过了10秒,直接更新到最终状态了。看起来像是第二次与第三次StateHasChanged()合并了。这是怎么回事呢?

我们先不回答这个问题,而是把上面的代码修改一下,改成下面这样,页面的行为就会与我们的理解一致了。

<pre>
    @this.content
</pre>
@code {
    private string content = "line 1 : default content";

    protected override void OnInitialized()
    {
        content +=Environment.NewLine +  "line 2 : appended by OnInitialized";
    }

    protected override async Task OnInitializedAsync()
    {
        this.content += Environment.NewLine + "line 3 : appended by OnInitializedAsync when the method returned";
        await Task.Delay(3000);
        this.content += Environment.NewLine + "line 4 : appended by OnInitializedAsync when the async task completed";
    }

    protected override void OnParametersSet()
    {
        this.content += Environment.NewLine + "line 5 : appended by OnParametersSet";
    }

    protected override async Task OnParametersSetAsync()
    {
        this.content += Environment.NewLine + "line 6 : appended by OnParametersSetAsync when the method returned";
        await Task.Delay(7000);
        this.content += Environment.NewLine + "line 7 : appended by OnParametersSetAsync when the async task completed";
    }
}

现在运行结果就如下图所示了:

onparam_example_2

那么为什么看起来差别不大的代码,为什么运行结果会差这么多呢?是我们的理解的问题吗?我首先定个调:我们目前对OnInitialized{Async}, OnParametersSet{Async}, SetParametersAsyncStateHasChanged()的理解是没有问题的。之所以出现上面的差异,是由于dotnet runtime在浏览器环境下,是单线程程序。

所以在继续介绍剩下的两个生命周期函数之前,我们需要开个小章节,来解释一下上面两段代码的差异源自哪里

2.4 Blazor WASM是一个运行在单线程环境下的C#程序

这个小节的标题基本就解释了80%:Blazor WASM运行环境是单线程的。或许WebAssembly在未来、或许现在已经,支持了多线程环境,但就目前而言,至少在dotnet 7.0版本中:

  1. Blazor WASM程序运行的dotnet runtime是一个单线程环境。这意味着使用async/await套路写的所有代码,其实都是运行在那唯一的线程上而已。
  2. 事实上,我们也无法在组件中写出诸如Thread t = new Thread(xxx)之类的代码,运行时会抛出一个异常告诉我们:对不起,该操作不受支持。如下图所示

no_thread_on_browser

我们先来一步一步的分析,我们用Thread.Sleep()写的那个,行为与我们预期不同的代码版本,这里再贴一遍,在关键行后用注释做了标记,以方便讲解:

<pre>
    @this.content
</pre>
@code {
    private string content = "line 1 : default content";

    protected override void OnInitialized()
    {
        content +=Environment.NewLine +  "line 2 : appended by OnInitialized"; // #1
    }

    protected override async Task OnInitializedAsync()
    {
        this.content += Environment.NewLine + "line 3 : appended by OnInitializedAsync when the method returned"; // #2
        await Task.Run(() => { // #3
            Thread.Sleep(3000); // #4
            this.content += Environment.NewLine + "line 4 : appended by OnInitializedAsync when the async task completed"; // #5
        });
    }

    protected override void OnParametersSet()
    {
        this.content += Environment.NewLine + "line 5 : appended by OnParametersSet"; // #6
    }

    protected override async Task OnParametersSetAsync()
    {
        this.content += Environment.NewLine + "line 6 : appended by OnParametersSetAsync when the method returned"; // #7
        await Task.Run(() => { // #8
            Thread.Sleep(7000); // #9
            this.content += Environment.NewLine + "line 7 : appended by OnParametersSetAsync when the async task completed"; // #10
        });
    }
}
  1. 框架运行后,初次渲染肯定是先执行到#1,然后OnInitialized返回,然后执行到#2,此时this.content已经有三行内容了,不过v-dom还没有被初次构建,页面上依然是loading字样
  2. 然后反人类的事情就来了:在#3位置,有这么几件事要按顺序发生:
    • Task.Run(...)的执行要返回一个可await的对象
    • #3中对该对象的await会导致OnInitializedAsync函数返回
    • 该对象内部封装的逻辑,即#4#5要在线程池中被执行

通常情况下,我们不必太过在意这三件事的发生顺序,但现在我们不得不去仔细研究这其中的顺序了:因为在浏览器的dotnet runtime中,是没有多线程这个东西的,也就没有所谓的线程池的,#4#5是会在唯一的那个线程上执行的!

在上面三件事中,还夹杂了一件事:OnInitializedAsync函数的返回,会导致第一次StateHasChanged()被框架执行,v-dom初次构建成功!这件事到底发生在#4, #5之前,还是之后,从理论上是说不清楚的。但从实际运行结果上来看,显然是发生在#4, #5之前。

如果这个浏览器上的dotnet runtime支持多线程环境,那么理论上StateHasChanged()触发的v-dom构建及页面渲染,和#4, #5的执行,应该是同时开始,同时进行的。

所以实际发生的事情顺序是:

  1. #3处,OnInitializedAsync函数返回,框架执行StateHasChanged(),然后页面被成功渲染。页面首次渲染出结果,有三行内容。
  2. 然后线程控制权被#4, #5函数体抢占,这个时候Thread.Sleep(3000)僵硬的地方就来了: 睡,是真的把这个线程堵死了3秒钟!就唯一的那个线程,啥也不干了,整个页面都卡住了,就搁那硬睡,睡三秒!
  3. 我们上面说过,OnInitializedAsync被await成功后,框架并不会立即给调用一次StateHasChanged(),而是把这个渲染合并在OnParametersSet{Async}调用之后了
  4. 然后和我们之前解释的顺序一样,#6 #7依次执行,此时this.content已经有六行内容了,但页面上依然是三行内容
  5. 接下来#8 #9 #10的运行逻辑与#3 #4 #5基本类似,但这一次不同的是:在OnParametersSetAsync返回后,#9 #10抢占了先机,抢在StateHasChanged()执行页面渲染逻辑之前,把线程控制权抢了过去
  6. 然后就是Thread.Sleep(7000)的僵硬时刻:整个UI线程结结实实的僵了七秒钟,直到#10执行结束,task执行成功。
  7. 直到此时,线程控制权才回到StateHasChanged()手中,页面的第二次渲染才正式完成。注意第二次渲染指的是代码中OnParametersSetAsync函数返回之后的那一次渲染
  8. 虽说页面应当在await OnParametersSetAsync后还有一次渲染,但鉴于在这个情况下,整个task已经完成,实际上框架也不会再调用第三次StateHasChanged()

这就是为什么从代码上看,页面应当被渲染三次,但实际运行效果却只渲染了两次的原因。

现在,聪明如你,一定会问一个问题:为什么类似的困境,#4 #5没有优先抢到线程使用权,而#9 #10却抢到了呢?

答案是:你不需要知道答案。

如果你有兴趣钻研框架的实现细节,你一定可以继续深挖相关源代码,找到准确的答案,但就一个框架使用者的角度而言,这个问题是没有意义的,作为框架使用者,只需要明确以下几点:

  1. Blazor WASM在浏览器上的运行模型是单线程的
  2. 不要使用同步阻塞调用:因为同步阻塞调用在阻塞期间一定会卡死线程,导致整个前端进程无响应

这就足够了。我知道爱好知识的你一定对这个答案不满意,但人生就是这样,要把有限的精力分配给更重要的事情,学习框架的时候,既要保持对细节的好奇心,也要避免陷入到一些无关的细节中去。如果你去查看微软的官方文档,官方文档甚至不会告诉你OnInitialized{Async}OnParametersSet{Async}是在SetParametersAsync中被调用的,官方文档只会告诉你:什么样的动作,适宜于放进什么样的生命周期函数中,以及各个生命周期函数被调用的先后顺序。至于这个生命周期函数是由谁调用的,怎么调用的,你不需要去关心。

而我们这里介绍SetParametersAsync的一丁点实现细节的原因,只是为了让大家更好的理解,为什么在覆写SetParametersAsync的时候需要调用base.SetParametersAsync而已。

2.5 OnAfterRender{Async}

我们再来回头看这张ASCII图:

|---> ctor
|---> DI injection
|
\---> SetParametersAsync(ParameterView parameters)
|       |
|       |    /-----------------------------\
|       |    | if(firstRender) {           |
|       |    |     OnInitialized();        |
|       |    |     OnInitializedAsync();   |
|       \--> | }                           |
|            | OnParametersSet()           |
|            | OnParametersSetAsync()      |
|            \-----------------------------/
|
\--> OnAfterRender(bool firstRender)
\--> OnAfterRenderAsync(bool firstRender)

其实这张图画的逻辑,是错误的。上面这张图给人的感觉,是框架有一只无形的手,在调用了SetParametersAsync后,又调用了OnAfterRender{Async}。但实际情况并不是这样。真实情况是:OnAfterRender{Async}的调用属于StateHasChanged()的一个后果,换句话说,你可以简单的理解为:页面每有一次渲染,OnAfterRender就会被调用一次。

所以更准确一点,上面的ASCII图应该被更新成下面这样

|---> ctor
|---> DI injection
|
\---> SetParametersAsync(ParameterView parameters)
        |
        |    /--------------------------------\
        |    | if(firstRender) {              |
        |    |     OnInitialized();------\    |
        |    |                           |    |
        |    |     OnInitializedAsync();-+----+----> StateHasChanged() ----> OnAfterRender{Async}(true)
        |    |       |                        |
        \--> | }     \---await---------\      |
             |                         |      |
             | OnParametersSet()-------+      |
             |                         |      |   
             | OnParametersSetAsync()--+------+---> StateHasChanged() ----> OnAfterRender{Async}(false)  
             |       |                        |   
             |       \---await----------------+---> StateHasChanged() ----> OnAfterRender{Async}(false)
             |                                |   
             \--------------------------------/   

此时这张图才算是勉强正确,之所以叫它勉强正确,是因为上面这张图正确的前提是OnInitializedAsyncOnParametersSetAsync内部均有异步调用,而不是直接返回一个Task.Completed

OnInitializedAsync的实现为空时,即没必要await它时,首次StateHasChanged()就不用被调用。会被推迟到OnParametersSet()后触发的那个StateHasChanged()

OnParametersSetAsync的实现为空时,即没必要await它时,最后一次StateHasChanged()就不会被调用。

总之,上图中一定会被调用的,其实是图中第二次StateHasChanged()。剩余两个StateHasChanged()是否被调用,要取决于两个对应的async task有没有必要await

现在回头来看OnAfterRender{Async}:其实OnAfterRender{Async}是渲染流程中的一环,我们可以简单的理解为,只要调用了StateHasChanged(),就会有一次OnAfterRender{Async}被调用。

所以,对于一个组件,极端情况下,页面初次加载时,会触发三次OnAfterRender{Async}的调用。通常情况下,至少有一次OnAfterRender{Async}会被调用

下面我们就分别写两个例子:

首页加载会触发三次OnAfterRender{Async}的极端案例

新建文件Components/TripleRender.razor,代码如下:


<p>afterRenderCalledCount == @this.afterRenderCalledCount</p>
<p>afterRenderAsyncCalledCount == @this.afterRenderAsyncCalledCount</p>

@code {

    private int afterRenderCalledCount = 0;
    private int afterRenderAsyncCalledCount = 0;

    protected override async Task OnInitializedAsync()
    {
        await Task.Delay(1000);
    }

    protected override async Task OnParametersSetAsync()
    {
        await Task.Delay(1000);
    }

    protected override void OnAfterRender(bool firstRender)
    {
        Console.WriteLine($"OnAfterRender({firstRender}): called count == {this.afterRenderCalledCount}");
        this.afterRenderCalledCount++;
    }

    protected override Task OnAfterRenderAsync(bool firstRender)
    {
        Console.WriteLine($"OnAfterRenderAsync({firstRender}): called count == {this.afterRenderAsyncCalledCount}");
        this.afterRenderAsyncCalledCount++;
        return Task.CompletedTask;
    }
}

Index.razor中直接调用的话,运行效果如下所示:

triple_render

而如果我们把上例中的OnInitializedAsyncOnParametersSetAsync移除掉,或者写成非async方法的形式,你就会看到如我们所说的,OnAfterRender{Async}在首屏加载的时候只会被调用一次:

single_render

如果你理解了这一点,体会到OnAfterRender{Async}调用与否其实是与StateHasChanged挂钩的,就自然会意识到一件事:OnAfterRender{Async}后续肯定不用再触发StateHasChanged():即在OnAfterRender{Async}中执行的数据变更,一定反映不到页面上去。

那这个生命周期回调有什么卵用呢?

这里就需要提到这个生命周期函数最重要的一个特点了:它是在页面渲染完成后再调用的。

  • 渲染完成意味着页面上已经有了可视的DOM元素,所以直接操作DOM元素的操作就适合,也只能放在这两个生命周期函数中。
  • 在纯Blazor开发中,我们甚少直接操作DOM元素这种。但世界是复杂的,后续章节我们会看到,Blazor框架还给我们提供了一种能力,能把Blazor框架与已经流行的前端JS库融合在一起。很多JS库的功能就是直接操纵DOM完成进一步的操作,这里典型的就是各种图表库:它们会把页面上已经存在的一个<div>或者其它元素,替换成可视化的图表。
  • 在实际开发过程中,几乎所有与JS有交互的胶水代码,都是写在这个生命周期函数中的

另外需要注意的是:在服务端预渲染特性中,这两个生命周期函数是不会被执行的:这个从框架设计的角度来讲,预渲染的意思就是在服务端预先渲染好可视的DOM树,自然不会去执行渲染完成后的生命周期函数。

但在浏览器加载预渲染内容之后,这两个生命周期函数会在浏览器中被触发。具体的更多细节我们会在后续介绍服务端预渲染特性的时候再仔细介绍。

3. 有关生命周期函数的一些额外注意点与知识点

现在我们基本已经介绍完了Blazor中的生命周期函数,现在唯一的遗憾,是在服务端预渲染中,部分生命周期函数有独特的行为,我们这里不太好介绍,这不重要,后续我们在专门介绍服务端预渲染的时候再专门说。

这里只是再补充一些在实践中需要注意的点,与一些零碎的知识点。

3.1 对于异步生命周期函数,一定要做好Task未完成的处理与Task失败处理

异步生命周期函数,特别是OnInitializedAsyncOnParametersSetAsync,实践中经常使用这二者来调用API获取数据。典型的写法如下所示:

@code {
    private Data? data;

    protected override async Task OnInitializedAsync()
    {
        this.data = await GetData();
    }
}

在上例中,我们一定要为this.data == null单独写条件渲染:因为无论是在Task未完成之前,还是Task失败之后,this.data的值都将为null,在页面上直接渲染this.data势必会产生一些类似于空指针的异常。所以正确的做法应当如下:

@if (this.data == null)
{
    <p><em>Loading data...</p></em>
}
else
{
    <DisplayData Data=@this.data />
}

3.2 除了生命周期函数外,有必要的情况下,还需要实现I{Async}Disposable接口

和写任何其它C#代码一样:如果组件内部持有着一些资源句柄,那么你就应该去实现I{Async}Disposable接口,在同步版本与异步版本之间挑一个实现就好。这没什么可说的。

但值得提一下的是:如果组件内部持有了EventHandler<>,并且在组件初始化的时候把这个EventHandler<>捅在了其它的一些地方,记得要在Dispose()的时候脱钩,下面就是一个例子:

作为一个文本编辑组件,它自身声明了一个editContext来存储编辑器上下文,为了监听编辑内容的变更,自己还实现了一个回调函数挂在这个编辑器上下文上。

并且把这个编辑器上下文传递给了子组件:意图是子组件去改写编辑器上下文中的文本内容,这样作为父组件,它挂上的回调函数就可以感知到文本内容的变化从而执行一些行为。

这里有个潜在的风险,就是组件本身被框架析构的时候,它内部的editContext可不一定被析构哦!在这种情况下,如果editContext指向的对象还在激发FieldChanged事件的话,而如果好死不死的this.fieldChanged这个回调函数中使用了组件本身的this指针的话,就有可能出现异常。所以正确的作法是在析构的时候把自行注册的各种EventHandler都解绑掉。

@code {
    // ...
    public EditContent editContent;

    private EventHandler<FieldChangedEventArgs>? fieldChanged;

    protected override void OnInitialized()
    {
        this.editContent = new(model);
        this.fieldChanged = (_, _) =>
        {
            ...
        };

        this.editContext.OnFieldChanged += this.fieldChanged;
    }

    public void Dispose()
    {
        editContent.OnFieldChanged -= this.fieldChanged;
    }
}

3.3 有必要的话把对外的API调用语句设置为可Cancel的

通常情况下,对外的API调用或快或慢,基本在秒级都能获得一个结果:无论是成功调用,还是得到一个失败的Http response。失败并不可怕,可怕的是这个Http请求一直卡在那里。

C#为async await编程范式提供了一个可以取消Task的机制:即CancellationTokenSourceCancellationToken

具体要不要用,这个就看你个人的权衡判断了,下面贴一个例子,在这个例子中,页面上有两个按钮

  • 点击按钮“Trigger long running work”,其回调函数会发起一个持续5秒的动作。当然在例子代码中我们是以Task.Delay来模拟长请求的
    • 在回调函数中,对于长请求,使用了一个CancellationToken来把它设定为“可取消的”
    • 在回调函数中,创建了一个(模拟的)资源。这个资源类本身也实现了IDisposable接口。
  • 点击按钮“Trigger Disposal”按钮,会做两件事
    • 通过cts.Cancel()来取消还未执行完毕的长请求
    • 通过直接调用资源对象的Dispose()方法来释放持有的资源
@page "/background-work"
@using System.Threading
@using Microsoft.Extensions.Logging
@implements IDisposable
@inject ILogger<BackgroundWork> Logger

<button @onclick="LongRunningWork">Trigger long running work</button>
<button @onclick="Dispose">Trigger Disposal</button>

@code {
    private Resource resource = new();
    private CancellationTokenSource cts = new();

    protected async Task LongRunningWork()
    {
        Logger.LogInformation("Long running work started");

        await Task.Delay(5000, cts.Token);

        cts.Token.ThrowIfCancellationRequested();
        resource.BackgroundResourceMethod(Logger);
    }

    public void Dispose()
    {
        Logger.LogInformation("Executing Dispose");
        cts.Cancel();
        cts.Dispose();
        resource?.Dispose();
    }

    private class Resource : IDisposable
    {
        private bool disposed;

        public void BackgroundResourceMethod(ILogger<BackgroundWork> logger)
        {
            logger.LogInformation("BackgroundResourceMethod: Start method");

            if (disposed)
            {
                logger.LogInformation("BackgroundResourceMethod: Disposed");
                throw new ObjectDisposedException(nameof(Resource));
            }

            // Take action on the Resource

            logger.LogInformation("BackgroundResourceMethod: Action on Resource");
        }

        public void Dispose()
        {
            disposed = true;
        }
    }
}

多数情况下,对于简单页面,简单的API调用,是没有必要上这么一套裹脚布的。总之还是那句话:用来用全看你自己的判断。