.NETで作れるブラウザ上のシングルアプリケーション Blazor は、仮想 DOM を使っていて、内部的には(多分)Vue.js とよく似ています。
Blazor の内部動作は Razor なので(ASP.NET の Razor)、SPA内部でのデータ構造は自由になります。動作的には MVVM パターンな View との双方向バインドになっていますが、たまに StateHasChanged を呼び出して画面を更新しないといけないのが、いまいちイケていないところです。
通常の Blazor の場合
Web API を呼び出して、その結果を表示させる場合、Blazor ではこんな感じになります。
@page "/normal"
@inject HttpClient Http
<h3>Normal Sample</h3>
<button class="btn btn-primary" @onclick="save">Click me</button>
<div>
@Result
</div>
@code {
private string Result { get; set; }
private async void save()
{
// Web API 呼び出し
var url = "http://localhost:8000/api/project";
var res = await Http.GetAsync(url);
// 結果を mutations へ commit する
this.Result = await res.Content.ReadAsStringAsync();
this.StateHasChanged();
}
}
Vue.js ならば data で result で保持するところを、Blazor ではプロパティ(実はフィールドでも良い)で持ちます。@code な部分は、Vue.js で言えば script なところです。Blazor では暗黙的に Blazor コンポーネントのクラスに割り当てられるので、このコードは、Normal クラスの内部メソッドやプロパティになります。
「Click me」なボタンを押したときに、save メソッドに割り当てる部分も、Vue.js と同じです。
Blazor の元ネタである Razor の場合(それ以前でいれば Web フォーム)も、こんな形でメソッドに割り当てることができます。Web フォームの場合は、JavaScript を駆使して該当するメソッド(実際はサーバーで動作する)に割り当てていたのですが、Blazor の場合はブラウザ上で動作する wasm 内で動くので完全にクライアントサイドで動作する save メソッドになります。
StateHasChanged メソッドは、仮想 DOM が更新されたのでレンダラーを動かす印なのですが、これが必要な場合と必要がない場合があります。大抵の場合は大丈夫なのですが、HttpClient で非同期に動いたときは必要っぽいです。
この部分は MVVM パターンで言えば OnPropertyChanged なのですが、Blazor の StateHasChanged はコンポーネント単位(この場合は、Normal.razor)への通知になるので、微妙なところです。たくさんのプロパティがあるときに、StateHasChanged により動作が遅くなるのかどうかは不明です。ちなみに、SVG でライフゲームを作ったとき 100 x 100 のプロパティを変更させていますが、それほど重くはありません。1万個のプロパティがあっても大丈夫なので、大抵の場合はスピード的に問題はないのでしょう。
動作させるとこんな感じになります。
Vuex っぽく書き替える
これを Vuex っぽく書き直してみます。Blazor 上のいくつかの Flux 実装をみると、dispatch や commit などの名前を使っていますが、名前自体に意味はない(アーキテクチャなので)ので C# のプロパティとメソッドを使って構築します。実は Vuex の書き方って Command パターンの悪癖の典型ような気がするので、これは型付の言語にはなじまないですよね。Blazor の場合は、コンパイルが間に挟まるので、プロパティ名やメソッド名にきちんと意味を持たせた方が、開発的に安全です。
namespace FluxBlazor
{
public class Store
{
// state:
private string _result = "";
public string Result
{
// getters
get => _result;
// mutations
set
{
_result = value;
OnStateHasChanged?.Invoke();
}
}
public event Action OnStateHasChanged;
HttpClient Http = new HttpClient();
// actions:
public Task Save()
{
var task = new Task(async () =>
{
// Web API 呼び出し
var url = "http://localhost:8000/api/project";
var res = await Http.GetAsync(url);
// 結果を mutations へ commit する
this.Result = await res.Content.ReadAsStringAsync();
});
task.Start();
return task;
}
}
}
Store.cs という新しいクラスを作って、そこに Flux の各種の値とメソッドを書いていきます。
- state は、外部から見えないようにするため、private で定義
- getters は、プロパティの get を使う
- mutations は、別のメソッドにしようと思ったけど、意味としてはプロパティと set と同じなので、ここで定義する
- actions は非同期処理になるので、常に Task を返す。
Vue.js の場合は、mutate による state の変化が、そのまま仮想DOMに伝わります。
しかし、Blazor の場合は、いちいち仮想DOMを更新しないといけないので、OnStateHasChanged イベントを発生させて、その中で StateHasChanged を呼び出して貰います。StateHasChanged の処理自体は、コンポーネントに紐づいているので、現在描画しているコンポーネントの StateHasChanged メソッドを呼び出す必要があるのです。
これ App クラス内に作って呼び出せば大丈夫かと思ったけど、駄目でした。あくまで、コンポーネント単位で StateHasChanged を呼ばないと駄目みたい。
<Router AppAssembly="@typeof(Program).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
</Found>
<NotFound>
<LayoutView Layout="@typeof(MainLayout)">
<p>Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code {
public static Store store { get; set; } = new Store();
}
Store の実体(オブジェクト)を何処におくか悩んだのですが、App.razor に書きます。Vuex の場合は、App.vue 内になるので、ちょうどそれと同じです。
Store オブジェクトは、どのコンポーネントからも参照ができるように static にしておきます。これで、App.store として参照できます。Vuex の場合は this.$store になるので、これも同じ感じです。
Flux.razor を新しく作って、Store クラスにある Web API を呼び出すようにします。
@page "/flux"
<h1>flux blazor sample</h1>
<button class="btn btn-primary" @onclick="save">Click me</button>
<div>
@Result
</div>
@code {
private string Result => App.store.Result;
protected override void OnInitialized()
{
base.OnInitialized();
// 変更イベントを受け取る
App.store.OnStateHasChanged += () => { this.StateHasChanged(); };
}
private void save()
{
Console.WriteLine("click save");
// dispatch
App.store.Save();
}
}
Web API の結果を表示する Result プロパティは、App.store.Result 自身を返します。これは、ちょうど Vue.js で算出プロパティを使って Vuex の getters を呼び出すところと同じです。
画面の初期化時には、OnStateHasChanged イベント内で StateHasChanged メソッドを呼び出すようにします。これで、Store 内の mutations の更新で自動で画面が更新されます。
ボタンを押したときの save メソッドでは、Store クラスの Save メソッドを呼び出します。これは Vuex の dispatch にあたる処理です。
これを実行してみると、Normal.razor と同じ動作になります。
まるで MVVM パターンのようだ
実際に書いてみるとわかりますが、実質 MVVM パターンと変わりません。Flux の場合は、state に対する読み取り(getters)と書き込み(mutations)を分けていますが、C# の場合はプロパティの get/set で十分です。引数がある場合は、別途メソッドを作ることになるでしょうが、値の出し入れだけなればプロパティで十分です。
MVVM と Flux/Vuex の大きな違いは、actions のところでしょう。
最初のノーマルな Blazor のコードの場合は、Web API 呼び出しの後に await を使ってレスポンスを取得、そして取得結果を表示しています。
Flux 風の場合は、非同期な save メソッドを用意しておいて、そこで Task オブジェクトを作って呼び出しています。Flux 風の場合は、Save メソッドを呼び出した直後では Reuslt の値は変更されません。あくまで、Save メソッドは Web API を呼び出すだけで、結果待ちはしていないのです。これは複数の Web API を actions 内に記述したときも同じです。このため Web API のレスポンスを受けて Result プロパティが変更されるタイミングを知るためには、通常の MVVM パターンとは違った方法が必要です。Save メソッドの戻り値ではないので、Vuex の then にあたる ContinueWith でメソッドチェーンをするか、await で同期待ちをします。
Blazor の場合、まだ始まったばかりなので、データの保持が MVVM がよいのか Flux がよいのか判然としませんが、Blazor のコンポーネント単位で ViewModel を持たせるよりも、App クラスに一括して持って持っておいたほうが画面遷移があるときは便利かもしれません。デスクトップアプリの WPF や UWP の場合でもたびたび画面遷移で ViewModel をどう引き継ぐのかというのが問題になるので、似たような議論は Blazor 界隈でおこるでしょう。
ちなみに、デスクトップアプリの ViewModel の取り回しは、子の ViewModel を作るときに、親の ViewModel を引き渡してしまうのが簡単です。
ViewModel _vm ;
var v = new ChildView( _vm );
v.ShowDialog();
こんな感じで、子ウィンドウを開くときに、自分の ViewModel を渡してしまいます。このようにすると、子のダイアログ内で親の変数を扱うときにも便利なので(親画面で表示しているリストの変更やラベルの変更など)、定型的に使うとよいです。