題目
こんなページを持つ Blazor アプリケーションを実装することを考えます。
まずはじめにページ上にはボタンがひとつあります (下図)。
これをクリックすると、テキスト入力欄が開く、という仕掛けです (下図)。
ボタンクリックしたら入力欄が表示されるところまで実装
まずは以下のように Razor コンポーネント (.razor) として実装してみました。
<div>
<button @onclick="OnClickShowInputArea" disabled="@_showInputArea">
アンケートに回答する
</button>
@* はじめは _showInputArea は false なので、textarea は描画されない *@
@if (_showInputArea)
{
<div>
<textarea />
<button>回答する</button>
</div>
}
</div>
@code
{
private bool _showInputArea = false;
// アンケートに回答... ボタンをクリックすると呼ばれる
private void OnClickShowInputArea()
{
// このフラグを true に変更することで、textarea が描画されるようになる
_showInputArea = true;
}
}
これでボタンクリックしたら入力欄が表示されるようになります。
入力欄にフォーカスを当てる。が、クラッシュする。
さてしかし、このままでは、ボタンクリックでたしかにテキスト入力欄が出現するものの、フォーカスがその出現したテキスト入力欄には当たっていません。ですので、そのままテキスト入力を始められず、Tab キー操作やマウスクリックによって明示的にテキスト入力欄にフォーカスを与える必要があります。これはユーザー操作的には不親切です。
そこで、ボタンクリックでテキスト入力欄が出現したら、そのテキスト入力欄にフォーカスを与えるように実装することにしました。コードは以下のようになります、
...
@if (_showInputArea)
{
<div>
@* @ref を使って、この textarea 要素への参照を取得し... *@
<textarea @ref="_textAreaRef" />
...
</div>
}
...
@code
{
...
private ElementReference _textAreaRef;
private async Task OnClickShowInputArea()
{
_showInputArea = true;
// その textarea への参照を使ってフォーカス設定を実行!
await _textAreaRef.SetFocusAsync();
}
}
上記実装でビルドも難なくとおりました。ではでは、これで上手くいく... と思いきや、ボタンをクリックしたときに以下の実行時エラーが発生してしまいました。
System.InvalidOperationException:
ElementReference has not been configured correctly.
どうしてこんなことになってしまうのでしょうか。どうすれば解決し、目的の動作を実現できるのでしょうか。
実行時エラーになってしまう原因
Blazor におけるイベント処理時、そのイベントハンドラ内でフラグを変更するなどの処理が行なわれたとしても、その都度毎回、リアルタイムに描画に反映されるわけではありません。イベントハンドラが処理中の間は、Blazor ランタイムとしては手が出せず、ハンドラの処理を待つのみなのです。そしてイベントハンドラが処理を完了すると、そこで描画処理が行なわれます。
先の実装では、イベントハンドラメソッド内でフラグを変更した直後 & まだ Blazor ランタイムによる描画処理が実行される前に、_textAreaRef
を参照してしまっています。しかしいま説明したようにまだ再度の描画の前ですから、前回描画完了時は @if (_showInputArea) { ... }
ブロックによって textarea 要素は実体化しておらず、それで _textAreaRef
の参照先が未定義である故に前述のエラーに至った次第です。
...
@if (_showInputArea)
{
<div>
<textarea @ref="_textAreaRef" />
...
</div>
}
...
@code
{
...
private ElementReference _textAreaRef;
private async Task OnClickShowInputArea()
{
// _showInputArea フラグを変更したとたんに描画が行なわれるわけではない!
_showInputArea = true;
// このイベントハンドラが Blazor ランタイムに処理を返した後に
// 描画処理が実行され、textarea 要素が描画されて実体化し、
// 結果、_textAreaRef にその要素への参照が設定される
// なので、この時点では _textAreaRef は未設定!
}
}
解法1. OnAfterRenderAsync とフラグ管理
解決方法ですが、Razor コンポーネントでは、OnAfterRender
および OnAfterRenderAsync
というライフサイクルメソッドが、Blazor ランタイムによる描画処理が完了するたびに毎回呼び出されます。
つまり今回の例でいうと、_showInputArea
フラグの変更の結果が描画に反映された後、すなわち、textarea 要素が描画・実体化され、_textAreaRef
にその要素への参照が確かに格納されたタイミングでも、OnAfterRender
および OnAfterRenderAsync
が呼び出されます。
これを応用して、_showInputArea
が true
に変更されて直後の OnAfterRenderAsync
メソッド呼び出しで、フォーカス設定処理を実行すればよさそうです。"変更されて直後" というタイミングを判断するために、フラグ管理が少々ややこしくなりますが、以下のような実装になります。
...
@if (_showInputArea)
{
<div>
<textarea @ref="_textAreaRef" />
...
</div>
}
...
@code
{
private bool _showInputArea = false;
private bool _initialFocused = false;
private ElementReference _textAreaRef;
private void OnClickShowInputArea()
{
// ボタンクリック処理では表示フラグを変更するのみ
_showInputArea = true;
}
// OnAfterRenderAsync ライフサイクルメソッドをオーバーライドし...
override protected async Task OnAfterRenderAsync(bool firstRender)
{
// この時点で _showInputArea が true なら、textarea 要素は描画済みで
// _textAreaRef は確実にその textarea 要素を参照している
if (_showInputArea)
{
// とはいえ、何かしら再描画のたびに毎回、フォーカス設定されても困るので、
// 初回のみを判定するフラグを使う
if (_initialFocused == false)
{
_initialFocused = true;
// このタイミングでフォーカス設定すれば OK!
await _textAreaRef.FocusAsync();
}
}
}
}
解法2. await Task.Delay(1)
ところで、イベントハンドラメソッドで表示フラグを true
に変更したのちに、いちど await Task.Delay(1)
を呼び出すと、Blazor ランタイムにいったん処理が戻ります。つまり Blazor ランタイムによって再描画が実施されます。これを利用して以下のように実装しても動作します。
...
@if (_showInputArea)
{
<div>
<textarea @ref="_textAreaRef" />
...
</div>
}
...
@code
{
...
private async Task OnClickShowInputArea()
{
_showInputArea = true;
// ここでいったん Blazor ランタイムに処理が返され、描画が実行される
await Task.Delay(1);
// 結果、再びここから処理が再開したときには、textarea 要素は描画されており、
// _textAreaRef はその参照を掴めているので、このフォーカス設定は成功する!
await _textAreaRef.FocusAsync();
}
}
ただ、動作はするのですが、Task.Delay()
に渡す遅延時間 "1" がどうにも気持ち悪く、また、コードの表現がやりたいことをうまく説明できていないように感じられ、自分は上記のような実装は躊躇しています。
解法3. Task の活用
あるいはまた、Task
を使って、表示フラグ変更後の再描画処理の待ち合わせを行なう実装も考えられます。実装例は次のとおり。
...
@code {
...
private TaskCompletionSource? _afterRender;
private Task AfterRenderTask => (_afterRender ??= new()).Task;
private async Task OnClickShowInputArea()
{
_showInputArea = true;
// ここでいったん Blazor ランタイムに処理が返され、描画が実行されるまで待機となる
await this.AfterRenderTask;
// 結果、再びここから処理が再開したときには、textarea 要素は描画されており、
// _textAreaRef はその参照を掴めているので、このフォーカス設定は成功する!
await _textAreaRef.FocusAsync();
}
protected override void OnAfterRender(bool firstRender)
{
// Blazor による描画か完了するたびに、_afterRender に設定された
// TaskCompletionSource に対しタスク完了を報せる
Interlocked.Exchange(ref _afterRender, null)?.SetResult();
}
}
自分としては、await Task.Delay(1)
のような微妙な待機もなく、最初に示したフラグ管理に比べて if
文もないので、こちらの Task
を駆使した実装のほうが好みです。フォーカス設定処理が、OnAfterRenderAsync
に分離されてしまうことなく、ボタンクリックのイベントハンドラ内で表現できているのも、好感が持てます。
まとめ
以上、ElementReference
による DOM 要素への参照を使用するには、Blazor の描画サイクルを考慮しておかないと、ElementReference has not been configured correctly
実行時例外になってしまうかもという話、そして、この実行時例外を回避するための自分が思いついた実装方法 x 3 案の紹介でした。
このお題は、下記リンク先の "REPL for Blazor" で、Web ブラウザ上で実際に試してみることができます。
ぜひぜひ皆さんもご自身で試してみていただき、他にもスマートな実装方法がありましたら、ぜひコメント欄等でお知らせください!