BlazorでSPAするぞ!(4) - データバインド(おかわり) - 正式版対応済

※最終更新日: 2020/5/24 正式リリース版に対応修正しました。

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

ryuichi111std.hatenablog.com

1. データバインド

Component のデータバインディングについては BlazorでSPAするぞ!(2) - Component -正式版対応済 - ryuichi111stdの技術日記 で軽く説明し、value="@xxx"によるone-wayバインディング / @bind="xxx"によるtwo-wayバインディングについても使用しました。
この部分についてもう少し おかわり(深堀) したいと思います。

1.1. サンプル

以下のような画面のサンプルアプリをもとに説明を進めます。

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

donet newコマンドでプロジェクトを作成します。

dotnet new blazorwasm -o bind_ex_blazor

Components/ChildComponent.razorコンポーネントを作成します。

# Components/ChildComponent.razor #

<div class="container bg-secondary">
  <h2>Child Component</h2>

  <p>YourScore: @YourScore</p>
  <input type="text" value="@YourScore" />
</div>

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

YourScoreというint型の値をパラメータとして受け取り、テキストおよびinput要素として表示します。

次に、ChildComponentコンポーネントをPages/Index.razorに配置します。

# Pages/Index.razor #

@page "/"
@using bind_ex_blazor.Components

<div class="container bg-primary">
  <h2>Index Component</h2>
  Score: @YourScore
  <br />
  <button @onclick="InclementScore">+</button>
  <button @onclick="DeclementScore">-</button>
  <br />
  <ChildComponent YourScore="@YourScore" />
</div>

@code {
  private int YourScore = 100;

  private void InclementScore() {
    this.YourScore++;
  }

  private void DeclementScore() {
    this.YourScore--;
  }
}

Index.razor自体にも いくつかUIを追加しています。
YourScoreは初期値100で用意しています。
+ボタン / -ボタンを配置し、クリックされたらイベントハンドラ「InclementScore() / DeclementScore()」によりYourScoreの値を増減させます。

1.2. 動作確認

では、動作を確認していきます。

+ボタンをクリックすると、Index.razorの YourScore 値が +1 され 101 になります。
-ボタンをクリックすると、Index.razorの YourScore 値が -1 され 99 になります。

以下は 110 までincrementした状態。

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

Index.razorの「Score: @YourScore」の値も、Blazor Runtimeによりバインド変数の変化の影響を自動判定して変更が反映されます。
また、ChildComponentへの「YourScore="@YourScore"」の値も、同様に変更が反映します。

以下、引き続きこのサンプルをベースに説明を進めます。

2. value="@YourScore" によるデータバインド

上述サンプルにおいて、ChildComponentのテキストボックスの値を直接変更してみます。
「1012」を入力し、フォーカスを移動します。

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

テキストボックス以外のYourScore値は変更されませんした。
これはテキストボックスに対して「value="@YourScore"」としてデータバインド定義が行われており、つまりone-wayバインディングになっているためです。
(データ→UIの一方通行バインディング)

3. @bind="YourScore" によるデータバインド

ChildComponent.razorを以下のように「@bind="YourScore"」で書き換えます。
これでtwo-wayバインディングとなり、データ→UI / UI→データの両方向のデータバインディングが有効になります。

# Components/ChildComponent.razor #

<div class="container bg-secondary">
  <h2>Child Component</h2>

  <p>YourScore: @YourScore</p>
  <input type="text" @bind="YourScore" />
</div>

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

dotnet runし直し、再び、ChildComponentのテキストボックスの値を直接変更してみます。
two-wayバインディングになったため、UIでの入力内容 1012 が裏側でChildComponentの YourScoreプロパティ値 に反映し、「YourScore: @YourScore」にも変更が反映しました。

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

ただし、親コンポーネント側(Index.razor)にはこの変更は伝達されていません(Index.razorのScore表示は100のまま)。

4. @bind-YourScore="YourScore" によるデータバインド

ChildComponentでのYourScoreの変更が、親コンポーネントIndex.razorにも伝達されるようにしてみたいと思います。

4.1. Index.razorのバインド定義を修正

コンポーネント側(Index.razor)で、ChildComponent定義時のYourScoreプロパティへのパラメータ定義を「YourScore="@YourScore"」から「@bind-YourScore="@YourScore"」に変更します。
つまり「@bind-」プレフィックスを追加しています。

@page "/"
@using bind_ex_blazor.Components

<div class="container bg-primary">
  <h2>Index Component</h2>
  Score: @YourScore
  <br />
  <button @onclick="InclementScore">+</button>
  <button @onclick="DeclementScore">-</button>
  <br />
  <ChildComponent @bind-YourScore="@YourScore" />
</div>

@code {
  private int YourScore = 100;

  private void InclementScore() {
    this.YourScore++;
  }

  private void DeclementScore() {
    this.YourScore--;
  }
}

4.2. ChildComponent.razorにEventCallbackを実装

ChildComponent.razor側にも手を加えます。

# Components/ChildComponent.razor #

<div class="container bg-secondary">
  <h2>Child Component</h2>

  <p>YourScore: @YourScore</p>
  <input type="text" @bind="YourScore" />
</div>

@code {
    [Parameter]
    public EventCallback<int>YourScoreChanged { get; set; }

    private int _yourScore = 0;

    [Parameter]
    public int YourScore {
        get {
            return this._yourScore;
        }
        set {
            if(this._yourScore != value) {
                this._yourScore = value;
                YourScoreChanged.InvokeAsync(this._yourScore);
            }
        }
    }
}

(1) EventCallbackパラメータ定義

EventCallbackパラメータを追加しています。
YourScoreChangedイベントを定義していますが、これにより当該イベント発生時にサブスクライバに対してイベントコールバックを呼び出すことができます。
int型の値をコールバック引数として渡すので「EventCallback<int>」と定義しています。

(2) YourScoreプロパティのgetter / settter定義

YourScoreプロパティにgetter/setterを定義しました。
setterでは、値が変更されていた場合、EventCallbackを呼び出します(YourScoreChanged.InvokeAsync(this._yourScore);)。

4.3. 実行

dotnet runで実行してみます。
ChildComponentのテキストボックス値を変更しフォーカスを移動すると、親コンポーネント(Index.razor)のYourScore値も変更されました。

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

4.4. ILを確認

Index.razor / ChildComponent.razorにいくつかの変更を加えましたがコンパイル後のILを確認すると、動作の繋がり・フローがよく分かります。
「bind_ex_blazor\bin\Debug\netstandard2.0\bind_ex_blazor.dll」をIlSpyでオープンします。

(1) ChildComponent.razor

まず、ChildComponentですが「YourScoreプロパティの定義」「BuildRenderTree()メソッドの定義」は以下のようになっています。

// bind_ex_blazor.Components.ChildComponent
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

public class ChildComponent : ComponentBase
{
    private int _yourScore = 0;

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

    [Parameter]
    public int YourScore
    {
        get
        {
            return _yourScore;
        }
        set
        {
            if (_yourScore != value)
            {
                _yourScore = value;
                YourScoreChanged.InvokeAsync(_yourScore);
            }
        }
    }

    protected override void BuildRenderTree(RenderTreeBuilder __builder)
    {
        __builder.OpenElement(0, "div");
        __builder.AddAttribute(1, "class", "container bg-secondary");
        __builder.AddMarkupContent(2, "\r\n  ");
        __builder.AddMarkupContent(3, "<h2>Child Component</h2>\r\n\r\n  ");
        __builder.OpenElement(4, "p");
        __builder.AddContent(5, "YourScore: ");
        __builder.AddContent(6, YourScore);
        __builder.CloseElement();
        __builder.AddMarkupContent(7, "\r\n  ");
        __builder.OpenElement(8, "input");
        __builder.AddAttribute(9, "type", "text");
        __builder.AddAttribute(10, "value", BindConverter.FormatValue(YourScore));
        __builder.AddAttribute(11, "onchange", EventCallback.Factory.CreateBinder(this, delegate(int __value)
        {
            YourScore = __value;
        }, YourScore));
        __builder.SetUpdatesAttributeName("value");
        __builder.CloseElement();
        __builder.AddMarkupContent(12, "\r\n");
        __builder.CloseElement();
    }
}

YourScoreプロパティの定義の方は、ほぼ元の実装の通りです。繰り返しですが、setterで値が変更されたらYourScoreChangedがInvokeされます。
BuildRenderTree()メソッドは、テキストボックスに対してonchangeがハンドルされYourScoreプロパティに代入が行われています。
「テキストボックス入力→onchange→YourScore setter呼び出し→YourScoreChangedのinvoke」という流れになります。

(2) 親コンポーネント(Index.razor)

次に親コンポーネントIndex.razorのBuildRenderTree()メソッドを見ていきます。

// bind_ex_blazor.Pages.Index
...
protected override void BuildRenderTree(RenderTreeBuilder __builder)
{
    __builder.OpenElement(0, "div");
    __builder.AddAttribute(1, "class", "container bg-primary");
    __builder.AddMarkupContent(2, "\r\n  ");
    __builder.AddMarkupContent(3, "<h2>Index Component</h2>\r\n  Score: ");
    __builder.AddContent(4, YourScore);
    __builder.AddMarkupContent(5, "\r\n  <br>\r\n  ");
    __builder.OpenElement(6, "button");
    __builder.AddAttribute(7, "onclick", EventCallback.Factory.Create<MouseEventArgs>((object)this, (Action)InclementScore));
    __builder.AddContent(8, "+");
    __builder.CloseElement();
    __builder.AddMarkupContent(9, "\r\n  ");
    __builder.OpenElement(10, "button");
    __builder.AddAttribute(11, "onclick", EventCallback.Factory.Create<MouseEventArgs>((object)this, (Action)DeclementScore));
    __builder.AddContent(12, "-");
    __builder.CloseElement();
    __builder.AddMarkupContent(13, "\r\n  <br>\r\n  ");
    __builder.OpenComponent<ChildComponent>(14);
    __builder.AddAttribute(15, "YourScore", RuntimeHelpers.TypeCheck(YourScore));
    __builder.AddAttribute(16, "YourScoreChanged", RuntimeHelpers.TypeCheck(EventCallback.Factory.Create(this, RuntimeHelpers.CreateInferredEventCallback(this, delegate(int __value)
    {
        YourScore = __value;
    }, YourScore))));
    __builder.CloseComponent();
    __builder.AddMarkupContent(17, "\r\n");
    __builder.CloseElement();
}

ChildComponentに対して「YourScoreChanged」イベントハンドラが定義されEventCallbackで受け取ったint値を自分のYourScoreに代入しています。
この「YourScoreChanged」実装は、Index.razorを以下の実装にした場合には出力されません。

<ChildComponent YourScore="@YourScore" />

今回の修正で以下のように実装したために追加された処理になります。

<ChildComponent @bind-YourScore="@YourScore" />

Blazorでは、「@bind-【プロパティ名】」と定義することで、「【プロパティ名】Changed」というEventCallbackを受け取る仕組みになっています。

という事で、整理すると以下のフローになります。

子コンポーネント側:
①テキストボックス入力
②onchange
③YourScore setter呼び出し
④YourScoreChangedのinvoke
↓↓↓  
親コンポーネント側:
⑤YourScoreChangedをハンドルして自らのYourScoreに変更値を反映
⑥UIにも変更が反映  

※以下の「bind-YourScore-YourScoreChanged」に関してはPreview6で廃止されました。

5. bind-YourScore-YourScoreChanged="@YourScore" によるデータバインド

前のサンプルで「bind-YourScore="@YourScore"」という定義を行いましたが、これは「bind-YourScore-YourScoreChanged="@YourScore"」と同意になります。
では、以下のように実装したらどうなるか?

# Index.razor #

<ChildComponent bind-YourScore-YourScoreChangeeeeeeeed="@YourScore" />

コンパイル後のILは以下のようになります。
定義した「YourScoreChangeeeeeeeed」がそのままEventCallback名としてコンパイルされました。

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
  ...省略
  builder.OpenComponent<ChildComponent>(14);
    builder.AddAttribute(15, "YourScore", RuntimeHelpers.TypeCheck(BindMethods.GetValue(YourScore)));
    builder.AddAttribute(16, "YourScoreChangeeeeeeeed", EventCallback.Factory.CreateBinder(this, delegate(int __value)
  ...省略
}

これを正常に動作させる場合、ChildComponent側のEventCallback定義名も合わせて変更する必要があります。

# ChildComponent.razor #
  [Parameter]
  public EventCallback<int>YourScoreChangeeeeeeeed { get; set; }

3. まとめ

データバインディングの動作の仕組みをILレベルで確認してみました。
データバインド構文的には以下のような感じで、構文とその動きを理解すればいいわけですが、ILを見てみるとロジックとして動作が繋がってすっきりしました。

one-way: [property name]="@Value"
two-way: @bind-[property name]="@value"
bind-[property name]-[event callback] ※Preview6で廃止されました。

次は ryuichi111std.hatenablog.com