🤖

.NET 8 の Blazor の新機能 「AddCascadingValue メソッド」

2023/10/22に公開

.NET 8 RC2 まで来たので、変わら無さそうな機能をちょっとずつ試してメモしていきたいと思います。
今回はカスケーディング!

今まで

今までは CascadingValue コンポーネントを使って .razor コンポーネントを使って子コンポーネントで値を共有していました。

<CascadingValue Value="ここに子コンポーネントで共有したい値を設定">
    ここの中は共有した値を使える
</CascadingValue>

値を使いたい場合は CascadingParameter 属性をつけたプロパティをコンポーネントに追加することで共有された値を取得出来ます。

.NET 8 で追加された方法

従来の方法は、コンポーネントの配下で共有したい値を設定することが出来るという特性上、アプリケーション全体で共有したい値を設定する場合は、アプリケーションのルートレベルに近いコンポーネントで設定する必要がありました。
設計的にあまり良くない状態かもしれませんが、沢山の値をコンポーネント間で共有したい場合は CascadingValue タグの入れ子が発生してしまうことになります。

.NET 8 からは Program.csbuilder.Services.AddCascadingValue(...) を呼ぶことで、アプリケーション全体で共有したい値を設定することが出来るようになりました。

このメソッドの定義は以下のようになっています。

public static IServiceCollection AddCascadingValue<TValue>(
    this IServiceCollection serviceCollection, Func<IServiceProvider, TValue> initialValueFactory)
    => serviceCollection.AddScoped<ICascadingValueSupplier>(sp => new CascadingValueSource<TValue>(() => initialValueFactory(sp), isFixed: true));

public static IServiceCollection AddCascadingValue<TValue>(
    this IServiceCollection serviceCollection, string name, Func<IServiceProvider, TValue> initialValueFactory)
    => serviceCollection.AddScoped<ICascadingValueSupplier>(sp => new CascadingValueSource<TValue>(name, () => initialValueFactory(sp), isFixed: true));

public static IServiceCollection AddCascadingValue<TValue>(
    this IServiceCollection serviceCollection, Func<IServiceProvider, CascadingValueSource<TValue>> sourceFactory)
    => serviceCollection.AddScoped<ICascadingValueSupplier>(sourceFactory);

一番低レベルな API が Func<TServiceProvider, CascadingValueSource<T>> 引数で指定するものです。他の 2 つは簡易的に使えるように用意されているものです。ちなみに上記コードだけを見ると ICascadingValueSupplier インターフェースを実装することで色々やれそうな気がしますが、このインターフェースは internal になっているので実装することは出来ません。

CascadingValueSource<T> クラス

ということで、新しい機能を知るには CascadingValueSource<T> クラスを見てみると良さそうです。この中で一番使う機会の多いのはコンストラクタだと思うのでコンストラクタの定義を見てみましょう。

大別すると以下のようなバリエーションがあります。

  • 値を直接設定するものか、値を生成する Func<T> を指定するもの
  • 名前を指定するものか、名前を指定しないもの

そして isFixed で値を変更可能かどうかを指定することが出来ます。

public CascadingValueSource(TValue value, bool isFixed) : this(isFixed)
public CascadingValueSource(string name, TValue value, bool isFixed) : this(value, isFixed)
public CascadingValueSource(Func<TValue> initialValueFactory, bool isFixed) : this(isFixed)
public CascadingValueSource(string name, Func<TValue> initialValueFactory, bool isFixed) : this(initialValueFactory, isFixed)

ちょっと使ってみましょう。
値を共有するための CounterValue クラスを以下のように定義します。

namespace BlazorApp23;

public class CounterValue
{
    public int Value { get; private set; } = 0;

    public void Increment() => Value++;
    public void Reset() => Value = 0;
}

Program.cs で以下のようにして値をコンポーネントの CascadingParameter として共有するようにします。

Program.cs
builder.Services.AddCascadingValue(_ => new CounterValue());
// 冗長に書くと↓みたいになる
//builder.Services.AddCascadingValue(
//    _ => new CascadingValueSource<CounterValue>(() => new(), true));

そして Counetr.razor を書き換えて CounterValue を使うようにします。

Counter.razor
@page "/counter"
@attribute [RenderModeInteractiveServer]

<PageTitle>Counter</PageTitle>

<h1>Counter</h1>

<p role="status">Current count: @CounterValue?.Value</p>

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

@code {
    [CascadingParameter]
    private CounterValue? CounterValue { get; set; }

    public void IncrementCount() => CounterValue?.Increment();
}

Home.razorCounter コンポーネントを 2 個置いて動作確認をしてみましょう。

Home.razor
@page "/"

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<Counter />

<hr />

<Counter />

実行して、上側のコンポーネントで10回ボタンを押してみました。

下側のコンポーネントは値が変わっていないですね。まぁ再レンダリングかかってないので当然です。下側のコンポーネントでボタンを押すといきなり11になります。

値の変更を伝搬させるには、自分でカウンターの値の変更を監視してコンポーネントを適切に再レンダリングする必要があります。その他に CascadingValueSource<T> で値の変更を通知させる方法があります。CascadingValueSource<T>NotifyChangedAsync を呼び出すと値の変更が通知され必要なコンポーネントが再レンダリングされます。

実装してみましょう。まず CounterValue クラスに INotifyPropertyChanged インターフェースを実装してカウンターの値の変更通知を出すようにします。

CounterValue.cs
using System.ComponentModel;

namespace BlazorApp23;

public class CounterValue : INotifyPropertyChanged
{
    public int Value { get; private set; } = 0;

    public event PropertyChangedEventHandler? PropertyChanged;

    public void Increment()
    {
        Value++;
        PropertyChanged?.Invoke(this, new(nameof(Value)));
    }

    public void Reset()
    {
        Value = 0;
        PropertyChanged?.Invoke(this, new(nameof(Value)));
    }
}

そして CascadingValueSource<T> を継承して PropertyChanged イベント発生時に NotifyChangedAsync を呼び出すクラスを作成します。

NotifyPropertyChangedCacadingValueSource.cs
using Microsoft.AspNetCore.Components;
using System.ComponentModel;

namespace BlazorApp23;

// INotifyPropertyChanged の変更通知を CascadingParameter に伝搬させる
public class NotifyPropertyChangedCacadingValueSource<T> : CascadingValueSource<T>, IDisposable
    where T : INotifyPropertyChanged
{
    private readonly T _value;

    public NotifyPropertyChangedCacadingValueSource(T value) : base(value, false)
    {
        _value = value;
        _value.PropertyChanged += OnValueChanged;
    }

    private void OnValueChanged(object? sender, PropertyChangedEventArgs e)
    {
        _ = NotifyChangedAsync();
    }

    public void Dispose()
    {
        // 後始末
        _value.PropertyChanged -= OnValueChanged;
    }
}

public static class NotifyPropertyChangedCacadingValueSource
{
    // 簡易的に指定するための拡張メソッド
    public static IServiceCollection AddCascadingNotifyPropertyChangedValue<T>(
               this IServiceCollection services,
               T value)
        where T : INotifyPropertyChanged
    {
        // このとき AddCascadingValue メソッドの型引数 T を指定しないと
        // NotifyPropertyChangedCascadingValueSource<T> がカスケーディングされるので注意
        return services.AddCascadingValue<T>(
            _ => new NotifyPropertyChangedCacadingValueSource<T>(value));
    }
}

Program.cs で、作成した NotifyPropertyChangedCacadingValueSource を使うように変更します。

Program.cs
// PropertyChanged イベントを監視して値の変更を通知するようにする
builder.Services.AddCascadingNotifyPropertyChangedValue(new CounterValue());

こうすると、以下のように値の変更が通知されてカウンターの値が同期するようになります。

まとめ

ということで、今回は CascadingValue の新機能を試してみました。
CascadingValueSource<T> クラスを継承することで、任意のタイミングで値の変更を通知することも出来るようになっています。従来の CascadingValue コンポーネントを使った方法も出来ますが、アプリケーション全体で共有する値を設定する場合は AddCascadingValue メソッドを使って DI コンテナに登録した方が Razor ファイルの見通しが良くなるので良いと思います。

Microsoft (有志)

Discussion