はじめに
Blazorでは,ブラウザ内で実行可能なC#のコードを書くことができます.しかしながら,DOM要素の操作や凝ったUIを作ろうとするとJavaScriptが必要となってきます.
JavaScriptをBlazorで利用する方法は,JS相互運用(JavaScript Interop)と呼ばれていて,下記ドキュメントに詳細が記されています.
ASP.NET Core Blazor JavaScript の相互運用性 (JS 相互運用)
ただ,むやみにJavaScript呼び出しを散りばめていくとコードの見通しが悪くなってしまうので,どうにかしたいです.
サービスクラスしてうまく切り出す方法があったので,備忘を兼ねてまとめて行きたいと思います.
環境
- .NET 9
- Blazor Web App (レンダーモードはページ/コンポーネントごとに指定)
IJSRuntimeを直接使うやり方
これはASP.NET Core Blazor JavaScript の相互運用性 (JS 相互運用)に載っている通常のやり方です.
JavaScriptを用意
例として,ブラウザが何かを調べてみようと思います 1.
.js
ファイルを作成して,Serverプロジェクトのwwwroot
下に置きます.
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プロジェクトで実装します.
@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
以降にしないと実行時エラーになります.
このままだと
必要なページやコンポーネントごとにIJSRuntime
をインジェクトして,module
を生成して.Dispose
してとコードがとっ散らかってしまいます.なので,Serviceクラスとして切り出して使い勝手を良くしたいです.
サービスクラス化していく
基底クラス作成
基底クラスはBlazor作成の一人であるSteve Sanderson氏が提案してくれています2ので,参考にします.
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もついでに).
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コンテナに入れます.
builder.Services.AddScoped<IBrowserCheckService, BrowserCheckService>();
ページで使用する
下記はSever版です.
WebAssembly版の修正もほぼ同じです.
@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();
}
}
}
とても使いやすくなりました.
余談
上記のJSModule
ですが,アップデート版があり,面白いので記載します.
こちらのブログに載っています.
Blazor Bits: Javascript Module Interop Base Class
/// <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は非同期操作に対応した遅延初期化パターンを実装したクラスです.
これを使うことで,リソースの最適化やUI応答性の向上を図れます.
このクラスは.NEXTというライブラリにいます.
.NEXTはBCL(C#の標準組み込みライブラリ)の発展版で,dotnetの公式リポジトリで管理されています.
Nugetから使用することもできます.
Nuget .NEXT
ブログによると,もしかするとBCLに入ってくるかもとあるので,期待ですね.