BlazorでSPAするぞ!(4) - データバインド(おかわり)

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

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

ryuichi111std.hatenablog.com

1. データバインド

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

1.1. サンプル

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

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

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

dotnet new blazor -o bind_ex_blazor

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

# Components/ChildComponent.razor #

<h2>Child Component</h2>

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

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

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

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

# Pages/Index.razor #

@page "/"
@using bind_ex_blazor.Components

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

@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 になります。

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

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

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

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

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

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

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

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

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

# Components/ChildComponent.razor #

<h2>Child Component</h2>

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

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

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

f:id:daigo-knowlbo:20190501230046p: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

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

@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 #

<h2>Child Component</h2>

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

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

    private int _yourScore = 0;

    [Parameter]
    private 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:20190501230118p: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

[Parameter]
private 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", BindMethods.GetValue(YourScore));
    builder.AddAttribute(11, "onchange", EventCallback.Factory.CreateBinder(this, delegate(int __value)
    {
        YourScore = __value;
    }, YourScore));
    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<UIMouseEventArgs>((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<UIMouseEventArgs>((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(BindMethods.GetValue(YourScore)));
    builder.AddAttribute(16, "YourScoreChanged", RuntimeHelpers.TypeCheck(EventCallback.Factory.Create(this, EventCallback.Factory.CreateInferred(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