Blazor [tips]: bindとonchangeの併用は不可

1. はじめに

Blazorを調査しているときに、データバインディングについて理解していない段階で はまった ことを思い出したのでTipsとして書いておきます。
「inputタグに"bind属性"と"onchange属性"を両方つけると、(おそらく)想定した動きをしないよねー」という話です。

2. サンプル

具体的には以下のコードです。

# Pages/index.razor #

@page "/"

<input type="text"
  bind="@inputValue"
  onchange="@(e => inputValueChanged(e.Value.ToString()) )"/>
<br />
→ inputValue: <div>@inputValue</div>
→ changedValue: <div>@changedValue</div>

@functions {
  private string inputValue { get; set; } = "";
  private string changedValue { get; set; } = "";

  void inputValueChanged(string newValue) {
    changedValue = newValue;
  }
}

実行画面

以下が実行画面です。

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

テキストボックスに「はろー」と入力して、フォーカスをテキストボックスから外します。
そうすると以下の画面のようになります。

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

実装コード的には以下を想定したのですが、、、

inputValue: はろー
changedValue: はろー

以下のようになりました。

inputValue: はろー
changedValue: 

結論としては「onchangeが効いていない」という事です。
記事タイトルの通り「bindとonchangeの併用は不可」って事ですね。

onchange="@(e => inputValueChanged(e.Value.ToString()) )"

3. なんで bindとonchangeは併用 できない?

ということで、先程のページ(index.razor)をビルドしたアセンブリ(dll)をILSpyで覗いてみます。
(補足:ここではindex.razorはhello_bind_appというBlazorプロジェクトに実装しました)

// hello_bind_app.Pages.Index
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
  builder.OpenElement(0, "input");
  builder.AddAttribute(1, "type", "text");
  builder.AddAttribute(2, "onchange", EventCallback.Factory.Create(this, delegate(UIChangeEventArgs e)
  {
    inputValueChanged(e.Value.ToString());
  }));
  builder.AddAttribute(3, "value", BindMethods.GetValue(inputValue));
  builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, delegate(string __value)
  {
    inputValue = __value;
  }, inputValue));
  builder.CloseElement();
  builder.AddMarkupContent(5, "\r\n<br>\r\n→ inputValue: ");
  builder.OpenElement(6, "div");
  builder.AddContent(7, inputValue);
  builder.CloseElement();
  builder.AddMarkupContent(8, "\r\n→ changedValue: ");
  builder.OpenElement(9, "div");
  builder.AddContent(10, changedValue);
  builder.CloseElement();
}

Blazorのページレンダリング(RenderTreeの構築)の仕組みとして、ページクラスの BuildRenderTree() メソッドがその役割を担います。
ここに .razor で定義した「HTML / CSS / インラインで記述したrazorコード」が、コードとして展開されています。

onchangeイベントに対するハンドリング処理は以下のように、良い感じに構築されています。

  builder.AddAttribute(2, "onchange", EventCallback.Factory.Create(this, delegate(UIChangeEventArgs e)
  {
    inputValueChanged(e.Value.ToString());
  }));

が、その直後に同じinputタグに対して再度 onchange 属性がaddAttribute()されています。
これは「bind="@inputValue"」によるtwo-wayデータバインディングを構築する実装に該当します。

  builder.AddAttribute(3, "value", BindMethods.GetValue(inputValue));
  builder.AddAttribute(4, "onchange", EventCallback.Factory.CreateBinder(this, delegate(string __value)
  {
    inputValue = __value;
  }, inputValue));

同じinputタグの onchange属性 に対して2回AddAttribute()が行われ、結果として後勝ちとなり「onchange="@(e => inputValueChanged(e.Value.ToString()) )」は無かったものとなってしまいます。

という事で、「なんで bindとonchangeは併用 できない?」の根拠がすっきりしました。

valueとonchangeの併用はおk

bindではなく、valueによるone-wayバインディングではonchangeがハンドルされないので、以下の onchange は有効に働きます。

# Pages/index.razor #

...省略...
<input type="text"
  value="@inputValue"
  onchange="@(e => inputValueChanged(e.Value.ToString()) )"/>
...省略...

ビルド後のアセンブリ実装は以下。

// hello_bind_app.Pages.Index
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.RenderTree;

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
  builder.OpenElement(0, "input");
  builder.AddAttribute(1, "type", "text");
  builder.AddAttribute(2, "value", inputValue);
  builder.AddAttribute(3, "onchange", EventCallback.Factory.Create(this, delegate(UIChangeEventArgs e)
  {
    inputValueChanged(e.Value.ToString());
  }));
  builder.CloseElement();
  builder.AddMarkupContent(4, "\r\n<br>\r\n→ inputValue: ");
  builder.OpenElement(5, "div");
  builder.AddContent(6, inputValue);
  builder.CloseElement();
  builder.AddMarkupContent(7, "\r\n→ changedValue: ");
  builder.OpenElement(8, "div");
  builder.AddContent(9, changedValue);
  builder.CloseElement();
}

まあ、このサンプルではbindによる動作が落ちることになるので、これが正解実装というわけではありません。
以下のような画面になる。changedValueには反映するが、two-wayではなくなったのでinputValueには反映しない。

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

3. ということは bindとoninputの併用はおk

bindはonchangeに展開されるので、bindとoninputの併用は問題ありません。

# Pages/index.razor #

...省略...
<input type="text"
  bind="@inputValue"
  oninput="@(e => inputValueChanged(e.Value.ToString()) )"/>
...省略...

oninputは入力ごとのイベントが発生するので、値の反映のタイミングは異なりますが、以下の画面のようにbindとoninputは両方が有効に動作していることが確認できます。

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

4. まとめ

ということで、
「bindはコンパイル後に onchange を含む実装に展開されるから、Developer側のonchange定義が無効になっちゃうよ」
そんなTipsでした。