vaguely

和歌山に戻りました。ふらふらと色々なものに手を出す毎日。

【Blazor Server】ここんぽーねんとで遊びたい

はじめに

この記事は Blazor Advent Calendar 2020 の22日目の記事です。

今回は子コンポーネントを追加してみることにします。 環境、ベースとなるプロジェクトは下記を参照してください。

コンポーネントを追加する

コンポーネントを追加する、といっても特別な何かがあるわけではなく、ただ .razor ファイル(必要に応じて + .razor.cs 、.razor.css)を作成し、HTMLタグのように親コンポーネントに追加するだけです。

Cell.razor

<div class="cell_frame">Hello cell</div>

Cell.razor.cs

using Microsoft.AspNetCore.Components;

namespace BlazorSample.Views.Components
{
    public partial class Cell
    {
    }
}

Cell.razor.css

.cell_frame{
    background-color: gray;
    border: 1px solid black;
    color: goldenrod;
}

DisplayGridPage.razor

@page "/"

<h1>Hello, world!</h1>
Welcome to your new app.
<div id="sheet_area">
    @for(var i = 1; i <= 3; i++){
        <BlazorSample.Views.Components.Cell></BlazorSample.Views.Components.Cell>
    }
</div>

結果

f:id:mslGt:20201222060717j:plain

コンポーネントCSS

DisplayGridPage.razor を表示したとき、BlazorSample.styles.css の中身は親?となる MainLayout.razor.css と DisplayGridPage.razor.css がマージされたものとなっていましたが、子どもとなる Cell.razor.css はどうでしょうか。

結果としてはそれもマージされます。

BlazorSample.styles.css

/* _content/BlazorSample/Views/Components/Cell.razor.rz.scp.css */
.cell_frame[b-64je09gotq]{
    background-color: gray;
    border: 1px solid black;
    color: goldenrod;
}
/* _content/BlazorSample/Views/Components/SheetSelector.razor.rz.scp.css */
/* _content/BlazorSample/Views/DisplayGridPage.razor.rz.scp.css */
#sheet_area[b-p832tuedyv]
{
    height: 30vh;
    width: 30vw;
    background-color: aqua;
    display: grid;
}
h1[b-p832tuedyv]{
    color: blue;
}
/* _content/BlazorSample/Views/Shared/MainLayout.razor.rz.scp.css */
h1[b-m6a6nzx0h4]{
    color: red;
}
header[b-m6a6nzx0h4]{
    background-color: rosybrown;
}

データのやりとり

Parameter

親から子にデータを渡す場合は Parameter を使います。

Cell.razor.cs

using System;
using Microsoft.AspNetCore.Components;

namespace BlazorSample.Views.Components
{
    public partial class Cell
    {
        [Parameter]
        public int Index { get; set; }
        [Parameter]
        public BlazorSample.Spreadsheets.Cell CellValue{ get; set; }
    }
}

Cell.razor

<div class="cell_frame" id="cell_@Index">Hello cell@(Index)</div>

DisplayGridPage.razor.cs

...
    public partial class DisplayGridPage
    {
        [Parameter]
        public List<BlazorSample.Spreadsheets.Cell> Cells { get; set; }

        public DisplayGridPage()
        {
            Cells = new List<Cell>
            {
                new Cell(1, 1, "Hello"),
                new Cell(1, 2, "World"),
            };
        }
...

DisplayGridPage.razor

...
<div id="sheet_area">
    @code
    {
        int index = 1;
    }
    @foreach (var cell in Cells)
    {
        <BlazorSample.Views.Components.Cell Index="index" CellValue="cell">
        </BlazorSample.Views.Components.Cell>
        index += 1;
    }
</div>
...

いつデータがセットされるか

コンストラクタが実行された時点では Parameter に値がセットされていません。

初期化時点、かつ値がセットされたタイミングで処理を行いたい場合は「OnInitialized()」などを使います。

Cell.razor.cs

...
    public partial class Cell
    {
        [Parameter]
        public int Index { get; set; }
        [Parameter]
        public BlazorSample.Spreadsheets.Cell CellValue{ get; set; }
        public Cell()
        {
            // ここではまだ値がセットされていない.
            // Output: [Constructor] CellValue Index: 0 Cell?:True
            Console.WriteLine($"[Constructor] CellValue Index: {Index} Cell?:{CellValue == null}" );
        }
        protected override void OnInitialized()
        {
            // Output: [Init] CellValue Index: 1 Cell?:False
            Console.WriteLine($"[Init] CellValue Index: {Index} Cell?:{CellValue == null}" );
        }
    }
...

こう見ると(影響を受けているかどうかはともかく) Angular を想起させます。

細かい部分は当然異なるわけですが、 Angular を触ったことのある方であれば、Blazor に慣れてなくとも何となくどんな風に書くかが想像できるのでは、という気がしました。

二回ロードされる?

値セットのタイミングを確認しているときに気づいたのが、ページを開く際、初期化処理が二回動いていることです。

どうやら _Host.cshtml で Blazor を読み込むときに「RenderMode.ServerPrerendered」にしていると、 App.razor が二回初期化されるため以降の処理も二回ずつ実行される、と。

_Host.cshtml

@namespace BlazorSample.Views
@(await Html.RenderComponentAsync<App>(RenderMode.ServerPrerendered))

「RenderMode.Server」に変更すると一度しか呼ばれないため、コンポーネントのプリレンダーが関連している、ということになります。

普段はあまり気にしなくても。。。という気はしますが、ページロード時に DB をいじるとか?の場合は注意が必要そうです。

状態管理

子から親に何らかの状態が変わったことを伝える方法はいくつかあります。

EventCallback

Parameter として EventCallback を設定しておくことで、イベントの発火を伝えることができます。

Cell.razor.cs

...
    public partial class Cell
    {
...
        [Parameter]
        public EventCallback<string> OnCellClicked { get; set; }
        public async Task OnClick()
        {
            await OnCellClicked.InvokeAsync();
        }
    }
...

DisplayGridPage.razor

...
    @foreach (var cell in Cells)
    {
        <BlazorSample.Views.Components.Cell Index="index" CellValue="cell"
            OnCellClicked="@((message) => Console.WriteLine(message))">
        </BlazorSample.Views.Components.Cell>
        index += 1;
    }
...

data-binding

親の持つインスタンスをバインドさせておくことで、子コンポーネント側で値が変更されたときに自動的に反映されるようになります。

Cell.razor.cs

...
    public partial class Cell
    {
...
        [Parameter]
        public BlazorSample.Spreadsheets.BindSample Sample{ get; set; }
        [Parameter]
        public EventCallback<BlazorSample.Spreadsheets.BindSample> SampleChanged { get; set; }
        public void OnClick()
        {
            Sample.Name = $"{Sample.Name} {Index}";
        }
    }
...

DisplayGridPage.razor

...
    <BlazorSample.Views.Components.Cell Index="10" CellValue='new BlazorSample.Spreadsheets.Cell(10, 10, "HelloWorld")'
        OnCellClicked='_ => Console.WriteLine("World")' @bind-Sample="sample">
    </BlazorSample.Views.Components.Cell>
...
<button @onclick="ClickSample">Click</button>
@code
{
    BlazorSample.Spreadsheets.BindSample sample = new BlazorSample.Spreadsheets.BindSample
    {
        Name = "Hello bind sample",
    };

    void ClickSample()
    {
        Console.WriteLine($"BindSample {sample.Name}");
    }
}

条件は「{バインドさせるParameter名}Changed」という EventCallback を作っておくことです(無いとエラーになります)。

Rx で別ルート

ここまでは 親 -> 子 -> 孫 というコンポーネント構造があった場合、 孫 -> 子 、 子 -> 親 と一つずつ状態の変化を伝える、というものでした。 f:id:mslGt:20201222060810p:plain

今度は Angular における NgRX よろしく、状態の管理を別クラスにさせてみることにします。

※雰囲気だけまねているので NgRX の正しい動作・仕様はNgRXのドキュメントなどをご確認ください f:id:mslGt:20201222060926p:plain

WorkSheet.cs

using System.Collections.Generic;
namespace BlazorSample.Spreadsheets
{
    public class WorkSheet
    {
        public string Name { get; init; }
        public List<Cell> Cells { get; init; } = new List<Cell>();
    }
}

WorkSheetStore.cs

using System;
using System.Reactive.Linq;
using System.Reactive.Subjects;
using BlazorSample.Spreadsheets;

namespace BlazorSample.Views.States
{
    public class WorkSheetStore
    {
        public Subject<WorkSheet> Subject { get; } = new();
        public IObservable<WorkSheet> Sheets => Subject.AsObservable();
    }
}

WorkSheetReducer.cs

using System;

namespace BlazorSample.Views.States
{
    public class WorkSheetReducer
    {
        private readonly WorkSheetStore store;
        public WorkSheetReducer(WorkSheetStore store)
        {
            this.store = store;
        }
        public void ChangeSheet(Spreadsheets.WorkSheet value)
        {
            store.Subject.OnNext(value);
        }
    }
}

WorkSheetEffect.cs

using System.Threading.Tasks;
namespace BlazorSample.Views.States
{
    public class WorkSheetEffect
    {
        private readonly WorkSheetReducer reducer;
        public WorkSheetEffect(WorkSheetReducer reducer)
        {
            this.reducer = reducer;
        }
        public async Task LoadSheetAsync(string sheetName)
        {
            // サーバーサイドからデータを取ってきて・更新して Reducer に反映
            await Task.Run(() => reducer.ChangeSheet(new Spreadsheets.WorkSheet
            {
                Name = sheetName,
            }));
        }
    }
}

WorkSheetAction.cs

using System.Threading.Tasks;

namespace BlazorSample.Views.States
{
    public class WorkSheetAction
    {
        private readonly WorkSheetReducer reducer;
        private readonly WorkSheetEffect effect;
        public WorkSheetAction(WorkSheetReducer reducer,
            WorkSheetEffect effect)
        {
            this.reducer = reducer;
            this.effect = effect;
        }
        public void ChangeSheet(BlazorSample.Spreadsheets.WorkSheet value)
        {
            reducer.ChangeSheet(value);
        }
        public async Task LoadSheetAsync(string sheetName)
        {
            await effect.LoadSheetAsync(sheetName);
        }
    }
}

で、状態を更新するコンポーネントから WorkSheetAction を呼び出し、状態の変化を受け取りたいコンポーネントで WorkSheetStore を Subscribe する、と。

Start.cs

...
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddServerSideBlazor();
            services.AddControllers();

            services.AddScoped<WorkSheetStore>();
            services.AddScoped<WorkSheetAction>();
            services.AddScoped<WorkSheetReducer>();
            services.AddScoped<WorkSheetEffect>();
        }
...

Cell.razor.cs

...
namespace BlazorSample.Views.Components
{
    public partial class Cell
    {
        [Inject]
        public WorkSheetAction Action{ get; set; }
....
        public void OnClick()
        {
            Action.ChangeSheet(new Spreadsheets.WorkSheet
            {
                Name = "New Sheet Name",
            });
        }
...

DisplayGridPage.razor.cs

...
namespace BlazorSample.Views
{
    public partial class DisplayGridPage
    {
        [Inject]
        public WorkSheetStore Store { get; set; }
...
        protected override void OnInitialized()
        {
            Store.Sheets.Subscribe(sheet => Console.WriteLine($"New sheet {sheet.Name}"));
        }
...

とりあえずものまねしてやってみたものの、ボタンクリックなど、ちょっとした状態の変化を取るには明らかにやりすぎだと思うので、使いどころは選ぶと思います。

Blazor で状態を管理するようなライブラリ・フレームワークはあるのかしら。

おわりに

途中でも降れましたが、Angular っぽいコードを TypeScript ではなく C# で書ける、というのはなんだか不思議な感じがします。

あ、最近あまり触れられていませんが、 Angular や TypeScript も好きですよ。念のため。

ただ、思っていた型と違っているんじゃ。。。といった心配をあまりしなくて良い安心感がありがたいですね。