Chapter 05

コンポーネント

Kazuki Ota
Kazuki Ota
2021.12.22に更新

はじめに

Hello world の章で Blazor WebAssembly の必要最低限のコードについて学びました。
本章では、著者自身が Blazor WebAssembly を理解するうえで一番重要だと考えるコンポーネントについて解説していきたいと思います。

Blazor WebAssembly のコンポーネント

Blazor WebAssembly では、基本的にアプリケーションの画面に表示されるものは、全てコンポーネントであると思っても差支えが無いほど、よく登場するものになります。例えば Hello world アプリケーションでは App というコンポーネントを画面に表示していました。

その他にも、後程の章で紹介するフォームや入力項目など様々なものがコンポーネントとして提供されています。また、ユーザーもコンポーネントを作ることが出来ます。ページもコンポーネントです。ユーザーは再利用可能なコンポーネントを定義していき、提供されているコンポーネントや自分で定義したコンポーネントを組み合わせてアプリケーションを作っていきます。

コンポーネントを作ってみよう

Hello world で App コンポーネントを作ったところで紹介した通りコンポーネントは Microsoft.AspNetCore.Components.ComponentBase クラスを継承したクラスです。razor という拡張子のファイルを作ると自動的に Microsoft.AspNetCore.Components.ComponentBase を継承したクラスが作られています。

では、Hello world のプロジェクトに 1 つコンポーネントを作ってみます。
GreetingMessage.razor という名前のファイルを Razor コンポーネントのアイテム テンプレートから作成します。
アイテムテンプレートから作成すると以下のようなコードが生成されます。

GreetingMessage.razor
<h3>GreetingMessage</h3>

@code {

}

ぱっと見で以下の 2 つの要素で構成されていることがわかると思います。

HTML っぽい部分の <h3>GreetingMessage</h3>@code { ... } で囲まれた部分です。Hello world の章でも説明したように上側が HTML と C# が混ざったような書き方が出来る部分で @code { ... } で囲まれた部分は C# でコンポーネント クラスのフィールドやプロパティやメソッドが定義できます。

とりあえずコードをいじる前に App.razor で、このコンポーネントを使ってみようと思います。コンポーネントを使うのは簡単で、純粋にクラス名をタグとして使うだけです。今回の場合は <GreetingMessage /> と書くだけです。App.razor に追加すると以下のようになります。

App.razor
@using Microsoft.AspNetCore.Components.Web
<h3>@Message</h3>

<button @onclick="ToJapanese">To Japanese</button>

@* GreetingMessage コンポーネントを使う *@
<GreetingMessage />

@code {
    private string Message { get; set; } = "Hello world";
    private void ToJapanese() => Message = "こんにちは世界";
}

因みに razor ファイルでのコメントは @* *@ で囲った部分になります。この状態で実行すると以下のように GreetingMessage コンポーネントの内容が表示されています。

このように非常に簡単にコンポーネントを定義して別のコンポーネント内で使うことが出来ます。コンポーネント自体の解説に入る前に、もう 1 つだけコンポーネントを簡単に定義して使えるところをお見せしようと思います。

まずは、コンポーネントに属性を追加して表示をカスタマイズできるようにしたいと思います。属性を定義するのも凄く簡単で Microsoft.AspNetCore.Components.ParameterAttribute をつけたプロパティを定義するだけです。それでは先ほどの GreetingMessage コンポーネントに Message プロパティを定義して表示させるように変えてみます。

GreetingMessage.razor
<h3>GreetingMessage</h3>
<p>@Message</p>

@code {
    [Parameter]
    public string Message { get; set; }
}

App.razor で Message プロパティに値を設定するようにします。

App.razor
@using Microsoft.AspNetCore.Components.Web
<h3>@Message</h3>

<button @onclick="ToJapanese">To Japanese</button>

@* GreetingMessage コンポーネントを使う *@
<GreetingMessage Message="挨拶!!" />

@code {
    private string Message { get; set; } = "Hello world";
    private void ToJapanese() => Message = "こんにちは世界";
}

実行すると以下のように Message で指定した内容が画面に表示されます。

非常に簡単に属性で見た目をカスタマイズできるコンポーネントを作ることが出来ました。このように razor ファイルでコンポーネントを定義して、それらを組み合わせて見た目を作っていくのが Blazor WebAssembly でのプログラミングのスタイルです。

コンポーネントのライフサイクル

ここまでで、非常に簡単にコンポーネントを定義して使用することが出来る感覚を掴んで頂けたと思います。ここでは、ただ作って使うだけではなくコンポーネント自体がどのようなライフサイクルで動いているかを説明したいと思います。コンポーネントのライフサイクルを理解することで、よりコンポーネントの動きをカスタマイズできるようになります。

コンポーネントのライフサイクルは、初回表示時と初回表示後でわけて考えると理解がしやすいです。コンポーネントは、初回表示の時には以下のような処理が行われます。そして、それに対応したメソッドが順に呼び出されます。

  1. コンポーネントのインスタンスの生成
  2. パラメーターのセット
    1. Task SetParametersAsync(ParameterView parameters) メソッド
      コンポーネントにパラメーターが設定されます。デフォルトの設定ロジックをカスタマイズしたい場合は SetParametersAsync メソッドをオーバーライドしてパラメーターの設定ロジックをカスタマイズできます。(通常はオーバーライドしなくてもいいメソッドです。)
  3. 初期化
    1. void OnInitialized() (Task OnInitializedAsync()) メソッド
      コンポーネントの初期化が終わった後に呼び出されます。
  4. パラメーターの設定後の処理
    1. void OnParametersSet() (Task OnParametersSetAsync()) メソッド
      OnInitialized メソッドの後に呼び出されます。パラメーターが設定された後のタイミングで呼び出されます。初期化時は OnInitialized メソッドと変わりはありませんが、初回表示後もパラメーターの変更後に呼ばれるコールバックとして OnParametersSet (OnParametersSetAsync) が使用できます。
  5. レンダリング
    1. void OnAfterRender(bool firstRender) (Task OnAfterRenderAsync(bool firstRender)) メソッド
      レンダリング後に呼び出されます。引数の firstRender には true が渡されます。

初回表示後は、以下のような動作になります。

  1. 以下のような条件によって再レンダリングがトリガーされた時
    • DOM イベント (ボタンクリックなど) 発生時
      イベントハンドラーが呼び出されます。イベントハンドラーが Task を返す場合は非同期処理の完了を待ちます。
    • 親コンポーネントにより再レンダリングがトリガーされた時
    • パラメーターに設定されている値が変わった時
      1. void OnParametersSet() (Task OnParametersSetAsync()) メソッド`
    • void StateHasChanged() メソッドがコンポーネント内で呼び出された時
  2. レンダリング
    1. bool ShouldRender() メソッド
      このメソッドが false を返すとレンダリングをスキップ出来ます。
    2. void OnAfterRender(bool firstRender) (Task OnAfterRenderAsync(bool firstRender)) メソッド
      レンダリング後に呼び出されます。引数の firstRender には false が渡されます。

ライフサイクルに伴って呼び出されるメソッドは上記のような時に呼び出されます。これらのメソッドには Task を返す非同期版のメソッドを持っているものもあります。非同期版のメソッドは、非同期処理の完了を待って後続の処理が呼び出されますが、例えば Task OnInitializedAsync() で 10 秒かかるような処理をしたとしても非同期ではないほうのルートは進んで OnInitialized -> OnParametersSet -> レンダリング -> OnAfterRender が呼び出されます。OnInitializedAsync の処理が完了した後にもレンダリングと OnAfterRender の呼び出しが行われます。

動作を確認するために、以下のような Lifecycle.razor というコンポーネントを作成してみましょう。

Lifecycle.razor
<h3>Lifecycle</h3>
<p>Value = @Value</p>

@code {
	private int Value { get; set; }
	public override Task SetParametersAsync(ParameterView parameters)
	{
		Console.WriteLine($"{DateTime.Now}: SetParametersAsync メソッドが呼び出されました。: {Value}");
		Console.WriteLine($"{DateTime.Now}: SetParametersAsync メソッドが終了しました。");
		return base.SetParametersAsync(parameters);
	}

	protected override void OnInitialized()
	{
		Console.WriteLine($"{DateTime.Now}: OnInitialized が呼び出されました。: {Value}");
		Console.WriteLine($"{DateTime.Now}: OnInitialized が終了しました。");
	}

	protected override async Task OnInitializedAsync()
	{
		Console.WriteLine($"{DateTime.Now}: OnInitializedAsync が呼び出されました。: {Value}");
		await Task.Delay(5000); // 5 秒待つ
		Value++; // OnInitializedAsync が終わったタイミングで Value が 1 になる
		Console.WriteLine($"{DateTime.Now}: OnInitializedAsync が終了しました。");
	}

	protected override void OnParametersSet()
	{
		Console.WriteLine($"{DateTime.Now}: OnParametersSet が呼び出されました。: {Value}");
		Console.WriteLine($"{DateTime.Now}: OnParametersSet が終了しました。");
	}

	protected override Task OnParametersSetAsync()
	{
		Console.WriteLine($"{DateTime.Now}: OnParametersSetAsync が呼び出されました。: {Value}");
		Console.WriteLine($"{DateTime.Now}: OnParametersSetAsync が終了しました。");
		return Task.CompletedTask;
	}

	protected override bool ShouldRender()
	{
		Console.WriteLine($"{DateTime.Now}: ShouldRender が呼び出されました。: {Value}");
		Console.WriteLine($"{DateTime.Now}: ShouldRender が終了しました。");
		return true;
	}

	protected override void OnAfterRender(bool firstRender)
	{
		Console.WriteLine($"{DateTime.Now}: OnAfterRender({firstRender}) が呼び出されました。: {Value}");
		Console.WriteLine($"{DateTime.Now}: OnAfterRender({firstRender}) が終了しました。");
	}

	protected override Task OnAfterRenderAsync(bool firstRender)
	{
		Console.WriteLine($"{DateTime.Now}: OnAfterRenderAsync({firstRender}) が呼び出されました。: {Value}");
		Console.WriteLine($"{DateTime.Now}: OnAfterRenderAsync({firstRender}) が終了しました。");
		return Task.CompletedTask;
	}
}

純粋に各種ライフサイクル メソッドをオーバーライドして標準出力にログを出しています。ポイントは OnInitializedAsync メソッドで 5 秒まって Value プロパティをインクリメントしているところです。
このコンポーネントを App.razor において実行すると以下のようなログがブラウザーの開発者ツールのコンソールに出力されます。

2021/05/21 15:22:44: SetParametersAsync メソッドが呼び出されました。: 0
2021/05/21 15:22:44: SetParametersAsync メソッドが終了しました。
2021/05/21 15:22:44: OnInitialized が呼び出されました。: 0
2021/05/21 15:22:44: OnInitialized が終了しました。
2021/05/21 15:22:44: OnInitializedAsync が呼び出されました。: 0
2021/05/21 15:22:44: OnAfterRender(True) が呼び出されました。: 0
2021/05/21 15:22:44: OnAfterRender(True) が終了しました。
2021/05/21 15:22:44: OnAfterRenderAsync(True) が呼び出されました。: 0
2021/05/21 15:22:44: OnAfterRenderAsync(True) が終了しました。
2021/05/21 15:22:49: OnInitializedAsync が終了しました。
2021/05/21 15:22:49: OnParametersSet が呼び出されました。: 1
2021/05/21 15:22:49: OnParametersSet が終了しました。
2021/05/21 15:22:49: OnParametersSetAsync が呼び出されました。: 1
2021/05/21 15:22:49: OnParametersSetAsync が終了しました。
2021/05/21 15:22:49: ShouldRender が呼び出されました。: 1
2021/05/21 15:22:49: ShouldRender が終了しました。
2021/05/21 15:22:49: OnAfterRender(False) が呼び出されました。: 1
2021/05/21 15:22:49: OnAfterRender(False) が終了しました。
2021/05/21 15:22:49: OnAfterRenderAsync(False) が呼び出されました。: 1
2021/05/21 15:22:49: OnAfterRenderAsync(False) が終了しました。

5 行目の「OnInitializedAsync が呼び出されました。: 0」の後で 5 秒 await で待っています。しかし、その後に OnAfterRender や OnAfterRenderAsync のメソッドが呼び出されているので、一旦レンダリングは行われていることがわかります。そして OnInitializedAsync が終わった後に OnParametersSet, OnParametersSetAsync, ShouldRender, OnAfterRender, OnAfterRenderAsync などのライフサイクルメソッドが呼び出されています。

このように動くので、例えば画面表示時に Web API を呼び出してデータを取ってくる場合は OnInitialized で実際のデータが取れる前の表示に関するデータをセットして OnInitializedAsync で Web API を呼び出します。そうすると初期表示では Web API のデータが無くても画面が表示されて、OnInitializedAsync でデータが取れた段階で再度表示が更新されてデータが画面に表示されます。

初回表示後は、以下のようなライフサイクルになります。

  1. DOM イベント (クリックなど) 発生
  2. イベントハンドラの実行
    • イベントハンドラは非同期・同期のどちらにも対応。
  3. イベントハンドラの終了待ち
  4. レンダリング
    1. bool ShouldRender() メソッド
      • コンポーネントのレンダリングが必要か必要ないかを返す。レンダリングの必要がある場合には true を返し必要がない場合は false を返す。
    2. レンダリング実行
    3. void OnAfterRender(bool firstRender) (Task OnAfterRenderAsync(bool firstRender)) メソッド
      • 引数の firstRender に false が渡されて呼び出されます。

最後にコンポーネントが破棄されるときの処理に対応するメソッドのライフサイクルメソッドですがコンポーネント自体には定義されていません。コンポーネントが破棄されたときの処理を記述するには System.IDisposable インターフェースか System.IAsyncDisposable インターフェースを実装して void Dispose() メソッドか ValueTask DisposeAsync() メソッドを実装して行います。

Lifecycle.razor
@* インターフェースを実装 *@
@implements IDisposable
@implements 

... 省略 ...

@code {
    public void Dispose()
    {
        // 破棄時の処理
    }

    public async ValueTask DisposeAsync()
    {
        // 破棄時の処理
    }
}

OnInitialized メソッドか OnInitializedAsync メソッドでイベントハンドラの登録を行った場合などに Dispose メソッドや DisposeAsync メソッドで登録解除などを行います。その他にキャンセルが必要な時間のかかる処理をコンポーネント内で行う際には CancellationTokenSource を使って以下のように行います。

CancellationTokenSourceComponent.razor
@using Microsoft.AspNetCore.Components.Web
@using System.Threading
@implements IDisposable
<h3>CancellationTokenSourceComponent</h3>

<button @onclick="CallLongProcessAsync">時間のかかる処理の呼び出し</button>
<button @onclick="LongProcessAsync">コンポーネント内での時間のかかる処理</button>

@code {
    private CancellationTokenSource _cts = new();

    protected override void OnInitialized()
    {
        Console.WriteLine("OnInitialized");
    }

    private async Task CallLongProcessAsync()
    {
        // 外部の非同期処理や時間のかかる処理に CancellationToken を渡して
        // キャンセルが必要になったタイミングでキャンセルしてもらうようにする
        try
        {
            Console.WriteLine("Task.Delay を呼び出しました");
            await Task.Delay(10000, _cts.Token);
            Console.WriteLine("Task.Delay を呼び出しが終了しました");
        }
        catch (TaskCanceledException)
        {
            Console.WriteLine("Task.Delay の呼び出しがキャンセルされました");
            throw;
        }
    }

    private async Task LongProcessAsync()
    {
        // 長い処理はキャンセルされているかどうかを確認してやる
        Console.WriteLine("時間のかかる処理を行います。");
        var token = _cts.Token;
        while (!token.IsCancellationRequested)
        {
            Console.WriteLine("キャンセルがあるまで処理を行います。");
            // 時間のかかる処理
            await Task.Delay(500);
        }
        Console.WriteLine("時間のかかる処理がキャンセルにより終了しました。");
    }

    // コンポーネントの破棄時に時間のかかる処理をキャンセル
    public void Dispose()
    {
        Console.WriteLine("Dispose");
        // コンポーネントが破棄されたタイミングでキャンセルをする
        _cts.Cancel();
        _cts.Dispose();
    }
}

ディレクティブ・ディレクティブ属性

@page@using など一般的にコンポーネントの先頭に書くディレクティブというものがあります。@ の後に特定のキーワードを付けることでコンポーネントに色々な機能を有効化したりパーサーの処理を変更したりできます。例えば、後程紹介する画面遷移を実現するためのルーティング機能にたいして、コンポーネントと URL のパスを紐づけるために使用する @page ディレクティブやコンポーネント内で使用するクラスの名前空間を using するための @using ディレクティブがあります。
その他に、コンポーネントにインターフェースを実装するための @implements ディレクティブや DI コンテナからインスタンスを注入してもらうための @inject ディレクティブなどがあります。

ディレクティブがコンポーネント自体の機能の有効化なのに対してディレクティブ属性はコンポーネント内で使用する HTML タグや他のコンポーネントに対して色々な機能を有効化できます。例えば button タグのクリックイベントにイベントハンドラを設定するには @onclick ディレクティブ属性を指定します。例えばクリック時に OnSomeButtonClick メソッドを紐づけるには以下のように書きます。

<button @onclick="OnSomeButtonClick">Some button</button>

@code {
   private void OnSomeButtonClick()
   {
      // ボタンクリック時の処理
   }
}

個々のディレクティブやディレクティブ属性の使い方については、該当する章で解説を行います。

コンポーネントでの値の表示

コンポーネントで変数の値を表示する場合は、これまでもコード例の中で示していますが @ に続いてコンポーネントのプロパティ名など指定します。例えば以下のようなコンポーネントでは h2 タグの中に Message プロパティの内容が表示されます。

<h3>@Message</h3>

@code {
	private string Message { get; set; } = "Hello world";
}

このコード例では Message プロパティに Hello world という文字列が入っているので、レンダリングの結果は以下のようになります。

<h3>Hello world</h3>

ライフサイクルの項で示した通り、イベントハンドラなどで Message プロパティの値が更新されるとレンダリングが行われてコンポーネントの表示が更新されます。例えば以下のように button がある場合を考えてみます。

@using Microsoft.AspNetCore.Components.Web @* クリックイベントのために必要 *@

<h3>@Message</h3>

<button @onclick="ChangeMessageClick">Change message</button>

@code {
	private string Message { get; set; } = "Hello world";
	private void ChangeMessageClick()
	{
		Message = "こんにちは世界";
	}
}

このコンポーネントでボタンを選択すると Message プロパティが「こんにちは世界」という値に変わり、イベントハンドラの終了後に再レンダリングが行われて、コンポーネントの表示が「こんにちは世界」に変わります。

明示的なコンポーネントの再レンダリングのリクエスト

ここまでのライフサイクルの確認で、一般的に再描画が必要なタイミングで Blazor のコンポーネントは再描画されることがわかりました。ただ、このタイミング以外にも再描画をしたいケースがあります。例としてボタンをクリックしたら Web API を呼び出すケースを考えてみましょう。
Web API の呼び出しには多少の待ち時間が発生します。Blazor のデフォルトの処理では、最初の await のタイミングで一度再レンダリングが行われ、イベントハンドラの実行が終わったタイミングで再度レンダリングが行われます。つまり、複数回の await が入るケースで各 await のタイミングで画面の見た目を更新するといったことは出来ません。例えば「ボタンを押したら A と B という Web API を呼び出す。API の呼び出し状況を"A を呼び出しています。"、"B を呼び出しています。"といったメッセージで表示してほしい」というケースでは "A を呼び出しています。" の部分で再レンダリングが行われますが "B を呼び出しています。" の部分では再レンダリングが行われません。

その他にもタイマーで定期的に画面に表示するためのデータを書き換えるような処理があるケースでも組み込みのライフサイクルでは画面の再レンダリングが行われません。試してみましょう。タイマーのような動きをするコンポーネントを作成します。OnInitialized メソッドで 1 秒間隔で Now プロパティを現在時刻で更新しています。コンポーネントでは Now プロパティを画面に表示しています。

@using Microsoft.AspNetCore.Components.Web
@using System.Threading
@implements IDisposable

<span>@Now</span>

@code {
	private DateTime Now { get; set; }
	private Timer _timer;

	protected override void OnInitialized()
	{
		// 1秒間隔で現在時間を更新
		_timer = new Timer(_ =>
		{
			Console.WriteLine("Tick!!");
			Now = DateTime.Now;
		}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
		
	}

	protected override void OnAfterRender(bool firstRender)
	{
		Console.WriteLine($"OnAfterRender({firstRender})");
	}

	public void Dispose()
	{
		_timer.Dispose();		
	}
}

この状態で実行すると、ログには Tick!! と表示されるのですが、画面の時刻は一切更新されません。

このような時のために、明示的にデータに変更があったのでコンポーネントに再レンダリングを要求する StateHasChanged というメソッドが提供されています。では、先ほどのコンポーネントに StateHasChanged の呼び出しを追加します。ここでは、タイマーが値を書き換えたタイミングで再レンダリングを行ってほしいので Timer のコールバックの部分に StateHasChanged の呼び出しを追加します。また StateHasChanged メソッドは Blazor の同期コンテキスト上で呼び出す必要があるので InvokeAsync でラップしてあげる必要があります。OnInitialized やクリックイベントは Blazor の同期コンテキスト上で呼ばれますが、今回のようなタイマーでは Blazor の同期コンテキスト外で呼ばれるため InvokeAsync でラップして呼び出す必要があります。

@using Microsoft.AspNetCore.Components.Web
@using System.Threading
@implements IDisposable

<span>@Now</span>

@code {
	private DateTime Now { get; set; }
	private Timer _timer;

	protected override void OnInitialized()
	{
		// 1秒間隔で現在時間を更新
		_timer = new Timer(_ =>
		{
			_ = InvokeAsync(() =>
			{
				Console.WriteLine("Tick!!");
				Now = DateTime.Now;
				StateHasChanged(); // ここに追加
			});
		}, null, TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(1));
		
	}

	protected override void OnAfterRender(bool firstRender)
	{
		Console.WriteLine($"OnAfterRender({firstRender})");
	}

	public void Dispose()
	{
		_timer.Dispose();		
	}
}

この状態で実行すると、以下のようにタイマーが Now プロパティを書き換えたタイミングで画面も更新されるようになります。

分岐やループによるコンポーネントの表示

コンポーネント内で @ に続けて ifforeachwhile などの C# のコードを書くことが出来ます。例えば以下のようなコンポーネントでは Message プロパティ

@using Microsoft.AspNetCore.Components.Web @* クリックイベントのために必要 *@

@if (string.IsNullOrEmpty(Message))
{
    <h3>Message プロパティに値が設定されていない</h3>
}
else
{
    <h3>@Message</h3>
}

<button @onclick="ChangeMessageClick">Change message</button>

@code {
	private string Message { get; set; }
	private void ChangeMessageClick()
	{
		Message = "こんにちは世界";
	}
}

以下のように foreach を使うことでコレクションのデータを表示することも出来ます。


<ul>
	@foreach (var item in Items)
	{
		<li>@item</li>
	}
</ul>

@code {
	private string[] Items { get; set; } = new[] { "A", "B", "C" };
}

データバインディング

[WIP]

  • @bind でバインドできる
  • @bind:event でバインドの更新タイミングを指定できる
  • @bind-PropertyName で自前のコンポーネントののプロパティとバインドできる
  • 変更通知のための PropertyNameChanged イベント

パラメーター

普通のパラメーターをカスケードパラメーター