ぷろじぇくと、みすじら。

Blazor のコンポーネントのステートについて

Created at:

これは Blazor Advent Calendar 2020 の25日目のエントリーです。

Blazor はお手軽に Single Page Application を作れるのですがコンポーネントのライフサイクル、ステート関連について知らないと若干不思議な仕組みで動いているように見えます。

例えばプロジェクトテンプレートの Counter は @onclick="IncrementCount" でプライベート変数 currentCount をインクリメントするとページのカウントがアップしますが、これは初見ではなかなか不思議な挙動です。Blazor は currentCount の変更をどうやって知ったのか?としばらく不思議に思っていました。そういったこともライフサイクルを知ることで理解できます。

基本的には ASP.NET Core Blazor ライフサイクル というドキュメントに書いてあるのですがこれはそれを補完する目的のエントリーです。

基本的な流れ

Blazor コンポーネントはコンポーネントのステート(状態)が変更されるたびに再レンダリングを行うという流れが基本となります。これ自体は他のフレームワークと大きく変わるところではないと思います。

これらの多くのライフサイクルに関連するものは Microsoft.AspNetCore.Components.ComponentBase クラス に実装されています。

初期化/初回レンダリング

コンポーネントの初回のレンダリング、つまり初めてページが表示されたときの処理の流れは次のようになっています。

初めに SetParametersAsync が呼び出され、初期化とパラメータのセットを行います。この OnInitializedAsync (初期化), OnParameterSetAsync (パラメータセット) では非同期処理を行うことができますが、その場合は Task の完了を待たずにレンダリングが行われて完了次第再レンダリングが行われます。

つまり非同期初期化の場合、コンポーネントの HTML をレンダリングが行われるタイミングではまだ値がそろっていない場合がある点には注意が必要です。

private NanikaObject _nanika;

protected override async Task OnInitializedAsync()
{
    await Task.Delay(1000);
    _nanika = new NanikaObject();
    await base.OnInitializedAsync();
}
...

@* OnInitializedAsync が終わるまで _nanika は null *@
<h1>@_nanika.Description</h1>

BuildRenderTree メソッドは Blazor 上のドキュメントツリー構造(仮想DOM的なもの)を構築するものです。通常は Razor Component (.razor) ファイルから自動生成され、手でツリーを作るようなカスタムコンポーネントを作らない限りは触ることはありません。中身が気になる場合には obj ディレクトリの中を覗いてみると生成物を確認できます。

なお Server-side Blazor に関してはレンダリングモードが ServerPrerendered の場合、 SetParametersAsync + BuildRenderTree が2回呼び出されます。1回目はプリレンダリングのための実行です。またプリレンダリングはサーバーサイドで完成系を返す都合、SetParametersAsync の完了を待ってからのレンダリングとなります。

OnInitialized と OnParametersSet の違い

OnInitialized{Async} と OnParametersSet{Async} の違いは、OnInitialized がコンポーネントの初期化であるのに対して、OnParametersSet はプロパティの変更です。

つまり [Parameter] として受けている値が変わったときに呼び出されるもので React で言うなら props の変更のような感じでしょうか。

例えば次のようなコンポーネントを用意して…

<p>@Count</p>

@code {
    [Parameter]
    public int Count { get; set; }

    protected override Task OnParametersSetAsync()
    {
        Console.WriteLine($"Count={Count}");
        return base.OnParametersSetAsync();
    }
}

Counter.razor にぶら下げてみます。

<Nantoka Count="currentCount" />

これでカウンターを更新すると OnParametersSetAsync が呼び出されることを確認できます。

ステート(状態)と再レンダリング

そもそも Blazor におけるステートとは何かステートという特別な入れ物があるわけでもなく、あるのは何らかの何らかステートが変更されたということを通知する仕組みです。

データそのものはプライベート変数であったり、Parameter であったりするかもしれませんが Blazor としては「何か状態が変わったことを知る」ということが重要になります。

Blazor はステートの変更通知を受けると、コンポーネントの再レンダリングを行い、表示を更新します。

ステート変更通知は何によって引き起こされるのか

ステートの変更通知はどのようなタイミングで発生するのかですが、おおまかに下記の3パターンで発生します。

3パターンと書きましたが2つ目と3つ目は暗黙的に StateHasChanged を呼び出しているというのが実際のところです。

StateHasChanged を呼び出したとき

StateHasChanged はステートが変更されたことを通知するメソッドです。このメソッドを呼び出すと再レンダリング候補としてマークされます。何が起こるのかは後ほど少し説明します。

多くのケースでは StateHasChanged を明示的に呼び出す必要はありませんが、ロジック側から表示を更新したいケースでは呼び出しを必要とします。

例えばサーバーからのデータ受信やタイマー処理といったユーザー操作によるイベントではないが表示を更新するようなパターンです。この場合は自らステートが変更されたので再レンダリングしてほしいという意思を伝えるために StateHasChanged を呼び出す必要があります。

イベント発火時 (@bind や @onclick とか)

冒頭でプロジェクトテンプレートの Counter でクリックしてプライベート変数を更新するだけでページの表示が変わって不思議、と書きましたがそのような挙動になる理由はここにあります。

Blazor の ComponentBase ではイベントハンドラーの実行時に StateHasChanged を呼び出しています。

https://github.com/dotnet/aspnetcore/blob/v5.0.0/src/Components/Components/src/ComponentBase.cs#L313

Task IHandleEvent.HandleEventAsync(EventCallbackWorkItem callback, object? arg)
{
    var task = callback.InvokeAsync(arg);
    var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
        task.Status != TaskStatus.Canceled;
    // After each event, we synchronously re-render (unless !ShouldRender())
    // This just saves the developer the trouble of putting "StateHasChanged();"
    // at the end of every event callback.
    StateHasChanged();
    return shouldAwaitTask ?
        CallStateHasChangedOnAsyncCompletion(task) :
        Task.CompletedTask;
}

これにより「クリックイベントが発生する」→「プライベート変数を更新する」→「StateHasChanged を呼び出す」→「再レンダリング」→「値が表示に反映される」という流れになっているというわけです。

変数を監視していたり、プロキシになっていたりするわけではなくイベントが発生したら StateHasChanged (=ステート変更通知)が呼び出されているだけという単純な仕組みです。

Parameter が変わったとき

コンポーネントの Parameter に渡される値が変化した場合もステートの変更がありと認識されます。

これも ComponentBase.SetParametersAsync の中で StateHasChanged を呼びだしているので、値の変更があると SetParametersAsync が呼び出されることで結果的にステートの変更があった扱いになります。

https://github.com/dotnet/aspnetcore/blob/v5.0.0/src/Components/Components/src/ComponentBase.cs#L277

private Task CallOnParametersSetAsync()
{
    OnParametersSet();
    var task = OnParametersSetAsync();
    // If no async work is to be performed, i.e. the task has already ran to completion
    // or was canceled by the time we got to inspect it, avoid going async and re-invoking
    // StateHasChanged at the culmination of the async work.
    var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion &&
        task.Status != TaskStatus.Canceled;
    // We always call StateHasChanged here as we want to trigger a rerender after OnParametersSet and
    // the synchronous part of OnParametersSetAsync has run.
    StateHasChanged();
    return shouldAwaitTask ?
        CallStateHasChangedOnAsyncCompletion(task) :
        Task.CompletedTask;
}

ステートが変更されると (StateHasChanged が呼ばれると) 何が起こるのか

StateHasChanged メソッドを呼ぶと次のような流れで処理が実行されます。

初回と似ていますが異なるのは初期化周りのメソッド(OnInitialized)が呼び出されないことと ShouldRender が呼ばれることです。

ShouldRender メソッドはレンダリングする必要があるかどうかを返すことができるものです。このメソッドのデフォルト実装は常に true を返しますが、カスタムコンポーネントは再レンダリングを抑える目的でオーバーライドできます。

StateHasChanged の詳細

StateHasChanged の実装は ComponentBase クラスにあり、protected として公開されています。

https://github.com/dotnet/aspnetcore/blob/v5.0.0/src/Components/Components/src/ComponentBase.cs#L100

このメソッド自体は比較的シンプルでやっていることは次のようになっています。

先ほどの流れで ShouldRender の後に BuildRenderTree が呼び出されると書いていましたが、実際は一度レンダラーのキューに詰めてから BuildRenderTree が呼び出されます。

まとめ

Blazor のライフサイクルやレンダリングでは StateHasChanged が重要な役割となります。

しかしイベントハンドラーのような暗黙的に呼び出されているケースもあり、Blazor の仕組みをあまり知らずに使っていても画面が更新されるので思わぬところでハマる可能性もあります。そういった罠を踏む前にある程度流れを抑えておくことをお勧めします。

Blazor Web Assembly を publish すると AssemblyResolutionException でエラーとなる

Created at:

現象

Visual Studio からの発行や dotnet publish -c Release などを実行したとき、IL Linker 実行中に以下のような AssemblyResolutionException がスローされます。

  Fatal error in Mono IL Linker
C:\Users\Tomoyo\.nuget\packages\microsoft.aspnetcore.components.webassembly.build\3.2.1\targets\Blazor.MonoRuntime.targets(326,5): error : Unhandled exception. Mono.Cecil.AssemblyResolutionException: Failed to resolve assembly: 'SQLitePCLRaw.core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=1488e028ca7ab535' [C:\Users\Tomoyo\Source\Repos\BlazorApp6\BlazorApp6\BlazorApp6.csproj]
   ---> Mono.Cecil.AssemblyResolutionException: Failed to resolve assembly: 'SQLitePCLRaw.core, Version=1.0.0.0, Culture=neutral, PublicKeyToken=1488e028ca7ab535'
     at Mono.Cecil.BaseAssemblyResolver.Resolve(AssemblyNameReference name, ReaderParameters parameters)
     at Mono.Linker.AssemblyResolver.Resolve(AssemblyNameReference name, ReaderParameters parameters)
     at Mono.Linker.LinkContext.Resolve(IMetadataScope scope)
     at Mono.Linker.LinkContext.Resolve(IMetadataScope scope)
     at Mono.Linker.LinkContext.ResolveReferences(AssemblyDefinition assembly)
     at Mono.Linker.Steps.LoadReferencesStep.ProcessReferences(AssemblyDefinition assembly)
     at Mono.Linker.Steps.LoadReferencesStep.ProcessAssembly(AssemblyDefinition assembly)
     at Mono.Linker.Steps.BaseStep.Process(LinkContext context)
     at Mono.Linker.Pipeline.ProcessStep(LinkContext context, IStep step)
     at Mono.Linker.Pipeline.Process(LinkContext context)
     at Mono.Linker.Driver.Run(ILogger customLogger)
     at Mono.Linker.Driver.Execute(String[] args, ILogger customLogger)
     at Mono.Linker.Driver.Main(String[] args)

原因

IL Linker は依存しているアセンブリ参照をすべて検索して、不要な IL を削っていくというビルドステップです。

この例外(エラー)は依存先のアセンブリを探しているときにアセンブリが見つからなかった場合に発生するもので、通常 NuGet でパッケージ参照されているものに対しては発生しません。

しかし依存パッケージの作り次第では例外が発生する場合があります。例えば Microsoft.CodeAnalysis.Workspaces.Common のようなパッケージを参照すると発生します。

これは Microsoft.CodeAnalysis.Workspaces.CommonSQLitePCLRaw.bundle_green パッケージを PrivateAssets="all" として参照しているため、実際のパッケージにはパッケージ参照として SQLitePCLRaw.bundle_green が含まれないことでアプリケーションから参照したときにパッケージが解決されないのでアセンブリが見つからないということが発生します。

つまりパッケージの依存としては扱わないもののアセンブリを参照はあるという状況で発生します。必須ではないパッケージなどの場合にはこういった構成になります。

解決方法

解決方法としては次の2つの方法があります。

1. 必要なパッケージをプロジェクトで参照する

見つからないといわれているアセンブリが含まれているパッケージをプロジェクトから参照します。

2. IL Linker を無効にする

SQLitePCLRaw.bundle_green のようなものは参照してもエラーとなるのでそういった場合には IL Linker 自体を無効にします。

https://docs.microsoft.com/ja-jp/aspnet/core/blazor/host-and-deploy/configure-linker?view=aspnetcore-3.1

<PropertyGroup>
  <BlazorWebAssemblyEnableLinking>false</BlazorWebAssemblyEnableLinking>
</PropertyGroup>