.NET 8 の Blazor の SSR で何個もボタンを置いた画面を作りたい!
.NET 8 の Blazor の SSR ですが拡張フォーム処理とストリーム レンダリングを組み合わせることで、1つのシンプルなフォームはいい感じに作れるようになっています。
ただ、この場合 form
の onsubmit
イベントは 1 つしかないので、複数個のボタンがあるような画面を作るのがメンドクサイです。
複数ボタンに対応するには submit 用のボタンに name
属性で名前を指定して、HttpContext.Request.Form
の中に name
属性の値が入っているかどうかで押されたボタンを判別することが出来ます。イメージとしては以下ようなコードになります。
<form @onsubmit="OnSubmit" @formname="counter" method="post" data-enhance>
<AntiforgeryToken />
<h3>Counter</h3>
<input type="hidden" name="Model.Count" value="@Model.Count" />
@* name 属性で、どのボタンが押されたかわかるようにする *@
<button name="incrementButton" type="submit">Increment</button>
<button name="decrementButton" type="submit">Decrement</button>
<p>Count: @Model.Count</p>
</form>
@code {
[CascadingParameter]
public HttpContext HttpContext { get; set; } = null!;
private void OnSubmit()
{
if (HttpContext.Request.Form.ContainsKey("incrementButton"))
{
// incrementButton が押されたときの処理
}
else if (HttpContext.Request.Form.ContainsKey("decrementButton"))
{
// decrementButton が押されたときの処理
}
}
}
正直いってかなりメンドクサイです。
簡略化しよう
ということで簡略化しようと思います。
HttpContext
から指定されたキーの値があるか無いかをチェックして、キーの値に応じた処理を呼び分けるということが出来れば良いので、以下のようにフォームの処理を行うためのメソッドに FormHandler
という属性をつけておいて、ボタンの name
属性には @nameof(FormHandler属性のついてメソッド)
でメソッド名を指定するような感じで使えるようにしてみました。
using System.Reflection;
namespace BlazorApp5;
/// <summary>
/// フォームの onsubmit 時に呼び出されるメソッドにつける属性です。
/// </summary>
[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public class FormHandlerAttribute : Attribute { }
/// <summary>
/// フォームの onsubmit 時に呼び出されるメソッドを管理する。
/// </summary>
/// <typeparam name="TPage">ページの型</typeparam>
/// <param name="httpContextAccessor"></param>
public sealed class FormHandler<TPage>(IHttpContextAccessor httpContextAccessor)
{
private static readonly MethodInfo[] _handlerMethods;
/// <summary>
/// onsubmit の処理中は true になります。
/// </summary>
public bool IsProcessing { get; private set; }
/// <summary>
/// onsubmit の処理中は false になります。
/// </summary>
public bool IsIdle => !IsProcessing;
static FormHandler()
{
// ページの型から FormHandlerAttribute がついたメソッドを探す。
_handlerMethods = typeof(TPage).GetMethods(BindingFlags.Public | BindingFlags.NonPublic | BindingFlags.Instance)
.Where(m => m.GetCustomAttribute<FormHandlerAttribute>() is not null)
.ToArray();
}
/// <summary>
/// フォームの onsubmit 時に呼び出されるメソッドを返します。
/// </summary>
/// <param name="page">ページのインスタンス</param>
/// <returns>form の onsubmit に設定するデリゲート</returns>
public Func<Task> HandleSubmit(TPage page) =>
() => HandleSubmitCore(page);
private async Task HandleSubmitCore(TPage page)
{
// HttpContext は必須なのでチェック
if (httpContextAccessor.HttpContext is null) throw new InvalidOperationException("HttpContext is null.");
try
{
// 処理中のフラグを立てる
IsProcessing = true;
// ページの FormHandler 属性のついたメソッドに対応する値がフォームに含まれていれば呼び出す。
foreach (var method in _handlerMethods)
{
if (httpContextAccessor.HttpContext.Request.Form.ContainsKey(method.Name))
{
var result = method.Invoke(page, null);
if (result is Task task)
{
// 非同期の場合は完了を待つ。
await task;
}
return;
}
}
// 対応するメソッドが無い場合は設定ミスなので例外を投げる。
throw new InvalidOperationException("Not found.");
}
finally
{
// 処理中のフラグを下ろす
IsProcessing = false;
}
}
}
Program.cs
に作成した FormHandler
と HttpContextAccessor
を登録しておきます。
// この 2 行を追加
builder.Services.AddHttpContextAccessor();
builder.Services.AddTransient(typeof(FormHandler<>));
ページのコードは以下のようになります。
@page "/counter"
@attribute [StreamRendering]
@* FormHandler を DI コンテナから受け取る *@
@inject FormHandler<Counter> FormHandler
@{
_ = Model ?? throw new InvalidOperationException();
}
@* onsubmit で FormHandler の HandleSubmit で作成したデリゲートを設定しておく *@
<form @onsubmit="FormHandler.HandleSubmit(this)" @formname="counter" method="post" data-enhance>
<AntiforgeryToken />
<h3>Counter</h3>
<input type="hidden" name="Model.Count" value="@Model.Count" />
@* nameof でメソッド名を name 属性に設定する *@
<button name="@nameof(IncrementAsync)" type="submit" disabled="@FormHandler.IsProcessing">Increment</button>
<button name="@nameof(DecrementAsync)" type="submit" disabled="@FormHandler.IsProcessing">Decrement</button>
<p>Count: @Model.Count</p>
</form>
@code {
[SupplyParameterFromForm]
public CounterModel? Model { get; set; }
protected override void OnInitialized()
{
Model ??= new();
}
// FormHandler 属性で onsubmit 時に呼び出されるメソッドを指定する。
[FormHandler]
public async Task IncrementAsync()
{
_ = Model ?? throw new InvalidOperationException();
await Task.Delay(500);
var current = int.Parse(Model.Count);
Model.Count = $"{current + 1}";
}
[FormHandler]
public async Task DecrementAsync()
{
_ = Model ?? throw new InvalidOperationException();
await Task.Delay(500);
var current = int.Parse(Model.Count);
Model.Count = $"{current - 1}";
}
public class CounterModel
{
public string Count { get; set; } = "0";
}
}
以下のような感じで動作します。
まとめ
あんまり、こういう仕組みは自分で作りたくない(将来のバージョンアップで同じような機能で、よりよいものが入りそうな気がする…) のですが、とりあえずの繋ぎとして使うのにはいいかも。
それにしてもページ全体を form
で括ってやるのは Classic ASP の頃のベストプラクティス的なものだったり ASP.NET WebForms では、フレームワークとしてそういう作りが前提になっていたのですが、それを思い出しました。歴史は繰り返す?
Discussion
現時点ではこのケースだと普通に回線つなぎたいなあ。。。(身も蓋もない)
個人的にはスケールインするマシンに当たると死んじゃうのが対処できたら回線でいいなぁと思います。