今回は、商品一覧画面を非同期で読み込むように変更していきます。
現在の実装では、商品データの取得処理が一瞬で終わってしまうため、あたかも同期処理のように見えています。しかし実際のアプリケーションでは、データベースへのアクセスなどに時間がかかることが多いため、ここでは「データ取得に3秒かかる」という想定で処理を非同期化していきます。
図1 初期状態の商品一覧画面。3件のデータが即座に表示される
OnInitializedAsyncへの変更
まずは元となるコードを確認します。private List<ProductEntity> _products フィールドに対して、OnInitializedメソッドの中で3件のProductEntityをAddしているだけの、ごくシンプな実装です。
図2 変更前のコード。OnInitializedの中で_productsに3件のデータをAddしている
このOnInitializedを、非同期版であるOnInitializedAsyncに変更します。あわせてasyncキーワードを付与し、戻り値をTaskにします。中身の先頭にawait Task.Delay(3000)を追加し、実際のデータ取得に3秒かかることを疑似的に再現します。
図3 OnInitializedAsyncに変更し、await Task.Delay(3000)でデータ取得の遅延を再現
この状態でいったん実行してみます。Home画面から「商品一覧」メニューをクリックすると、アドレスバーは/productに変わっているにもかかわらず、画面はしばらくHomeの内容(Hello, world!)のまま止まってしまいます。
図4 商品一覧をクリックした直後。URLは/productに変わっているが、画面はまだHomeのまま
3秒待たされたあと、ようやく商品一覧のテーブルがまとめてポンと表示されます。
図5 3秒後、商品一覧のテーブルが一度に表示される
一見するとデータが非同期で取得できているように感じますが、実際には画面遷移そのものが3秒間ブロックされているだけであり、見た目上は同期処理と変わらない動きになっています。
@attribute [StreamRendering] を追加する
なぜ非同期で動作しないのか。その理由を確認するために、サンプルとして最初から用意されているWeather.razorを開いてみます。Weather.razorも同じような作りになっていますが、ファイルの一番上に @attribute [StreamRendering] という記述が入っています。
図6 Weather.razorの先頭にある@attribute [StreamRendering]
これは、非同期処理の途中でUIを更新(ストリーミングレンダリング)したい場合に必要な指定です。この記述がないと、コンポーネントの初期化処理がすべて完了するまで画面の描画が行われません。同じ内容をProductPage.razorの先頭にも追加します。
図7 ProductPage.razorの先頭に@attribute [StreamRendering]を追加
この状態であらためて実行し、商品一覧をクリックしてみます。今度はクリックした直後にページの見出しやテーブルのヘッダー部分だけが先に表示され、
図8 商品一覧クリック直後。見出しとテーブルの見出し行だけが先に表示される
その3秒後に、テーブルの中身(各行のデータ)だけが後から表示されるようになりました。これで、画面遷移そのものはブロックされずに非同期で動作するようになったことが確認できます。
図9 3秒後、テーブルの行データだけが後から表示される
データ取得中であることをHTML側で表現する
非同期化はできましたが、このままでは最初にテーブルが0件の状態で表示されてしまうため、見た目上「本当にデータが0件なのか、まだ取得中なのか」が分からないという問題があります。そこで、@ifを使ってHTML側の表示を切り替えるようにします。
まず_productsフィールドの初期値を、いきなりnewするのではなくnullにしておきます。そのうえで、OnInitializedAsyncの中でawait Task.Delay(3000)のあとに_products = new List<ProductEntity>()を実行し、そこに3件のデータをAddする、という流れに変更します。こうすることで、「データ取得が終わるまでは_productsがnullである」という状態を作ることができます。
図10 _productsの初期値をnullにし、OnInitializedAsyncの中でnew Listしてから件数を追加する
次に、テーブルを表示している部分のHTMLを@ifで囲みます。_productsがnullの場合は「データ取得中です」という案内のpタグを表示し、そうでない場合は既存の table要素をそのままelseの中に入れます。
図11 テーブル表示部分を@ifで囲む前のHTML
この時点でいったん実行し、「データが0件」の場合の見え方も確認しておきます。テストのため一時的にProductEntityをAddする3行をコメントアウトすると、「データ取得中です」の表示のあと、テーブルの見出し行だけが表示されて中身が0件になり、これが本当に0件なのか読み取りづらい表示になってしまうことが分かります。
そこで、_products.Count == 0のケースも別途判定できるように、else ifを追加します。
図12 else if (_products.Count == 0) を追加している途中。IntelliSenseでCountの候補が表示されている
_productsがnullなら「データ取得中です」、_products.Countが0なら「データなし」、それ以外(データが1件以上ある場合)は従来どおりtableを表示する、という3分岐が完成しました。
図13 完成した@if / else if / else の3分岐
実行して商品一覧を開くと、まず「データ取得中…」と表示され、
図14 実行直後は「データ取得中…」と表示される
Addの3行をコメントアウトしたまま(データ0件の状態)で3秒待つと、今度は「データなし」ときちんと表示されるようになりました。
図15 データが0件の場合は「データなし」と表示される
コメントアウトを元に戻し、データが3件ある状態で実行すると、「データ取得中…」を経て、最終的にこれまでどおりの商品一覧テーブルが表示されます。
図16 データが取得できた場合は、最終的に通常どおりテーブルが表示される
描画のタイミングについて
なお、描画のタイミングについて補足しておきます。_productsの値が変わるたびに画面が何度も再描画されるわけではなく、OnInitializedAsyncを抜けたタイミングで1回だけ描画が走る、という動きになっています。最初にOnInitializedAsyncに入った時点(_productsがnullの状態)で1回描画され、「データ取得中です」が表示されます。その後、await Task.Delay(3000)を経てOnInitializedAsyncを抜けたタイミングでもう1回描画が走り、その時点で_productsの状態(null/0件/データあり)が再度チェックされて、対応する表示に切り替わる、という仕組みです。
まとめ
データベースへのアクセスなど時間のかかる処理は、何秒かかるか分からないため、基本的には非同期で実装するようにします。起動時に実行する処理はOnInitializedAsyncを使い、@attribute [StreamRendering]を付けることで画面遷移そのものをブロックせずに済みます。あわせて、データ取得中・0件・データありの3つの状態をHTML側で@ifを使って出し分けておくと、ユーザーにとって分かりやすい画面になります。
最終的なコード(@code部分)
@code {
private List<ProductEntity> _products = null;
protected override async Task OnInitializedAsync()
{
await Task.Delay(3000);
_products = new List<ProductEntity>();
_products.Add(new ProductEntity(100, "p100", 1000));
_products.Add(new ProductEntity(200, "p200", 2000));
_products.Add(new ProductEntity(300, "p300", 3000));
}
}
最終的なコード(HTML部分)
@page "/product"
@attribute [StreamRendering]
@using Ander99.Objects
<h3>商品一覧</h3>
@if (_products == null)
{
<p>データ取得中...</p>
}
else if (_products.Count == 0)
{
<p>データなし</p>
}
else
{
<table class="table">
<thead>
<tr>
<th>商品ID</th>
<th>商品名</th>
<th>価格</th>
</tr>
</thead>
<tbody>
@foreach (var p in _products)
{
<tr>
<td>@p.ProductId</td>
<td>@p.ProductName</td>
<td>@p.Price</td>
</tr>
}
</tbody>
</table>
}
A01_はじめに
A02_バージョンの確認
A03_プロジェクトの作成
A04_アプリの実行
A05_サンプルプログラムの構成
A06_Razorコンポーネント
A07_Razor構文
A08_@pageでURLを指定する
A09_@rendermode
A10_PageTitle
A11_HTMLの見出し
A12_pタグ
A13_単方向データバインド
A14_単方向データバインドの詳細
A15_Bootstrap
A16_ボタンクリックイベント
A17_イベント引数の受け取り方
A18_ここまでのまとめ
B01_新しいWebページを追加する方法
B02_サイドバーから画面を表示する
B03_サイドバーのアイコンを変更する方法
B04_計測値を表示する
B05_変数に置き換える
B06_最新の値でデータを更新する
B07_@usingで通常のC#コードを呼び出す
B08_画面遷移_aタグ
B09_画面遷移_aタグの見た目をボタンにする
B10_画面遷移_NavigationManagerでの遷移
B11_画面間のパラメータの受け渡し方法
B12_EscapeDataStringでエスケープする
B13_複数のパラメータを渡す
B14_任意のパラメータにする
B15_string以外の型をパラメータで使う方法
B16_DateTime型をパラメータで渡す方法
B17_引数2個のURLも型指定に変更する
B18_パラメータ属性をつけるときの注意点
B19_OnParametersSet
B20_OnInitialized
B21_OnInitializedが2回通る問題の対応
B22_ブラウザが開いている間の値を記憶する方法
B23_値の受け渡しとしてのProtectedSessionStorage
B24_非同期を想定したUIの制御
C01_一覧表示
C02_非同期での一覧表示
C03_table-striped
D01_入力エリア
D02_双方向データバインド
D03_入力データをリアルタイムで通知する方法
D04_placeholder
D05_入力エリアの横幅の調整
D06_テキストチェンジイベントを拾う方法
D07_プルダウン
D08_プルダウンのbootstrap
D09_プルダウンの選択している値をデータバインドで操作する
D10_プルダウンにリストの値を表示する
D11_プルダウンに未選択行を入れる
D12_プルダウンの入力を禁止する
D13_プルダウンのチェンジイベントを取得する
D14_チェックボックス
D15_チェックボックスにラベルを付ける
D16_複数のチェックボックスを並べる
D17_ラジオボタン
D18_ラジオボタンのデータバインド
D19_ラジオボタンのチェンジイベント
D20_ラジオボタンにbootstrapを適応する