概要
.NET 5の正式版がリリースされ、Blazorも新機能が追加されました。
WebAssemblyがGAされてからしばらくBlazorから遠のいていましたが、リハビリを兼ねて新機能に関して簡単なサンプルを見ながら説明していこうと思います。
MSの公式ドキュメントに書いてある事をなぞっている点が多いとは思いますが、合わせて読むことで理解の助けになればと思います。
大した内容ではありませんが、下記にデモとソースを公開しています。
今回、紹介する項目は下記になります。
- CSSの分離
- JavaScriptの分離とオブジェクト参照
- InputRadioおよびInputRadioGroupコンポーネント
- InputFileコンポーネント
- コンポーネントの仮想化
- UIフォーカスの設定
- アセンブリの遅延読み込み
※基本的にWebAssemblyの視点での話になります。
各項目説明
1.CSSの分離
VueやReact、AngularといったSPAフレームワークではコンポーネント単位でCSSを適応できる機能が提供されていますが、Blazorでも対応できるようになりました。
コンポーネントの範囲にスタイル適応を限定できるので、下記のようなメリットがあるかと思います。
- スタイル適応の影響範囲の極小化
- 管理しやすい(razorとCSSをペアで管理)
実現方法
razorコンポーネントと同名のcssファイルを作るだけで対応できます。
(Test.razorというコンポーネントであれば、Test.razor.cssというファイルを作成。)
Visual Studioだと下記のようにネストして表示してくれるのでわかりやすいですね。
あとは、cssに通常のスタイルシート同様に記載するだけです。
下記ではh1タグのフォント色を赤色にしています。
<h1>Parent H1</h1>
h1 {
color: red;
}
このコンポーネントをページに配置してみます。
ページ内にはコンポーネントと同じく、h1タグがあります。
<h1>Page H1</h1>
<Parent></Parent>
表示させるとコンポーネントのh1のみにフォント色が適応されている事が確認できますね。
子コンポーネントへの適応
コンポーネント内でさらに別コンポーネントを使用している場合に、そのコンポーネントに対しても同一のスタイルを適応させることができます。
下記のようなChildといったコンポーネントと、それを使用するParentというコンポーネントがある場合、通常ではParentのスタイルはChildには適応されません。
<h1>Child H1</h1>
<h2>Child H2</h2>
<!-- ルートにdivタグを定義している理由は後述 -->
<div>
<h1>Parent H1</h1>
<h2>Parent H2</h2>
<Child />
</div>
この場合、CSSにdeepといった連結子を付与することで子コンポーネントに対しても有効なスタイルとなります。
h1 {
color: red;
}
::deep h2 {
color: blue;
}
h2タグだけ子コンポーネントにもスタイルが適応されていることがわかります。
注意点としてdeep連結子は、ルート要素に対しては効果が無いのでルート要素にならないようにする必要があります。上記の例ですと、Parent.razorに対してdivタグを定義して、h2タグおよびChildコンポーネントがルートにならないようにしています。
ちなみに、これらのコンポーネントに記載されたCSSはビルド時に1つのCSSファイルにまとめられて、index.htmlに記載された(Project名).style.cssといった形で読み込まれていますのでこの参照を外さないように注意が必要です。
<head>
...
<link href="<ProjectName>.styles.css" rel="stylesheet" />
</head>
2.JavaScriptの分離とオブジェクト参照
これまでBlazorからJavascirptの処理を呼び出しを行うにはindex.htmlに使用するjsファイルの参照の追加やグローバルなwindowオブジェクトへの関数等の参照登録が必要でした。
ですが、動的にjsファイルのロード機能を使用することでグローバル名前空間の汚染やjsファイルのインポートが不要になります。
実現方法
まずは、使用したい関数をexportしているjsファイルを作成します。
下記はalert関数で引数に渡されたメッセージを表示する処理となっています。
export function displayAlert(message) {
alert(message);
}
jsファイルはwwwroot以下の任意の場所に配置します。
今回は wwwroot/js/displayAlert.js に配置するとします。
あとはC#コード側で下記を実行します。
- IJSRuntime.InvokeAsync("import", "JSのパス");でモジュールを呼び出し
- モジュールのInvvokeAsync(InvokeVoidAsync)でメソッド名と引数を渡す
@inject IJSRuntime js;
@code {
string message = "hello";
async void ShowAlertAsync()
{
var module = await js.InvokeAsync<IJSObjectReference>("import", "./js/displayAlert.js");
await module.InvokeVoidAsync("displayAlert", message);
}
}
JSのパスはBlazorアプリのPJの場合は上記の通り、wwwrootからのパスとなりますがライブラリの場合には、
_content/{ライブラリ名}/{wwwrootからのパス}
となるようなので注意が必要です。
下図のようにバインドした値を渡して呼び出すことができました。
JSがファイルの配置だけで呼び出せるようになったのはかなり嬉しいですね!
3.InputRadioおよびInputRadioGroupコンポーネント
RadioButton及びRadioButtonグループ用のコンポーネントが追加されました。
Enumのデータをバインドして双方向バインディングがお手軽に実装できます。
まず、下記のようなデータクラスを定義します。
public enum Country { Japan, China, America, Brazil }
public enum FoodType { Rice, Bread, Meat, Vegetable }
public enum DrinkType { Water, Tea, Coffee }
public class InputData
{
[Required, EnumDataType(typeof(Country))]
public Country? SelectedCountry { get; set; } = null;
[Required, EnumDataType(typeof(DrinkType))]
public DrinkType? SelectedDrinkType { get; set; } = null;
[Required, EnumDataType(typeof(FoodType))]
public FoodType? SelectedFoodType { get; set; } = null;
}
InputRadioGroup内にInputRadioを記載するだけで使用可能です。
<EditForm Model="@InputData">
<DataAnnotationsValidator />
<ValidationSummary />
<p>
<InputRadioGroup @bind-Value="InputData.SelectedCountry">
Country:
<br>
@foreach (var country in Enum.GetValues(typeof(Country)))
{
<InputRadio Value="country" />
@country
}
</InputRadioGroup>
</p>
@if (InputData.SelectedCountry != null)
{
<p>@InputData.SelectedCountry.ToString() is selected</p>
}
else
{
<p>Not Selected Country</p>
}
<button type="submit">Submit</button>
</EditForm>
@code {
public InputData InputData = new InputData();
}
InputRadioGroupのNameとInputRadioのNameで一致させることでネストしたグループ内でグルーピングすることができます。
<EditForm Model="@InputData">
<DataAnnotationsValidator />
<ValidationSummary />
<p>
<InputRadioGroup @bind-Value="InputData.SelectedFoodType" Name="food">
<InputRadioGroup @bind-Value="InputData.SelectedDrinkType" Name="drink">
<InputRadio Value="FoodType.Rice" Name="food" /> Rice
<InputRadio Value="FoodType.Bread" Name="food" /> Bread
<InputRadio Value="FoodType.Meat" Name="food" /> Mead
<InputRadio Value="FoodType.Vegetable" Name="food" /> Vegetable
<InputRadio Value="DrinkType.Tea" Name="drink" /> Tea
<InputRadio Value="DrinkType.Coffee" Name="drink" /> Coffee
<InputRadio Value="DrinkType.Water" Name="drink" /> Water
</InputRadioGroup>
</InputRadioGroup>
</p>
<button type="submit">Submit</button>
</EditForm>
@code {
public InputData InputData = new InputData();
}
4.InputFileコンポーネント
inputタグによるファイルのアップロード処理がJavascriptのFile APIを直接使用しなくても実現可能なInputFileコンポーネントが追加されました。
ファイルのアップロード
下記のようなファイルを選択すると画面にファイル情報を表示するような機能を作成してみます。
<InputFile OnChange="LoadFiles" multiple />
<br />
<span>@errorMessage</span>
@if (isLoading)
{
<p>Loading...</p>
<br />
}
@foreach (var file in loadedFiles)
{
<p id="file-@(file.FileName)">
<strong>Name:</strong> <span id="file-name">@(file.FileName)</span><br />
<strong>Last modified:</strong> <span id="file-last-modified">@(file.LastModified.ToString())</span><br />
<strong>Size (bytes):</strong> <span id="file-size">@(file.Size)</span><br />
<strong>Content type:</strong> <span id="file-content-type">@(file.ContentType)</span><br />
</p>
}
@code {
List<UploadFile> loadedFiles = new List<UploadFile>();
bool isLoading;
string errorMessage;
async Task LoadFiles(InputFileChangeEventArgs e)
{
isLoading = true;
loadedFiles.Clear();
errorMessage = string.Empty;
try
{
foreach (var file in e.GetMultipleFiles(3))
{
StateHasChanged();
var buffers = new byte[file.Size];
await file.OpenReadStream().ReadAsync(buffers);
var uploadFile = new UploadFile()
{
FileName = file.Name,
ContentType = file.ContentType,
Size = file.Size,
LastModified = file.LastModified,
Content = buffers
};
loadedFiles.Add(uploadFile);
}
}
catch (Exception ex)
{
errorMessage = ex.Message;
}
finally
{
isLoading = false;
}
}
public class UploadFile
{
public string FileName { get; set; }
public byte[] Content { get; set; }
public DateTimeOffset LastModified { get; set; }
public string ContentType { get; set; }
public long Size { get; set; }
}
}
まず、InputFileタグにはmultipleという属性を付与することで複数のファイルを選択可能としています。
(通常のinputタグにmultipleファイルを付与するのと同じ。)
次に、OnChangeにInputFileChangeEventArgsを引数とするメソッドをバインドします。
InputFileChangeEventArgsには、GetMultipleFilesといったメソッドがあり、選択したファイルから何個まで情報を取得するかを指定して、予想外の大量のファイルの選択された場合に不用意に処理を実行しない対応が可能です。
GetMultipleFiles取り出した情報はIBrowserFileという型となっていて、ファイル名やファイルサイズなどの情報を持っています。
サーバー側にファイルをアップロードしたい際には下記のような感じでJson化して送れば良いかと思います。
async Task SubmitAsync()
{
var data = new UploadData() { UploadFiles = loadedFiles };
await Http.PostAsJsonAsync<UploadData>("サーバの宛先URL", data);
}
public class UploadData
{
public List<UploadFile> UploadFiles { get; set; }
}
こんな感じのJsonがPOSTされるので後はサーバ側で処理するだけですね。
画像のプレビュー表示
次に下図のようなアップロードした画像をサムネイルとしてプレビューできる例を見ていきます。
やっていることは先ほどのファイルアップロードとほぼ同じで、変更点としては下記になります。
1.RequestImageFileAsyncメソッドで画像をリサイズ
2.DataUrlに変換してブラウザ上に表示
<div class="card" style="width:30rem;">
<div class="card-body">
@foreach (var imageDataUrl in imageDataUrls)
{
<img class="rounded m-1" src="@imageDataUrl" />
}
</div>
</div>
@code {
private List<string> imageDataUrls = new List<string>();
private async Task OnInputFileChange(InputFileChangeEventArgs e)
{
var maxAllowedFiles = 3;
var format = "image/png";
foreach (var imageFile in e.GetMultipleFiles(maxAllowedFiles))
{
// 100*100の画像にリサイズ
var resizedImageFile = await imageFile.RequestImageFileAsync(format, 100, 100);
var buffer = new byte[resizedImageFile.Size];
await resizedImageFile.OpenReadStream().ReadAsync(buffer);
// ブラウザ上に表示するためにDataUrlに変換
var imageDataUrl =
$"data:{format};base64,{Convert.ToBase64String(buffer)}";
imageDataUrls.Add(imageDataUrl);
}
}
}
これまでJavascritp側でゴリゴリ実装が必要だった部分がC#で完結しているのと、画像のリサイズ機能まで提供されているのはうれしいですね。
5.コンポーネントの仮想化
現在表示されている部分のみを描画する機能が提供されました。
コレクションなどで大量の要素を描画する際に表示部分だけを描画するようになるのでUIのパフォーマンスの向上が期待できます。
使い方は簡単で、foreachで列挙する代わりに、Virtualizeタグにコレクションをバインディングするだけです。
Contextにコレクションの要素のクラス名、Itemsにコレクションを指定します。
<!-- 仮想化あり -->
<p>Virtualization</p>
<Virtualize Context="Person" Items="@People">
<p>
name: @Person.Name , age: @Person.Age
</p>
</Virtualize>
<!-- 仮想化なし -->
<p>Non Virtualization</p>
@foreach (var person in People)
{
<p>
name: @person.Name , age: @person.Age
</p>
}
@code {
public List<Person> People = Enumerable.Range(1, 30000).Select(x => new Person(x.ToString(), x % 60)).ToList();
public class Person
{
public Person(string name, int age)
{
Name = name;
Age = age;
}
public string Name { get; }
public int Age { get; }
}
}
下図の通り、仮想化している場合には大量のコレクションを表示させてもすぐ表示されますが、非仮想化の場合には表示・非表示の切り替えを行う際に画面が一定時間フリーズします。
項目プロバイダーによる非同期読み込み
一般的なユースケースであれば大量のコレクションを読み込む必要がある時には、REST APIなど外部のIFから適宜必要な分だけを部分読み込みをすると思います。
Virtualizeタグではこのような機能を実現するために項目プロバイダといった機能が提供されています。
まず下記のような、ItemsProviderRequestを引数として、ValueTaskを戻り値とするメソッドを定義します。
// REST APIなどからデータを取得するクラス
private DummyPersonService _service = new DummyPersonService();
// 取得するデータの総数
private int? totalCount;
private async ValueTask<ItemsProviderResult<Person>> LoadPeople(ItemsProviderRequest request)
{
// ロードするデータの総数を取得
if (totalCount == null)
{
totalCount = await _service.GetTotalCountAsync();
}
// 開始位置と(StartIndex)データ数(Count)を指定して部分的にデータを取得
var people = await _service.GetPeopleAsync(request.StartIndex, request.Count);
// 取得したデータ数とデータ総数を渡す
return new ItemsProviderResult<Person>(people, totalCount ??= 0);
}
引数のItemsProviderRequestにはStartIndexとCountというプロパティが定義されていて、Virtualizationタグが必要としているデータの開始位置とデータ数が渡されてきます。
開始位置とデータ数を外部のAPIに渡すことでデータを取得して、その戻り値を返します。
よって呼び出し側のAPIにはインデックスによるページネーション機能が無いと使用が厳しいかもしれません。
合わせて、データの総数を取得しておく必要もあり、戻り値としてItemsProviderRequestに部分取得したデータと総データ数を渡します。
あとはVirtualizeタグのItemsProviderに上記のメソッドをバインドします。
ItemContent内に表示する内容、Placeholderにデータ読み込み中に表示する内容を記載します。
ItemSizeが一度のロードで読み込むデータ数、OverscanCountが前後の非表示の領域を事前読み込みする数として調整ができます。
指定しない場合にはデフォルト値(25と3)で実行されます。
<Virtualize Context="Person" ItemsProvider="@LoadPeople" ItemSize="25" OverscanCount="4">
<ItemContent>
<p>
name: @Person.Name , age: @Person.Age
</p>
</ItemContent>
<Placeholder>
<p>
Loading...
</p>
</Placeholder>
</Virtualize>
こうすると下図のようにスクロール位置に合わせていい感じにデータを読み込んでくれます。
初回の読み込みにはPlaceHolderが適応されないので自前でロード表示する実装と、戻った場合にも再読み込みされるのでAPI呼び出し前の層にキャッシュする層を入れるなどの工夫が必要かもしれませんね。
6.UIフォーカスの設定
C#コードだけでフォーカスの設定が可能になりました。
あまり意識することが無かったのです、今まではフォーカスを特定の要素に移動させたい場合はJavascriptを呼び出すしかなかったようです。
使い方は簡単でrefで要素の参照を定義して、FocustAsyncメソッドを呼び出すだけです。
<input @ref="textInput" />
<button @onclick="SetFocusToTextInput">Set focus</button>
@code {
private ElementReference textInput;
private async Task SetFocusToTextInput()
{
await textInput.FocusAsync();
}
}
7.アセンブリの遅延読み込み
Blazorアプリ起動時に全てのアセンブリを読み込むのではなく必要時に後から読み込む、遅延読み込みが可能になりました。
遅延読み込みを行うことで必要最低限のアセンブリのみを読み込んで、アプリの起動の高速化が期待できます。
ログイン後の画面など、特定のユーザしか使用しない機能などで使うと良いかもしれませんね。
まず、下記のようにBlazorアプリのcsprojに遅延読み込みしたいDLLをBlazorWebAssemblyLazyLoadという項目名で指定します。
Project参照や既存のDLL参照はそのまま残した上で、BlazorWebAssemblyLazyLoadを追加します。
(PackageReferenceやProjectReferenceと合わせて2個記載する。)
<!-- 自分のプロジェクト参照 -->
<ItemGroup>
<ProjectReference Include="..\LazyLoadModule\LazyLoadModule.csproj" />
</ItemGroup>
<!-- 自分のプロジェクトのDLL -->
<ItemGroup>
<BlazorWebAssemblyLazyLoad Include="LazyLoadModule.dll" />
</ItemGroup>
App.razorに記載されたRouterコンポーネントを使用します。
AdditionalAssembliesという属性をバインドしてこの値に遅延読み込みしたアセンブリを追加します。
後は、下記の流れで処理を行います。
1.OnNavigateAsyncでページ遷移時にURLをチェックして動的にアセンブリを読み込むかどうか判断
2.LazyAssemblyLoader.LoadAssembliesAsyncでアセンブリを指定して読み込む
3.読み込んだアセンブリをAdditionalAssembliesに追加する
なお、Navigatingタグを使用することで、アセンブリ読み込み中の待ち時間に表示する要素を定義できます。
@using System.Reflection
@using Microsoft.AspNetCore.Components.Routing
@using Microsoft.AspNetCore.Components.WebAssembly.Services
@inject LazyAssemblyLoader LazyAssemblyLoader
<Router AppAssembly="@typeof(Program).Assembly" AdditionalAssemblies="@lazyLoadedAssemblies" OnNavigateAsync="@OnNavigateAsync">
<Navigating>
<div>
<p>Loading modules...</p>
</div>
</Navigating>
<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 {
const string ModuleName = "LazyLoadModule.dll";
List<Assembly> lazyLoadedAssemblies = new List<Assembly>();
async Task OnNavigateAsync(NavigationContext args)
{
if (args.Path == "lazy" && !lazyLoadedAssemblies.Any(x => x.GetName().Name + ".dll" == ModuleName))
{
var assemblies = await LazyAssemblyLoader.LoadAssembliesAsync(new string[] { ModuleName });
lazyLoadedAssemblies.AddRange(assemblies);
}
}
}
サンプルだと毎回モジュールを読み込んでいたのですが、lazyLoadedAssembliesの中身をチェックして一度読み込んだモジュールは再度読み込まないといった事もできましたが、効果のほどは不明です。
その他の機能
これまで紹介した機能以外にも追加された機能が多々あります。
BlazorServer系の機能を中心に有用な機能がありますので下記を参照してみてください。
まとめ
.NET 5で追加されたBlazorの新機能の一部をコード例と合わせて簡単に説明してみました。
説明だけではなく自分でコードを書くことでわかる事もあり、大変でしたがやってみて良かったです。
新機能でJavascriptからの依存が低減し、C#コードだけで完結できるスコープが広がった点や、SPAフレームワークとして欲しい機能が拡張されるなど着実に進化しています。
今後の機能追加に関しても期待したいですね!