📌

.NET 8 の ASP.NET Core Blazor の新機能「セクション」

2023/12/12に公開

はじめに

.NET 8 の ASP.NET Core Blazor の新機能の 1 つのセクションについて紹介します。
とはいっても、機能自体は非常にシンプルでレイアウトコンポーネントなどの親コンポーネントで定義したセクションに対して、子コンポーネントから好きなコンテンツを表示することが出来るというものです。

例えば画面の上部とかにページごとの操作用のボタンを配置したいといった時に使えます。
昔懐かしの汎用機みたいな、画面の下にボタンが並んだような UI も画面の下部分をセクションにしておいて、そこにページごとに好きなボタンを表示するといったような使い方も出来ます。
アプリケーションの全体の面倒を見る人にとっては地味だけど、あると有難い機能の 1 つです。

使い方は特に難しくありません。SectionOutlet コンポーネントでセクションを定義して SectionContent コンポーネントでセクションに表示するコンテンツを定義するだけです。
SectionOutlet は複数定義することができて、SectionName プロパティか SectionId プロパティで SectionOutletSectionContent の対応を取ります。

試してみよう

Blazor Web App でプロジェクトを新規作成します。Interactive render mode は Auto (Server and WebAssembly) で、Interactivity location は Per page/component を選択します。
今回使う SectionOutletSectionContent コンポーネントは Microsoft.AspNetCore.Components.Sections 名前空間で定義されているのですが、この名前空間はデフォルトでは _Imports.razorusing されていないので、Server 側と WebAssembly 側のプロジェクトの両方で以下の行を _Imports.razor に追加します。

_Imports.razor
@using Microsoft.AspNetCore.Components.Sections

そして Components/Layout/MainLayout.razor を編集して SectionOutlet を定義します。

MainLayout.razor
@inherits LayoutComponentBase

<div class="page">
    <div class="sidebar">
        <NavMenu />
    </div>

    <main>
        <div class="top-row px-4">
            <a href="https://learn.microsoft.com/aspnet/core/" target="_blank">About</a>
        </div>

        <article class="content px-4">
            <div>
                ここからセクション
                <SectionOutlet SectionName="MySection" />
                ここまでセクション
            </div>
            @Body
        </article>
    </main>
</div>

<div id="blazor-error-ui">
    An unhandled error has occurred.
    <a href="" class="reload">Reload</a>
    <a class="dismiss">🗙</a>
</div>

Components/Pages/Home.razorSectionContent を定義します。

Home.razor
@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<SectionContent SectionName="MySection">
    <h3>Section content from Home.</h3>
</SectionContent>

こうすることで、SectionOutlet で定義したセクションに SectionContent で定義したコンテンツが表示されます。実行して Home.razor を表示すると以下のようになります。

ちゃんと出ていますね。

ストリーミング レンダリングとセクション

ストリーミング レンダリングを有効にしていても、ちゃんと動きます。Components/Pages/Weather.razor でストリーミング レンダリングを有効にしている状態で SectionContent の中身を書き換えるような定義をしてみます。

Weather.razor
@page "/weather"
@attribute [StreamRendering]

<PageTitle>Weather</PageTitle>

<h1>Weather</h1>

<p>This component demonstrates showing data.</p>

@if (forecasts == null)
{
    <p><em>Loading...</em></p>
}
else
{
    <table class="table">
        <thead>
            <tr>
                <th>Date</th>
                <th>Temp. (C)</th>
                <th>Temp. (F)</th>
                <th>Summary</th>
            </tr>
        </thead>
        <tbody>
            @foreach (var forecast in forecasts)
            {
                <tr>
                    <td>@forecast.Date.ToShortDateString()</td>
                    <td>@forecast.TemperatureC</td>
                    <td>@forecast.TemperatureF</td>
                    <td>@forecast.Summary</td>
                </tr>
            }
        </tbody>
    </table>
}

<SectionContent SectionName="MySection">
    @* セクションで OnInitializedAsync 内で更新されている値を参照している見た目を定義 *@
    <h3>Section content from Weather. (Progress is @progress.)</h3>
</SectionContent>


@code {
    private int progress = 0;
    private WeatherForecast[]? forecasts;

    protected override async Task OnInitializedAsync()
    {
        // 途中経過を報告しながらデータを読み込んでいるつもりのコード
        for (int i = 0; i < 5; i++)
        {
            await Task.Delay(500);
            progress = (i + 1) * 20;
            StateHasChanged();
        }

        var startDate = DateOnly.FromDateTime(DateTime.Now);
        var summaries = new[] { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" };
        forecasts = Enumerable.Range(1, 5).Select(index => new WeatherForecast
        {
            Date = startDate.AddDays(index),
            TemperatureC = Random.Shared.Next(-20, 55),
            Summary = summaries[Random.Shared.Next(summaries.Length)]
        }).ToArray();
    }

    private class WeatherForecast
    {
        public DateOnly Date { get; set; }
        public int TemperatureC { get; set; }
        public string? Summary { get; set; }
        public int TemperatureF => 32 + (int)(TemperatureC / 0.5556);
    }
}

実行すると以下のようになります。最初は Loading... と表示されていて、セクションも progress 変数の値がきちんと適用されていることが確認できます。

OnInitializedAsync が終わるとデータが表示されて、セクションの中身もきちんと更新されていることが確認できます。

対話機能とセクション

厳密には対話機能とセクションの話ではないのですが、デフォルトのプロジェクトテンプレートで作った場合の構造がページで対話機能を有効化した場合 (@rendermodeInteractiveServerInteractiveWebAssemblyInteractiveAuto を設定した場合) MainLayout.razor は静的 SSR でレンダリングされページ内部でだけ対話機能が有効化されます。

この状態だとページの初期表示時点では SectionOutlet に対して SectionContent は、流し込まれますが、対話操作を通じて更新された結果については反映されません。厳密にいうとサーバーサイドでプリレンダリングされた内容が表示されています。

Client 側のプロジェクトにある Pages/Counter.razor を以下のようにしてカウンターの値をセクションに表示するようにしてみます。そして SectionContent とページ内で WASM かサーバーかがわかるようなテキストも表示するようにしてみます。

Counter.razor
@page "/counter"
@rendermode InteractiveAuto

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>
<p>(@(OperatingSystem.IsBrowser() ? "WASM" : "Server"))</p>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

<SectionContent SectionName="MySection">
    @* セクションにカウンターの値を表示するようにしてみる *@
    <h3>Section content from Counter. (Count: @currentCount) (@(OperatingSystem.IsBrowser() ? "WASM" : "Server"))</h3>
</SectionContent>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

表示後に何回かボタンを押してみた結果は以下のようになります。

セクション内はサーバーでレンダリングされた初期値の状態から更新されていないことがわかります。上記の画像では WASM で動いていますが Server モードで動いている状態でも同じ結果になります。

プリレンダリング時のコンテンツが表示されるので、プリレンダリングをオフにすると、セクションには何も表示されなくなります。実際に試してみましょう。

Counter.razor
@page "/counter"
@* プリレンダリングをオフ! *@
@rendermode InteractiveWebAssemblyWithoutPrerender

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>
<p>(@(OperatingSystem.IsBrowser() ? "WASM" : "Server"))</p>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

<SectionContent SectionName="MySection">
    @* セクションにカウンターの値を表示するようにしてみる *@
    <h3>Section content from Counter. (Count: @currentCount) (@(OperatingSystem.IsBrowser() ? "WASM" : "Server"))</h3>
</SectionContent>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

@code {
    // プリレンダリングをオフ!
    private static InteractiveWebAssemblyRenderMode InteractiveWebAssemblyWithoutPrerender = new(false);
}

実行すると以下のようになります。

想定通り、セクションに何も表示されません。上記の結果は WebAssembly での対話機能ですが、Server を使用した場合でも同じ結果になります。

レンダリングモードを跨がない場合の挙動

これまでは静的SSRとInteractiveServerといったようにレンダリングモードを跨いでいる状態での動きを見てみました。デフォルトのよく使うであろうプロジェクト構成がこれなので、この時の動きを理解するのは大事です。

結構ハマりどころが多そうですが、グローバルで InteractiveServer などのレンダリングモードを設定した場合は、ここまでの注意点はあてはまりません。例えば App.razorRoutes コンポーネントのレベルで以下のように InteractiveServer を設定して各ページからは @rendermode を設定しないようにしてみます。

App.razor
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <base href="/" />
    <link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
    <link rel="stylesheet" href="app.css" />
    <link rel="stylesheet" href="BlazorApp1.styles.css" />
    <link rel="icon" type="image/png" href="favicon.png" />
    <HeadOutlet />
</head>

<body>
    <Routes @rendermode="InteractiveServer" />
    <script src="_framework/blazor.web.js"></script>
</body>

</html>
Counter.razor
@page "/counter"

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>
<p>(@(OperatingSystem.IsBrowser() ? "WASM" : "Server"))</p>

<p role="status">Current count: @currentCount</p>

<button class="btn btn-primary" @onclick="IncrementCount">Click me</button>

<SectionContent SectionName="MySection">
    @* セクションにカウンターの値を表示するようにしてみる *@
    <h3>Section content from Counter. (Count: @currentCount) (@(OperatingSystem.IsBrowser() ? "WASM" : "Server"))</h3>
</SectionContent>

@code {
    private int currentCount = 0;

    private void IncrementCount()
    {
        currentCount++;
    }
}

こうすると、レイアウト部分も完全に Server の対話モードになるため SectionOutlet 内の値もきちんと更新されるようになります。

まとめ

地味に便利なセクション機能について紹介しました。
レンダリング モードを跨いだ時の動作とかは理解すると当然ではあるのですが、地味にハマりどころなので注意が必要なので、実際に使うときはしっかりと設計してから使いましょう。

セクションが更新されない!なんで!?とならないように。

Microsoft (有志)

Discussion