Blazor Bits: Javascript Module Interop Base Class
For those lucky enough to give Blazor a try in a live project, you will have invariably had to write some JavaScript interop code. It’s a bit tedious and it can be quite complicated. To make the process more straightforward a few bright sparks proposed a base class for our JavaScript interop services.
JavaScript Interop
We don’t want to inject the JavaScript runtime, IJSRuntime, into components directly. It’s too close to the problem we’re trying to solve. It’s much more effective when we abstract our interop so that it looks and feels like other Blazor elements. So let’s jump to the chase. We’re going to define a class which we can use for any service which will need to call to some Javascript. There’s a few key parts which i’m hoping will be familiar to Blazor developers:
- We inject the
IJSRuntimeto invoke animportstatement with our a url for our javascript module - We provide the usual
InvokeAsyncandInvokeVoidAsyncmethods but they will act directly upon our encapsulated JavaScript module - Dipose everything when we’re done
// BlazorBits/JsModule.cs
namespace BlazorBits;
using System;
using System.Threading.Tasks;
using Microsoft.JSInterop;
/// <summary>
/// Helper for loading any JavaScript (ES6) module and calling its exports
/// </summary>
public abstract class JsModule : IAsyncDisposable
{
private readonly AsyncLazy<IJSObjectReference> _jsModuleProvider;
private readonly CancellationTokenSource _cancellationTokenSource = new();
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));
/// <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>
/// 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);
}
/// <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;
await module.DisposeAsync().ConfigureAwait(false);
}
_isDisposed = true;
}
/// <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/dotNext/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())
{ }
}
}
An Example
So we’ve got our base class but how do we use it. The example that has often come bundled with a new Blazor project is a JavaScript prompt. Let’s refactor that into a new PromptService.
// BlazorBits/PromptService.cs
namespace BlazorBits;
public class PromptService : JsModule
{
// We create the constructor and pass down a path to our javascript
// which will be in our /wwwroot folder, BlazorBits/wwwroot/promptService.js.
// If you module is in an RCL you might find it within the build generated ./_content/ path
public PromptService(IJSRuntime jsRuntime)
: base(jsRuntime, "./promptService.js")
{
}
public async ValueTask<string> Prompt(string message)
{
return await InvokeAsync<string>(JavaScriptMethods.ShowPrompt, message);
}
private static class JavaScriptMethods{
public const string ShowPrompt = "showPrompt";
}
}
The JavaScript file if you’re interested and didn’t find it in your template:
// BlazorBits/wwwroot/promptService.js
// This is a JavaScript module that is loaded on demand. It can export any number of
// functions, and may import other JavaScript modules if required.
export function showPrompt(message) {
return prompt(message, 'Type anything here');
}
We wire our service into DI in Program.cs:
// BlazorBits/Program.cs
using BlazorBits;
using BlazorBits.Components;
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddRazorComponents()
.AddInteractiveServerComponents();
// Add that service now!
builder.Services.AddScoped<PromptService>();
var app = builder.Build();
// Configure the HTTP request pipeline.
if (!app.Environment.IsDevelopment())
{
app.UseExceptionHandler("/Error", createScopeForErrors: true);
// The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
app.UseHsts();
}
app.UseHttpsRedirection();
app.UseStaticFiles();
app.UseAntiforgery();
app.MapRazorComponents<App>()
.AddInteractiveServerRenderMode();
app.Run();
Finally, our page with the injected service:
// BlazorBits/Components/Pages/Home.razor
@page "/"
@inject PromptService PromptService
<PageTitle>Home</PageTitle>
<h1>Hello, world!</h1>
Welcome to your new app.
<button onclick="@onButtonClick">Prompt me</button>
<output>@_userInput</output>
@code {
private string _userInput = string.Empty;
private async Task onButtonClick()
{
_userInput = await PromptService.Prompt("What say you!?");
}
}
Who to thank and what’s next
Thanks must go to Steve Sanderson, one of the creators of Blazor who first introduced me to this idea and published his code which included the basis for this helpful class. And to GitHub user @lonix1 who submitted a feature request which informed my attempt at a JsModule class. Keep your eyes peeled as this might make it into dotnet 9!