什么是组件?在前面几篇文章的描述里,组件是*.razor
文件,是转译后的C#类。在这篇文章中,我们要把视角,从“类”,转向“这个类的实例是怎么被构造出来的”,以及“这个类的实例什么时候被析构”。
“组件生命周期”这个概念并不是Blazor创造的,这个概念在React等前端框架中也存在。简单来说,就是框架在实例化组件以及析构组件实例的过程中,调用的不仅仅只有构造函数与析构函数(这里的析构说的并不是真正的析构,而是类似于对IDisposible
接口的实现),还调用了其它一些函数。而框架把这些额外的函数就称为“生命周期函数”。作为框架的使用者,程序员如若想在组件实例化与析构的过程中添加一些别的动作,可以通过覆写相应的函数来实现。
我们其实并不会了解,也没必要了解框架在具体实现过程中是如何从细节实例化一个组件的,作为框架的使用者,我们只需要了解:
所以本篇文章介绍的有关组件实例化的过程模型,都是经过简化后的模型,并且随着dotnet版本的更迭,其内部实现可能会发生变更。
说实话,生命周期不是很好讲,我这篇稿子开头的500字已经重新写了四篇了,最终我决定还是放弃先讲概念再讲代码的方式,而是直接上一个例子,然后以走读代码的形式来直观的给大家讲明白。
这个组件唯一的作用就是用来讲解生命周期函数,位于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;
}
}
这个组件非常简单
HttpClient
实例,但实际上并没有用到这个实例。我们主要关心的是DI注入发生在什么时候@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
组件中:
param
,然后把这个字段当成组件参数传递给<LifeCycle>
组件this.param
的值就会变更,按我们之前学到的知识,按钮的事件处理会导致Index
重新被框架渲染,而this.param
值的改变会让框架也重新递归渲染<LifeCycle>
组件接下来,我来把这个程序运行起来,效果如下:
如果看图看得比较费劲,那么我再描述一下运行结果:
首先,在初次渲染时,控制台上输出的日志内容是:
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)
用语言描述的话,就如下:
httpClient
字段进行了赋值SetParametersAsync
,并且在该函数内部,按顺序调用了更多的函数
SetParametersAsync
内部,会按顺序调用OnInitialized
与OnInitializedAsync
两个生命周期函数。
OnInitialized{Async}
被调用时,所有组件参数都处于可用状态OnParametersSet
与OnParametersSetAsync
SetParametersAsync
返回后(注意这是个异步方法,方法返回不代表Task执行结束,有关Task的细节我们后面再聊),调用OnAfterRender
方法OnAfterRender
返回后,调用OnAfterRenderAsync
方法其实上面那个非常简洁明了的例子已经几乎介绍了85%的知识了,Blazor生命周期函数的名字已经非常有自解释性了,但我们这里还是要结合框架代码,正式介绍一下各个生命周期函数的用途:
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)
的调用,原因有二:
parameters
对象中的内容,或者就地根据传入的parameters
,新建一个符合你要求的新ParameterView
对象传递给框架的默认实现去。但除非你是万中无一的武学奇才,否则重新实现一遍parameters.SetParameterProperties(this)
是非常没有意义的事情SetParametersAsync
的框架默认实现,除了传递参数之外,还触发了后续两个生命周期函数的调用,如果忘记调用默认实现的话,会导致其它生命周期函数不被框架调用,从而引发一些奇怪的后果。总结:
SetParametersAsync
的设计意图,就是放了个口子让程序员去自定义参数传递的行为SetParametersAsync
的时候,不要忘记调用base.SetParametersAsync(parameters)
SetParametersAsync
是七个生命周期函数中第一个被框架调用的(但不是第一个返回的)。其内部除了实现组件参数的传递功能外,还负责触发另外两/四个生命周期函数的调用:OnInitialized{Async}
与OnParametersSet{Async}
SetParametersAsync
是头一个被调用的生命周期函数,在它内部也是可以安全访问被@inject
注入的各种属性的。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}
两个方法,而上面的代码其实也非常值得一说,从默认实现写的注释就能看出来:
SetParametersAsync
中,先调用了parameters.SetParameterProperties(this)
来完成对组件参数的赋值。然后才去调用RunInitAndSetParametersAsync
。
OnInitialized{Async}
执行期间,组件参数是可用的OnInitialized{Async}
两个方法写具体实现,而是完全留空了
OnInitialized{Async}
中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}
content
的值。
OnInitialized
执行结束之前,v-dom树上有关该组件的枝桠都未被创建,换句话说就是v-dom树在这个OnInitialized
返回前都处于不完整状态"line 1 : xxx"
与"line 2 : xxx"
一起展示的。在展示之前,浏览器还需要等v-dom构建,要等5秒,在此期间,页面会一直显示默认的“Loading”OnInitializedAsync
函数返回后,但Task未执行完之前,组件在v-dom树中初次创建枝桠的StateHasChanged()
会被调用。这意味着"Line 3 : xxx"
其实也会出现在首次渲染的页面上,即v-dom初次构建的时候就有"Line 3 : xxx"
OnInitializedAsync
背后的Task需要执行5秒,5秒后content
会被再次更新,而按我们之前的理解,框架在await OnInitializedAsync()
后还会调用一次StateHasChanged()
来更新v-dom。这意味着页面在5秒后会自动更新。而实际的运行效果,确实如下所示:
虽然"Line 1"
是构造时就有的值,"Line 2"
需要等框架调用OnInitialized
,还需要等5秒,"Line 3"
需要框架调用OnInitializedAsync
,但在页面上,是足足等一5秒后,"line 1/2/3"
才一起出现的
OnInitialized
与OnInitializedAsync
返回(注意返回不代表异步Task执行结束)之后,框架才会为这个组件第一次调用StateHasChanged()
,即第一次构建它在v-dom中的枝桠App
构建结束了递归构建Index
,Index
构建结束了递归构建InitExample
。在InitExample
卡的那五秒钟里,整个v-dom树都处于未构建完成的状态,这就是为什么页面上长时间保持Loading
的原因理解OnInitialized{Async}
的调用时机与v-dom初次构建时机,就是理解OnInitialized{Async}
的关键。另外我们再提一嘴剩余几个不太重要的知识点:
OnInitialized{Async}
的调用也是先后调用的。那么显然,对于同步版本的OnInitialized
,父组件的调用、执行、返回是一定在子组件前面的。对于异步版本的OnInitializedAsync
,父组件的调用、返回,一定是先于子组件的,但具体Task的执行情况,就不一定了。OnInitialized{Async}
并没有默认实现,所以在覆写OnInitialized{Async}
的时候,不需要特别显式的去调用base.OnInitialized{Async}()
。这一点跟SetParametersAsync
是不同的。OnInitialized{Async}
是不会被第二次执行的。这个我们上面已经提到过了,这也是它起名为Initialized
的原因关于OnInitializedAsync
,还有一个偏门知识点,说它重要吧,也没那么重要,说它不重要吧,保不齐你什么时候就会踩坑里去,这里我们也提一嘴,后续在相关章节再仔细介绍:
OnInitializedAsync
可能会被调用两次
那么什么是预渲染特性呢?我们上面的页面不是加载了5秒,让用户等了5秒的loading
吗?预渲染特性就是在服务端预先把页面渲染成HTML文档,在用户首次请求的时候直接把这个渲染结果扔给用户,然后同时在背后暗搓搓的下载WASM项目,然后在浏览器上运行,最终无缝将预渲染页面切换到本地渲染的WASM程序上来。
Blazor有很多有意思的特性,我们后续在介绍完Blazor的主要知识点后,会一一介绍这些特性,现在只需要知道有这么个事就行了。
OnParametersSet{Async}
从功能上来说,OnParametersSet{Async}
与OnInitialized{Async}
是高度相似的:
base.On[ParametersSet|Initialized]{Async}()
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();
}
我们来再捋一次:
如果是组件初次渲染:
SetParametersAsync
,并在其内部:
parameters.SetParameterProperties(this)
来为组件参数赋值RunInitAndSetParametersAsync()
,在其内部
OnInitialized()
,再调用OnInitializedAsync()
。StateHasChanged()
初次构建v-domawait OnInitializedAsync()
后,调用CallOnParametersSetAsync()
,并在其内部
OnParametersSet()
,再调用OnParametersSetAsync()
StateHasChanged()
更新v-domawait OnParametersSetAsync()
后,再次调用StateHasChanged()
更新v-domSetParametersAsync
返回如果组件是二次渲染
SetParametersAsync
,并在其内部:
parameters.SetParameterProperties(this)
来为组件参数赋值CallOnParametersSetAsync()
,并在其内部
OnParametersSet()
,再调用OnParametersSetAsync()
StateHasChanged()
更新v-domawait OnParametersSetAsync()
后,再次调用StateHasChanged()
更新v-domSetParametersAsync
返回所以说,极端情况下,即四个生命周期函数都覆盖,且组件为初次渲染,那么StateHasChanged()
会被调用:
OnInitialized{Async}
函数返回,但异步版本的Task还未执行完毕时,会调用StateHasChanged()
进行初次v-dom构建await OnInitializedAsync
完成后,接着连续调用OnParametersSet{Async}
且返回,但异步版本的OnParametersSetAsync
还未执行完毕时,会调用StateHasChanged()
进行v-dom更新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
但,实际程序运行起来,页面只更新了一次,如下所示:
实际运行效果是,页面首次加载之后,经过了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";
}
}
现在运行结果就如下图所示了:
那么为什么看起来差别不大的代码,为什么运行结果会差这么多呢?是我们的理解的问题吗?我首先定个调:我们目前对OnInitialized{Async}
, OnParametersSet{Async}
, SetParametersAsync
与StateHasChanged()
的理解是没有问题的。之所以出现上面的差异,是由于dotnet runtime在浏览器环境下,是单线程程序。
所以在继续介绍剩下的两个生命周期函数之前,我们需要开个小章节,来解释一下上面两段代码的差异源自哪里
这个小节的标题基本就解释了80%:Blazor WASM运行环境是单线程的。或许WebAssembly在未来、或许现在已经,支持了多线程环境,但就目前而言,至少在dotnet 7.0版本中:
async/await
套路写的所有代码,其实都是运行在那唯一的线程上而已。Thread t = new Thread(xxx)
之类的代码,运行时会抛出一个异常告诉我们:对不起,该操作不受支持。如下图所示我们先来一步一步的分析,我们用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
,然后OnInitialized
返回,然后执行到#2
,此时this.content
已经有三行内容了,不过v-dom还没有被初次构建,页面上依然是loading
字样#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
的执行,应该是同时开始,同时进行的。
所以实际发生的事情顺序是:
#3
处,OnInitializedAsync
函数返回,框架执行StateHasChanged()
,然后页面被成功渲染。页面首次渲染出结果,有三行内容。#4, #5
函数体抢占,这个时候Thread.Sleep(3000)
僵硬的地方就来了: 睡,是真的把这个线程堵死了3秒钟!就唯一的那个线程,啥也不干了,整个页面都卡住了,就搁那硬睡,睡三秒!OnInitializedAsync
被await成功后,框架并不会立即给调用一次StateHasChanged()
,而是把这个渲染合并在OnParametersSet{Async}
调用之后了#6 #7
依次执行,此时this.content
已经有六行内容了,但页面上依然是三行内容#8 #9 #10
的运行逻辑与#3 #4 #5
基本类似,但这一次不同的是:在OnParametersSetAsync
返回后,#9 #10
抢占了先机,抢在StateHasChanged()
执行页面渲染逻辑之前,把线程控制权抢了过去Thread.Sleep(7000)
的僵硬时刻:整个UI线程结结实实的僵了七秒钟,直到#10
执行结束,task执行成功。StateHasChanged()
手中,页面的第二次渲染才正式完成。注意第二次渲染指的是代码中OnParametersSetAsync
函数返回之后的那一次渲染await OnParametersSetAsync
后还有一次渲染,但鉴于在这个情况下,整个task已经完成,实际上框架也不会再调用第三次StateHasChanged()
这就是为什么从代码上看,页面应当被渲染三次,但实际运行效果却只渲染了两次的原因。
现在,聪明如你,一定会问一个问题:为什么类似的困境,#4 #5
没有优先抢到线程使用权,而#9 #10
却抢到了呢?
答案是:你不需要知道答案。
如果你有兴趣钻研框架的实现细节,你一定可以继续深挖相关源代码,找到准确的答案,但就一个框架使用者的角度而言,这个问题是没有意义的,作为框架使用者,只需要明确以下几点:
这就足够了。我知道爱好知识的你一定对这个答案不满意,但人生就是这样,要把有限的精力分配给更重要的事情,学习框架的时候,既要保持对细节的好奇心,也要避免陷入到一些无关的细节中去。如果你去查看微软的官方文档,官方文档甚至不会告诉你OnInitialized{Async}
与OnParametersSet{Async}
是在SetParametersAsync
中被调用的,官方文档只会告诉你:什么样的动作,适宜于放进什么样的生命周期函数中,以及各个生命周期函数被调用的先后顺序。至于这个生命周期函数是由谁调用的,怎么调用的,你不需要去关心。
而我们这里介绍SetParametersAsync
的一丁点实现细节的原因,只是为了让大家更好的理解,为什么在覆写SetParametersAsync
的时候需要调用base.SetParametersAsync
而已。
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)
| |
\--------------------------------/
此时这张图才算是勉强正确,之所以叫它勉强正确,是因为上面这张图正确的前提是OnInitializedAsync
与OnParametersSetAsync
内部均有异步调用,而不是直接返回一个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
中直接调用的话,运行效果如下所示:
而如果我们把上例中的OnInitializedAsync
和OnParametersSetAsync
移除掉,或者写成非async
方法的形式,你就会看到如我们所说的,OnAfterRender{Async}
在首屏加载的时候只会被调用一次:
如果你理解了这一点,体会到OnAfterRender{Async}
调用与否其实是与StateHasChanged
挂钩的,就自然会意识到一件事:OnAfterRender{Async}
后续肯定不用再触发StateHasChanged()
:即在OnAfterRender{Async}
中执行的数据变更,一定反映不到页面上去。
那这个生命周期回调有什么卵用呢?
这里就需要提到这个生命周期函数最重要的一个特点了:它是在页面渲染完成后再调用的。
<div>
或者其它元素,替换成可视化的图表。另外需要注意的是:在服务端预渲染特性中,这两个生命周期函数是不会被执行的:这个从框架设计的角度来讲,预渲染的意思就是在服务端预先渲染好可视的DOM树,自然不会去执行渲染完成后的生命周期函数。
但在浏览器加载预渲染内容之后,这两个生命周期函数会在浏览器中被触发。具体的更多细节我们会在后续介绍服务端预渲染特性的时候再仔细介绍。
现在我们基本已经介绍完了Blazor中的生命周期函数,现在唯一的遗憾,是在服务端预渲染中,部分生命周期函数有独特的行为,我们这里不太好介绍,这不重要,后续我们在专门介绍服务端预渲染的时候再专门说。
这里只是再补充一些在实践中需要注意的点,与一些零碎的知识点。
异步生命周期函数,特别是OnInitializedAsync
与OnParametersSetAsync
,实践中经常使用这二者来调用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 />
}
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;
}
}
通常情况下,对外的API调用或快或慢,基本在秒级都能获得一个结果:无论是成功调用,还是得到一个失败的Http response。失败并不可怕,可怕的是这个Http请求一直卡在那里。
C#为async await编程范式提供了一个可以取消Task的机制:即CancellationTokenSource和CancellationToken
具体要不要用,这个就看你个人的权衡判断了,下面贴一个例子,在这个例子中,页面上有两个按钮
Task.Delay
来模拟长请求的
CancellationToken
来把它设定为“可取消的”IDisposable
接口。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调用,是没有必要上这么一套裹脚布的。总之还是那句话:用来用全看你自己的判断。