38
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 3 years have passed since last update.

Blazor 開発者は input 入力を即時処理しようとして二度死ぬ

Last updated at Posted at 2020-08-10

はじめに

この話題は、いちおう、公式ドキュメントにもはっきり記載されている仕様ではありますし、この件に関して解説して下さっているネット上の記事も既にあります。
ですが、それにしても自分を含め自分の周りでこの件にぶつかる事例を見ることが少なくないので、改めて本記事でまとめます。

命題: input へのテキスト入力を、入力があるたびに変数に反映したい

SPA を開発するにあたって、その要件として、 input 要素へのテキスト入力を、入力があるたびに逐次処理したいケースはままあるかと思います。

Angular の場合

私が扱える SPA 実装用の言語/フレームワークとしては、TypeScript/Angular と C#/Blazor とがあるのですが (React や Vue をはじめとした他のフレームワークについては、ごめんなさい、自分は扱えないです)、さて Angular や Blazor であれば、input 要素に対するデータバインディングが使えます。

ですので、例えば Angular であれば、以下のようにバインド用のフィールド変数をコンポーネントクラスに用意して input 要素にバインドしてやることで、input 要素に対する入力を、このフィールド変数を参照して知るようにすることでしょう。

バインドしたフィールド変数の内容が確認できるよう、同フィールド変数を p 要素内への表示としてもバインドしておきます。

// app.component.ts on Angular

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
  <p><input [(ngModel)]="currentValue" /></p>
  <p>CurrentValue is: "{{currentValue}}"</p>`
})
export class AppComponent {
  public currentValue = '';
}

実際に ng serve で走らせてみると、期待どおり動作していることがわかります。

movie-001.gif

それが Blazor だと...

同じノリで、Blazor で実装するとどうなるでしょうか。

Angular と同じ構造になりますが、以下のようにコーディングするのではないかと思います。

@* App.razor on Blazor *@

<p><input @bind="CurrentValue" /></p>
<p>CurrentValue is: "@CurrentValue"</p>

@code {
    private string CurrentValue { get; set; }
}

ところがこれは、おそらくは大半の読者が期待する動作ではないと思います。
input 要素に入力しても、バインドしたフィールド変数には、その入力内容が即時反映されないのです。

movie-002.gif

ではいつ反映されるかというと、上図からも推測できますが、input 要素での change イベントが発生したタイミングで反映されるのです。

このことはインテリセンスで表示されるドキュメントコメントからもうかがえます。

fig.001.png

input イベントで反映させたい

しかしだからといって
「なんと! Blazor だと入力内容が即時反映されない!? まったく使えないではないか!」
と Blazor を窓から投げ捨てるのはちょっと待って下さい。

少し手をかける必要はあるのですが、Blazor でも入力内容を即時にバインド先の変数に反映させることは可能です。

具体的には、input 要素での change イベントではなく input イベントでバインド先の変数に入力内容を反映させるように Blazor を仕向けることが可能です。

input イベントは、キーボードからの入力や、マウス操作でのクリップボードからの貼り付けなど、なにかした入力に変更が発生したときに発生するので、今回の命題のような用途にはうってつけです。

このようなとき、Blazor では、@bind:event="..." ディレクティブを書き足すことでこれを実現します。バインド先の変数に反映するタイミングを、この @bind:event="..." ディレクティブで変更可能なのです。

今回のケース、input イベントのタイミングで反映させたい場合は、@bind:event="oninput" を書き足したします。

...
<p><input @bind="CurrentValue" @bind:event="oninput" /></p>
...

これでようやく Blazor でも、input イベントのタイミングで即時にバインド先の変数に結果が反映されるようになりました。

movie-003.gif

命題: input イベントのタイミングで、入力された内容について何かする

さてしかし、実際のところは、input 要素に入力された内容を即時、別の表示にバインドするというだけがやりたいことではないはずです。
大抵の場合は、同じく input イベントのタイミングで、input 要素に入力された内容に対して何か処理することになるでしょう。

その場合、当該 input 要素の input イベントをハンドルすることになります。

Angular の場合

まずは Anuglar でやってみましょう。

先のコードについて、input 要素の input イベントをハンドルするコードを書き足します。とりあえず今回はデモンストレーションですので、単純に、開発者コンソールに入力された内容を表示するだけにします。

// app.component.ts on Angular
  ...
  <p><input [(ngModel)]="currentValue" (input)="onInput()"/></p>
  ...
export class AppComponent {
  ...
  public onInput(): void {
    console.log(`onInput: "${this.currentValue}"`);
  }
}

動作を確認してみると、期待どおり動作していることがわかります。

movie-004.gif

それが Blazor だと...

同じノリで、Blazor で実装するとどうなるでしょうか。

Angular と同じ構造になりますが、以下のように @oninput="..."input イベントをハンドルしようとコーディングするのではないかと思います。

@* App.razor on Blazor *@

<p><input @bind="CurrentValue" @bind:event="oninput" @oninput="OnInput" /></p>
...
@code {
  ...
  private void OnInput()
  {
    Console.WriteLine($"OnInput: \"{CurrentValue}\"");
  }
}

ところが上記コードは、実行はおろか、下記エラーでコンパイルすら通りません (!)。
(※実行時に爆発するより、コンパイル時に阻止されるほうが何倍もマシではあるのですが、それはさておき。)

The attribute 'oninput' is used two or more times for this element.
Attributes must be unique (case-insensitive).
The attribute 'oninput' is used by the '@bind' directive attribute.

fig.002.png

どうしてこんなことになるかというと、まず、Blazor の仕様として、とある HTML 要素に対し、@on{イベント名}="..." で登録できるイベントハンドラは、1 イベントにつき 1 つまで、という制約があります。
(正確にいうと、イベントというか、属性指定を重複して指定できないということになります)

いっぽうで、@bind:event="oninput" ディレクティブにより、Blazor 内で @oninput="..." 相当のコードが組み込まれてしまいます。

それで、我々開発者が明示的に追記した @oninput="..." は、@bind:event="oninput" ディレクティブによって暗黙的に追加された @oninput="..." に押し負ける格好となって、エラーとなるのです。

ただ、まぁ、理屈がわかったところで、ちょっと納得感は薄いですよね。
自分も「どうしてこんな仕様・挙動にしたのか?」と感じられずにはいられません。

とはいうものの、
「なんと! Blazor だとバインドとイベントハンドリングが両立しない!? まったく使えないではないか!」
と Blazor を窓から投げ捨てるのはちょっと待って下さい。

少し手をかける必要はあるのですが、Blazor でも最終的な要求を満たすことは可能です。

但し大変残念なことに、この問題を解決するには、Blazor の双方向バインド構文 (今まで使ってきた @bind="..." ディレクティブ) は使えなくなります。

順を追って見ていきましょう。

まず前述のとおり、1つのイベントに対しては、明示的・暗黙的に依らず1までしかイベントハンドラを登録できませんから、まずは @bind:event="oninput" ディレクティブの記載を撤去します。

...
<!-- 👇 @bind:event="oninput" を削除 -->
<input @bind="CurrentValue" @oninput="OnInput"/>
...

さてこの状態ですと @bind="..." ディレクティブは、暗黙的に change イベントをハンドルしてバインド先の変数を更新する状態になります。
ですが、今や時前で input イベントをハンドルして入力された内容を処理しようとしているわけです。
ですので、もはや @bind="..." ディレクティブによる Blazor ランタイムにての双方向バインドを仕込むことは意味がありません。
そこでバインド先の変数を input 要素の value 属性へ反映させるだけの単方向バインドを指定するだけに留めます。

具体的には、@bind="CurrentValue"value="@CurrentValue" に置き換えます。

...
<!-- 👇 @bind="CurrentValue" を置換 -->
<input value="@CurrentValue" @oninput="OnInput"/>
...

次はイベントハンドラである OnInput メソッドを改造していきます。

先の実装例では OnInput メソッドは引数なしで実装していましたが、これを改め、Blazor から渡されるイベント引数 (ChangeEventArgs 型のオブジェクトになります) を受け取るようにします。

...
private void OnInput(ChangeEventArgs e) // 👈 引数を追加
{
  Console.WriteLine($"OnInput: \"{CurrentValue}\"");
}
...

この ChangeEventArgs 型のイベント引数には、そのイベント発生時の入力内容が Value プロパティに収まっています。

ですので、イベント引数を参照してその時点の入力内容を入手することで、実装完了です。

...
private void OnInput(ChangeEventArgs e)
{
  CurrentValue = e.Value as string; // 👈 イベント引数から入力内容を入手
  Console.WriteLine($"OnInput: \"{CurrentValue}\"");
}

これでようやくコンパイルも通るようになり、期待どおりの動作となりました。

movie-005.gif

※なお、バインド変数 CurrentValue を用意せずとも「input イベントが発生する毎に、その時点の入力内容に対して何かする」という実装はもちろん可能です。ですが、一般的な用途では、初期値が入力済みの状況があったりすると思われますので、上記実装例での紹介としました。

まとめ

最終的なサンプルコードを以下に再録します。

Angular の場合

// app.component.ts on Angular

import { Component } from '@angular/core';

@Component({
  selector: 'app-root',
  template: `
  <p><input [(ngModel)]="currentValue" (input)="onInput()" /></p>
  <p>CurrentValue is: "{{currentValue}}"</p>  `
})
export class AppComponent {
  public currentValue = '';
  public onInput(): void {
    console.log(`onInput: "${this.currentValue}"`);
  }
}

Blazor の場合

@* App.razor on Blazor *@

<p><input value="@CurrentValue" @oninput="OnInput" /></p>
<p>CurrentValue is: "@CurrentValue"</p>

@code {
  private string CurrentValue { get; set; }

  private void OnInput(ChangeEventArgs e)
  {
    CurrentValue = e.Value as string;
    Console.WriteLine($"OnInput: \"{CurrentValue}\"");
  }
}

個人的な感想ではありますが、Blazor 推しな私であっても、この Blazor の仕様・挙動は、Angular と比べても、非直感的に過ぎる感想を持っています。

そうはいっても、少なくとも今日現在時点では Blazor がこのような仕様・実装となっている以上、この現実に向き合わなくては動くプログラムは作れません。

ということで、本記事が一人でも多くの Blazor 開発者の救いになればと願う次第です。

38
26
2

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
38
26

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?