BlazorでSPAするぞ!(2) - Component

※ 2019/6/15 .NET Core 3.0 Preview 6 に対応した記述に修正しました。

という事で、↓↓↓の続きです。

ryuichi111std.hatenablog.com

BlazorでのUI要素であるComponent(コンポーネント)について見ていこうと思います。

1. コンポーネント(Component)

前回 Hello Blazor ページを実装しましたが、これ自体がコンポーネントです。
ページやダイアログ、ページ内の要素などのUI要素(オブジェクト)がコンポーネントです。
HTML/CSS と イベント処理などを行うロジックコード で構成されます。
コンポーネントは、ネストしてコンポーネント内に配置することも可能です。

まあ、ASP.NETとかWPFとかでも同様な一般的な UIコンポーネント とか コントロール とか言われるようなものですね。

1.1. SimpleHelloコンポーネントの作成(.razor)

コンポーネントは .razor 拡張子で作成します(Previewリリースより前は .cshtml 拡張子でした)。
では超シンプルなコンポーネントを作成し、そのコンポーネントをページに配置したいと思います。

まずは dotnet new でプロジェクトを作成します。
名前はsimple_component_blazorとしました。

C:\workspace\for_blog>dotnet new blazor -o simple_component_blazor
The template "Blazor (client-side)" was created successfully.

Processing post-creation actions...
Running 'dotnet restore' on simple_component_blazor\simple_component_blazor.csproj...
  C:\workspace\for_blog\simple_component_blazor\simple_component_blazor.csproj の復元が 219.72 ms で完了しました。

Restore succeeded.

(1) SimpleHello.razorを作成

simple_component_blazorフォルダ直下に Componentsフォルダ を作成します。
そして、Components/SimpleHello.razor ファイルを作成します。

# Components/SimpleHello.razor #

@Message

@code {
  private string Message = "Hello シンプル過ぎるコンポーネント";
}
@Message と Message定義

「@Message」を記述することにより、@codeブロックで定義した変数Messageが画面上に表示されいます。

(2) SimpleHelloコンポーネントをページに配置

次にページ(Pages/Index.razorn)に、SimpleHelloコンポーネントを配置します。

# Pages/Index.razor #

@page "/"
@using simple_component_blazor.Components

<SimpleHello />
@using

まず「@using」。
Blazorの.razorでは、プロジェクト名+フォルダ階層が名前空間になります(※1)。
Index.simple_component_blazr.Pages名前空間のIndexクラスになります。
SimpleHello.razorは、simple_component_blazor.Components名前空間のSimpleHelloクラスになります。
Pages/Index.razorからは、別の名前空間コンポーネントを使うことになるので「@using simple_component_blazor.Components」として名前空間への参照を指定しておきます。

※1 namespaceを明示的に指定することも可能です。

# Components/SimpleHello.razor (名前空間を明示的に指定)#

@namespace hogehoge.ugougo

@Message

@code {
  private string Message = "Hello シンプル過ぎるコンポーネント";
}
<SimpleHello />

@usingでsimple_component_blazorへの参照を定義済みなので、コンポーネントコンポーネントクラス名(= 拡張子.razorを除いたファイル名 = SimpleHello)をタグで記述することで配置可能です。

dotnet runで実行した画面は以下です。

f:id:daigo-knowlbo:20190501221813p:plain

1.2. Calculatorコンポーネントの作成(.razor)

次にCalculatorコンポーネントというものを作成したいと思います。
完成画面は以下です。
入力テキストボックスを2つ配置し、その和をreadonlyのテキストボックスに表示します。

f:id:daigo-knowlbo:20190501221917p:plain

まずは dotnet new でプロジェクトを作成します。
名前はcalc_blazorとしました。

C:\workspace\for_blog>dotnet new blazor -o calc_blazor
The template "Blazor (client-side)" was created successfully.

Processing post-creation actions...
Running 'dotnet restore' on calc_blazor\calc_blazor.csproj...
  C:\workspace\for_blog\calc_blazor\calc_blazor.csproj の復元が 219.72 ms で完了しました。

Restore succeeded.

(1) Calculationコンポーネントを作成

calc_blazorフォルダ直下に Componentsフォルダ を作成します。
Components/Calculator.razor / Components/CalculatorBase.cs ファイルを作成します。
(UI定義の .razor と コードビハインドの .cs です)

# Components/Calculator.razor #

@inherits calc_blazor.Components.CalculatorBase

<input type="text" @bind="@Value1" />
+
<input type="text" @bind="@Value2" />
=
<input type="text" readonly="true" @value="@Result" />
<br />
<input type="button" @onclick="@Calc" value="計算" />
# Components/CalculatorBase.cs #

using System;
using Microsoft.AspNetCore.Components;

namespace calc_blazor.Components
{
  public class CalculatorBase : ComponentBase
  {
    public int Value1 { get; set; } = 0;
    
    public int Value2 { get; set; } = 0;

    public int Result { get; set; } = 0;

    public void Calc() {
      this.Result = this.Value1 + this.Value2;
    }
  }
}
CalculatorBaseクラス

コードビハインドクラスの実装です。
Value1 / Value2 は2つの入力項目、Result は計算結果を表すプロパティです。
また Calc()メソッド は、Value1 と Value2 の和を Result に設定する計算メソッドです。

Calculator.razor

主にUIを定義したrazorファイルです。
「@inherits calc_blazor.Components.CalculatorBase」でコードビハインドクラスを指定しています。
Value1 と Value2 は <input> タグの @bind属性 に対してバインディング設定されています。
これはTwo-Wayバインディングになります。ロジック上でValue1/Value2の値が変更されるとHTML上でのinput-valueの値が変更されますし、逆に、HTML UI上からユーザによって値の入力が行われるとValue1/Value2の値が変更されます。
Result は <input> タグの value属性 に対してバインディング設定されています。
これはOne-Wayバインディングになります。ロジック上でResultの値が変更されるとHTML上でのinput-valueの値が変更されますが、逆に、HTML UI上からユーザによって行われた値の変更はResultには反映されません(ここではReadOnlyとすることで入力自体出来なくしていますが)。
「<input type="button" @onclick="@Calc" value="計算" />」により、ボタンクリック時に計算メソッド Calc() が呼び出されるようにしています。
Two-WayバインディングでHTML上の入力からプロパティ this.Value1 / this.Value2 に反映した値を合計し、this.Result を更新しています。this.Resultの変更はOne-WayバインディングによりUIに反映されます。

(2) Calculationコンポーネントをページに配置

作成した Components/Calculator コンポーネントを、Pages/Index.razor に配置します。

# Pages/Index.razor #

@page "/"
@using calc_blazor.Components

<Calculator />

dotnet run コマンドを実行するとCalculatorコンポーネントが画面に表示されます。

(さらに)Calculator.razorにイベントを追加

もう1つ。Calculatorコンポーネントにイベントを追加してみたいと思います。
計算が完了した際に計算完了イベント(計算完了コールバック)を実装します。

Calculator.razorにイベント(コールバック)を追加

まず、CalculatorBase.csコードビハインドクラスにイベントを追加します。

# Components/CalculatorBase.cs #

using System;
using Microsoft.AspNetCore.Components;

namespace calc_blazor.Components
{
  public class CalculatorBase : ComponentBase
  {
    public int Value1 { get; set; } = 0;
    
    public int Value2 { get; set; } = 0;

    public int Result { get; set; } = 0;

    [Parameter]
    public EventCallback<int> ChangeCalcResult { get; set; }

    public void Calc() {
      this.Result = this.Value1 + this.Value2;
      ChangeCalcResult.InvokeAsync(this.Result);
    }
  }
}
【ChangeCalcResult】

「public EventCallback ChangeCalcResult { get; set; }」がイベントハンドラの受け口になります。
[parameter]属性は今後(別投稿)の説明で出てきますが、ここでは「呼び出し元(親)から受け取る値について付ける属性」と思っておいてください。

Index.razorでイベント(コールバック)を受け取る

次にIndex.razor側でイベント(コールバック)を受け取ります。
こちらは、とりあえずコードビハインドを使わずに実装してしまいます。

# Pages/Index.razor #

@page "/"
@using calc_blazor.Components

<Calculator ChangeCalcResult="@OnChangeCalcResult" />
<br />
計算結果:@ReceivedResult

@code {
  private int ReceivedResult = 0;

  private void OnChangeCalcResult(int result) {
    this.ReceivedResult = result;
  }
}
【ChangeCalcResult="@OnChangeCalcResult"】

Calculatorタグの属性ChangeCalcResultにイベント(コールバック)を受け取るメソッド名を記述します。
OnChangeCalcResult()メソッドは@codeブロック内で定義しています。

dotnet runでの実行画面は以下です。

f:id:daigo-knowlbo:20190501222820p:plain

1.3. one-wayバインディングとtwo-wayバインディング

引き続き、作成したcalc_blazorを使ってDataBindingに関するちょっとした実験をしてみます。

<input value={@xxx}>によるOne-Wayバインディングについて、razorコードを不適切にいじって動作を確認してみます。
以下のように、Value2に対するバインディングを 「@bind → value」 に変更します。

# Components/Calculator.razor(Value2をOne-wayバインディングに変更) #

@inherits calc_blazor.Components.CalculatorBase

<input type="text" @bind="@Value1" />
+
<input type="text" value="@Value2" />
=
<input type="text" readonly="true" value="@Result" />
<br />
<input type="button" onclick="@Calc" value="計算" />

つまり、Value2 は「HTML UIからの変更がコードのValue2変数に反映されない」状態になりました。
実行画面は以下です。
「10 + 20 = 10」という事で、Value2の値は計算コード上では 0 となってしまいました(HTML inputの値が反映されなかった)。

f:id:daigo-knowlbo:20190501222936p:plain

(1) valueと@bindのIL(Intermediate Language)

Blazor(というか.NET)は、.razor/.csをコンパイルしてDLL(アセンブリ)に変換しています。つまりC#をILに変換しています。
valueと@bindの違いは、ILを覗くとしっくり感じ取ることができます。
Value2への「bind→valueの変更を元に戻して」ビルドしておきます(dotnet buildコマンド実行)。
「calc_blazor\bin\Debug\netstandard2.0\calc_blazor.dll」をIlSpyで逆コンパイルした画面が以下です。

f:id:daigo-knowlbo:20190501223053p:plain

calc_blazor.Components.Calculator#BuildRenderTree(RenderTreeBuilder)メソッドがポイントです。
.razorで記述されたHTMLタグおよびRazor構文が解釈され、コードに落とし込まれています。
calc_blazor.Components.Calculator.BuildRenderTree()メソッドのコードは以下です。

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
    builder.OpenElement(0, "input");
    builder.AddAttribute(1, "type", "text");
    builder.AddAttribute(2, "value", BindMethods.GetValue(base.Value1));
    builder.AddAttribute(3, "onchange", EventCallback.Factory.CreateBinder(this, delegate(int __value)
    {
        base.Value1 = __value;
    }, base.Value1));
    builder.CloseElement();
    builder.AddMarkupContent(4, "\r\n+\r\n");
    builder.OpenElement(5, "input");
    builder.AddAttribute(6, "type", "text");
    builder.AddAttribute(7, "value", BindMethods.GetValue(base.Value2));
    builder.AddAttribute(8, "onchange", EventCallback.Factory.CreateBinder(this, delegate(int __value)
    {
        base.Value2 = __value;
    }, base.Value2));
    builder.CloseElement();
    builder.AddMarkupContent(9, "\r\n=\r\n");
    builder.OpenElement(10, "input");
    builder.AddAttribute(11, "type", "text");
    builder.AddAttribute(12, "readonly", value: true);
    builder.AddAttribute(13, "value", base.Result);
    builder.CloseElement();
    builder.AddMarkupContent(14, "\r\n<br>\r\n");
    builder.OpenElement(15, "input");
    builder.AddAttribute(16, "type", "button");
    builder.AddAttribute(17, "onclick", EventCallback.Factory.Create<UIMouseEventArgs>((object)this, (Action)base.Calc));
    builder.AddAttribute(18, "value", "計算");
    builder.CloseElement();
}

眺めれば、大体 Value1を構築している箇所 / Value2を構築している箇所 / Resultを構築している箇所 が分かります。
@bind属性を利用した Value1 / Value2 に対しては「onchangeに対してEventCallBackが作成され、デリゲートメソッド内で変更された値(__value)を base.Value1(コードビハインドのValue1プロパティ)に設定する」処理が実装されています。
一方value属性を利用した Result に対しては「onchangeに対する実装」がありません。
このように、.razorにおける value="@xxx" と @bind="@xxx" の定義の違いは、対象要素に対して UIからの変更をバインドプロパティに反映するonchangeイベントハンドラ を生成するかどうかの違いになってきます。

1.4. リストデータを表示するページ(コンポーネント)を作成

次はリストデータを表示するページを作成してみます。
TodoList表示ページとします。
以下のイメージです。

f:id:daigo-knowlbo:20190501223553p:plain

これまでコンポーネントという表現を使っていましたがページという表現をしました。
ページも「=コンポーネント」ですが、ここでは Pages/TodoList.razor というページ(コンポーネント)を追加して実装してみます。

以下のコマンドで todo_blazor プロジェクトを作成しておきます。

dotnet new blazor -o todo_blazor

(1) モデルクラスを作成

Todoを表すモデルクラスを作成します。
todo_blazorフォルダ直下に Modelsフォルダ を作成します。
Models/Todo.csを追加します。

# Models/Todo.cs #
using System;

namespace todo_blazor.Models {
  public class Todo {
    public int ID { get; set; }

    public string Contents { get; set; }

    public bool IsCompleted { get; set; }
  }
}

普通のC#のPOCOなモデルクラスです。

(2) ページ(コンポーネント)を作成

UIを定義した Pages/TodoList.razor とそのコードビハインド Pages/TodoListBase.cs を追加します。

まず、Pages/TodoList.razor。

# Pages/TodoList.razor #

@page "/Todos/list"
@inherits todo_blazor.Pages.TodoListBase
@using todo_blazor.Models

@foreach( Todo todo in this.TodoList) {
  @if(todo.IsCompleted) {
    <s>@todo.Contents</s>
  }
  else {
    @todo.Contents
  }
  <br />
}
【@page】

@page は、Blazorのルーティング設定です。
「【アプリルート】/Todos/list」が、このページを表すURIになります。

【@inherits】

TodoList.razorのコードビハインドクラスを設定しています。
todo_blazor.Pages.TodoListBaseクラスがコードビハインドクラスになります。

【@using】

.razor内でのレンダリングにモデルクラスを利用する為、@using todo_blazor.Models を設定しています(C#のusingと同じです)。

レンダリングロジック】

@foreach / @if でTodoをレンダリングしています。
これは従来からのASP.NET / ASP.NET Core の Razorエンジンによるテンプレート記述と同じ方式で記述可能です。

次に、コードビハインドクラス Pages/TodoListBase.cs。

# Pages/TodoListBase.cs #

using System;
using System.Collections.Generic;
using Microsoft.AspNetCore.Components;
using calc_blazor.Models;

namespace todo_blazor.Pages
{
  public class TodoListBase : ComponentBase
  {
    public List<Todo> TodoList { get; } = new List<Todo>();
    
    protected override void OnInit() {
      this.TodoList.Add( new Todo() { ID = 0, Contents = "C#やる", IsCompleted = true } );
      this.TodoList.Add( new Todo() { ID = 1, Contents = "TypeScriptやる", IsCompleted = false } );
      this.TodoList.Add( new Todo() { ID = 2, Contents = "Babelやる", IsCompleted = true } );
      this.TodoList.Add( new Todo() { ID = 3, Contents = "Blazorやる", IsCompleted = false } );
      this.TodoList.Add( new Todo() { ID = 4, Contents = "Goやる", IsCompleted = false } );
    }
  }
}
【TodoListプロパティ】

TodoListプロパティにTodoのリストを保持し、プロパティとして公開しています。

【OnInit()メソッド】

OnInit()メソッドでTodoListを初期化しています。
OnInit()メソッドは、コンポーネントのライフサイクルメソッドで、コンポーネントの初期化時に呼び出されます。
通常のアプリではWebAPI呼び出しなどでTodoリストを取得すると思いますが、ここではローカルで固定の5つのTodoを作成します。
TodoListプロパティの値が TodoList.razor に適用されて画面にレンダリングされます。

(3) メニューにTodoListを追加

自動生成されたプロジェクトテンプレートでは、サイドメニューに「Home / Counter / Fetch data」の3つが並んでいます。
TodoList.razor へのメニューリンクを追加します。
Shared/NavMenu.razor を開き、<li>要素の並びに以下を追加します。

# Shared/NavMenu.razor #
...省略...

<li class="nav-item px-3">
  <NavLink class="nav-link" href="Todos/list">
    <span class="oi oi-list-rich" aria-hidden="true"></span> Todo List
  </NavLink>
</li>

...省略...

NavLink要素の href属性 がページのURIを表します。ページ側で @page で指定したルーティングとマッチしたページにリンクするようになります。

以上で完成です。
コマンドプロンプトで(プロジェクトフォルダで) donet run コマンドにより実行することができます。

2. まとめ

という事でComponent(コンポーネント)についてのまとめ。
BlazorのUI要素は .razor拡張子 で定義し、これらがComponentと呼ばれるものである。
ページやコントロール的なもの含めてComponent(コンポーネント) と呼ぶ。
Component内には、Componentをネストして配置することができる。
Componentは、プロパティやイベントを持つことができる。

次は ryuichi111std.hatenablog.com