BlazorのJS相互運用をサービスとして切り出して使う

# はじめに
Blazorでは，ブラウザ内で実行可能なC#のコードを書くことができます．しかしながら，DOM要素の操作や凝ったUIを作ろうとするとJavaScriptが必要となってきます．

JavaScriptをBlazorで利用する方法は，JS相互運用(JavaScript Interop)と呼ばれていて，下記ドキュメントに詳細が記されています．
[ASP.NET Core Blazor JavaScript の相互運用性 (JS 相互運用)](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/javascript-interoperability/?view=aspnetcore-9.0)

ただ，むやみにJavaScript呼び出しを散りばめていくとコードの見通しが悪くなってしまうので，どうにかしたいです．

サービスクラスしてうまく切り出す方法があったので，備忘を兼ねてまとめて行きたいと思います．

# 環境
* .NET 9
* Blazor Web App (レンダーモードはページ/コンポーネントごとに指定)


# IJSRuntimeを直接使うやり方
これは[ASP.NET Core Blazor JavaScript の相互運用性 (JS 相互運用)](https://learn.microsoft.com/ja-jp/aspnet/core/blazor/javascript-interoperability/?view=aspnetcore-9.0)に載っている通常のやり方です．

## JavaScriptを用意
例として，ブラウザが何かを調べてみようと思います [^1]．
`.js`ファイルを作成して，Serverプロジェクトの`wwwroot`下に置きます．
```js:wwwroot/browserCheck.js
export function getBrowser() {
    var userAgent = navigator.userAgent.toLowerCase();

     if (userAgent.indexOf('msie') != -1 || userAgent.indexOf('trident') != -1) {
         return 'Internet Explorer';
     } else if (userAgent.indexOf('edge') != -1) {
         return 'Edge';
     } else if (userAgent.indexOf('chrome') != -1) {
         return 'Google Chrome';
     } else if (userAgent.indexOf('safari') != -1) {
         return 'Safari';
     } else if (userAgent.indexOf('firefox') != -1) {
         return 'FireFox';
     } else if (userAgent.indexOf('opera') != -1) {
         return 'Opera';
     } else {
         return 'Other';
     }
}
```

## JavaScriptを呼び出す
下記はServer側の実装です．
WebAssemblyの場合は，Clientプロジェクトで実装します．

```cs: Home.razor
@page "/"
@rendermode InteractiveServerWithoutPrerendering
@implements IAsyncDisposable

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<p>Current browser: @browser</p>

@code {

    [Inject]
    IJSRuntime JSRuntime { get; set; } = default!

    private string? browser;

    private IJSObjectReference? module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            // JavaScriptを読み込む
            module = await JSRuntime.InvokeAsync<IJSObjectReference>("import", "./browserCheck.js");

            // JavaScriptを使用する
            browser = await module.InvokeAsync<string>("getBrowser");

            StateHasChanged();
        }
    }

    async ValueTask IAsyncDisposable.DisposeAsync()
    {
        if (module is { })
        {
            try
            {
                // 読み込んだJavaScriptを破棄
                await module.DisposeAsync();
            }
            catch (JSDisconnectedException)
            {
            }
        }
    }
}


```
※ ややこしいのでプリレンダリングはOFFにしています．プリレンダリングがONの場合，JavaScript呼び出しは`OnAfterRenderAsync`以降にしないと実行時エラーになります．

Server版
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/393d9338-5a1c-4de6-8e56-54e725a0456d.png)

WASM版
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/51919e2b-13f3-4a5c-b647-7ba0d53b5ebd.png)

## このままだと
必要なページやコンポーネントごとに`IJSRuntime`をインジェクトして，`module`を生成して．`Dispose`してとコードがとっ散らかってしまいます．なので，Serviceクラスとして切り出して使い勝手を良くしたいです．

# サービスクラス化していく
## 基底クラス作成
基底クラスはBlazor作成の一人であるSteve Sanderson氏が提案してくれています[^2]ので，参考にします．

```cs: JSModule.cs

public abstract class JSModule(IJSRuntime js, string moduleUrl) : IAsyncDisposable
{
    private readonly Task<IJSObjectReference> moduleTask 
        = js.InvokeAsync<IJSObjectReference>("import", moduleUrl).AsTask();

    protected async ValueTask InvokeVoidAsync(string identifier, params object[]? args)
        => await (await moduleTask).InvokeVoidAsync(identifier, args);

    protected async ValueTask<T> InvokeAsync<T>(string identifier, params object[]? args)
        => await (await moduleTask).InvokeAsync<T>(identifier, args);

    public async ValueTask DisposeAsync()
    {
        try
        {
            await (await moduleTask).DisposeAsync();
        }
        catch (JSDisconnectedException)
        {
        }
    }
}

```

## サービスクラス作成
上記の`JSModule`を継承します（Interfaceもついでに）．
```CS
public interface IBrowserCheckService
{
    Task<string> GetBrowserAsync();
}

public class BrowserCheckService(IJSRuntime js) : JSModule(js, _jsUrl), IBrowserCheckService
{
    private const string _jsUrl = "./browserCheck.js";

    public async Task<string> GetBrowserAsync()
        => await InvokeAsync<string>("getBrowser");
}
```

`Program.cs`でDIコンテナに入れます．
```cs:Program.cs
builder.Services.AddScoped<IBrowserCheckService, BrowserCheckService>();
```

## ページで使用する
下記はSever版です．
WebAssembly版の修正もほぼ同じです．
```cs: Home.razor
@page "/"
@using BlazorApp1.Client.Service
@rendermode InteractiveServerWithoutPrerendering

<PageTitle>Home</PageTitle>

<h1>Hello, world!</h1>

Welcome to your new app.

<p>Current browser: @browser</p>

@code {

    [Inject]
    IBrowserCheckService BrowserCheckService { get; set; } = default!;

    private string? browser;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender)
        {
            browser = await BrowserCheckService.GetBrowserAsync();

            StateHasChanged();
        }
    }
}
```

Server版
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/43a3b5b3-36fc-42dc-b7b5-c84d869b867d.png)

WASM版
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4044744/c1f07581-d0ec-459b-a20c-0ca1959003a7.png)


とても使いやすくなりました．


# 余談
上記の`JSModule`ですが，アップデート版があり，面白いので記載します．
こちらのブログに載っています．
[Blazor Bits: Javascript Module Interop Base Class](https://markianholland.co.uk/blog/blazor-bits-js-module/)

```cs
/// <summary>
/// Helper for loading any JavaScript (ES6) module and calling its exports
/// </summary>
public abstract class JSModule : IAsyncDisposable
{
    private readonly CancellationTokenSource _cancellationTokenSource = new();
    private readonly AsyncLazy<IJSObjectReference> _jsModuleProvider;
    private bool _isDisposed;

    /// <summary>
    /// On construction, we start loading the JS module
    /// </summary>
    /// <param name="js"></param>
    /// <param name="moduleUrl">javascript web uri</param>
    protected JSModule(IJSRuntime js, string moduleUrl)
        => _jsModuleProvider = new AsyncLazy<IJSObjectReference>(async () =>
            await js.InvokeAsync<IJSObjectReference>("import", moduleUrl));

    /// <inheritdoc />
    public virtual async ValueTask DisposeAsync()
    {
        await DisposeAsyncCore().ConfigureAwait(false);
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// Perform the asynchronous cleanup the JS module.
    /// </summary>
    protected virtual async ValueTask DisposeAsyncCore()
    {
        if (_isDisposed)
            return;

        _cancellationTokenSource.Cancel();
        _cancellationTokenSource.Dispose();

        if (_jsModuleProvider.IsValueCreated)
        {
            var module = await _jsModuleProvider.Value;

            // ここだけ修正．そのままだとServer版でエラーになるので，try-catchしときます
            // 詳細: https://learn.microsoft.com/ja-jp/aspnet/core/blazor/javascript-interoperability/?view=aspnetcore-9.0#javascript-interop-calls-without-a-circuit
            try
            {
                await module.DisposeAsync().ConfigureAwait(false);
            }
            catch (JSDisconnectedException)
            {
            }
            
        }
        _isDisposed = true;
    }

    /// <summary>
    /// invoke exports from the module with an expected return type T
    /// </summary>
    /// <param name="identifier"></param>
    /// <param name="args"></param>
    /// <typeparam name="T">Return Type</typeparam>
    /// <returns></returns>
    protected async ValueTask<T> InvokeAsync<T>(string identifier, params object[]? args)
    {
        var jsModule = await _jsModuleProvider.Value;
        return await jsModule.InvokeAsync<T>(identifier, _cancellationTokenSource.Token, args);
    }

    /// <summary>
    /// invoke exports from the module
    /// </summary>
    /// <param name="identifier"></param>
    /// <param name="args"></param>
    protected async ValueTask InvokeVoidAsync(string identifier, params object[]? args)
    {
        var jsModule = await _jsModuleProvider.Value;
        await jsModule.InvokeVoidAsync(identifier, _cancellationTokenSource.Token, args);
    }

    /// <summary>
    /// Asynchronous initialization to delay the creation of a resource until it’s absolutely needed.
    /// </summary>
    /// <remarks>
    /// This naive approach is much quicker than attempting to explain the use of a DotNext
    /// implementation. Alternatively, DotNext threading could be added via NuGet: `dotnet add
    /// package DotNext.Threading`.
    /// </remarks>
    /// <see href="https://devblogs.microsoft.com/pfxteam/asynclazyt/" />
    /// <see href="https://github.com/dotnet//blob/master/src/DotNext.Threading/Threading/AsyncLazy.cs" />
    /// <see href="https://dev.azure.com/vercodev/Venso/_git/Vanguard?path=%2FVanguard%2FVanguard.Core%2FAsyncLazy.cs" />
    /// <typeparam name="T">Resource to be initialized asynchronously</typeparam>
    public class AsyncLazy<T> : Lazy<Task<T>>
    {
        public AsyncLazy(Func<T> valueFactory) :
            base(() => Task.Factory.StartNew(valueFactory))
        { }

        public AsyncLazy(Func<Task<T>> taskFactory) :
            base(() => Task.Factory.StartNew(taskFactory).Unwrap())
        { }
    }
}

```
AsyncLazy<T>は非同期操作に対応した遅延初期化パターンを実装したクラスです．
これを使うことで，リソースの最適化やUI応答性の向上を図れます．

このクラスは[.NEXT](https://github.com/dotnet/dotNext)というライブラリにいます．
.NEXTはBCL（C#の標準組み込みライブラリ）の発展版で，dotnetの公式リポジトリで管理されています．

Nugetから使用することもできます．
[Nuget .NEXT](https://www.nuget.org/packages/DotNext)

ブログによると，もしかするとBCLに入ってくるかもとあるので，期待ですね．


[^1]: [使用してるブラウザを判定したい](https://qiita.com/sakuraya/items/33f93e19438d0694a91d)
[^2]: [JsModule](https://github.com/SteveSandersonMS/AudioBrowser/blob/dd7a03d93b4de5e97efb333f1120792ee48c70ba/MediaFilesAPI/Util/JSModule.cs)