A JavaScript module is a file that exports an explicit API without adding its functions to the global scope.
Blazor leverages this native browser feature to provide us with JS Isolation.
Advantages:
- Cleanliness: You don’t pollute the
window. - Performance: The script is not downloaded when the app starts. It is downloaded (Lazy Load) only when the user enters the page that uses that component.
- Encapsulation: Internal functions and variables remain inside the module (although the same module instance can be shared by multiple imports).
Creating the JavaScript Module
Let’s create a script for an editor. Instead of adding it to window, we use the export keyword.
Create a file in wwwroot/js/editor.js:
// wwwroot/js/editor.js
export function initEditor(element, initialMessage) {
if (element) {
element.value = initialMessage;
element.style.border = "2px solid #6610f2"; // Simulating something visual
console.log("Editor initialized");
}
}
export function getText(element) {
return element ? element.value : "";
}
export function showAlert() {
alert("Hello from an isolated module!");
}Notice there is no window.something = .... Everything you want to use from C# must have export before it.
Importing the Module with IJSObjectReference
Here the mechanics change compared to previous articles.
Instead of calling JS.InvokeVoidAsync directly on a global function, we must first import the file.
When you dynamically import a JS file, Blazor returns an object of type IJSObjectReference. This object is our “remote control” to interact with that specific file.
@implements IAsyncDisposable
@inject IJSRuntime JS
<textarea @ref="editor" rows="5"></textarea>
<button @onclick="ReadText">Read Text</button>
@code {
// This variable will hold the reference to the loaded module
private IJSObjectReference? moduleJS;
private ElementReference editor;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// 1. Load the file dynamically
// The path is relative to wwwroot
moduleJS = await JS.InvokeAsync<IJSObjectReference>(
"import", "./js/editor.js");
// 2. Invoke the exported function FROM THE MODULE
await moduleJS.InvokeVoidAsync("initEditor", editor, "Write here...");
}
}
private async Task ReadText()
{
if (moduleJS is not null)
{
var text = await moduleJS.InvokeAsync<string>("getText", editor);
Console.WriteLine($"Retrieved text: {text}");
}
}
// ... Dispose (see below)
}Notice the key difference:
- Before:
JS.InvokeVoidAsync("globalName") - Now:
moduleJS.InvokeVoidAsync("exportedName")
Releasing the Reference with DisposeAsync
IJSObjectReference represents an interop reference to the module. We must release it when the component is removed so that the runtime can remove it from its reference table.
To do this, the component must implement IAsyncDisposable (not regular IDisposable, because JS cleanup is asynchronous).
public async ValueTask DisposeAsync()
{
if (moduleJS is not null)
{
try
{
await moduleJS.DisposeAsync();
}
catch (JSDisconnectedException)
{
// Ignore: this happens if the user closes the browser tab
// and SignalR disconnects before we can clean up.
}
}
}JavaScript Files Placed Alongside the Component
In .NET 6+, Microsoft improved this even further.
Just as we can have Counter.razor.css, we can have Counter.razor.js.
If you place the JS file right next to the component (in the Components or Pages folder, not in wwwroot), Blazor will automatically bundle it into a special path.
File Structure:
Pages/MyComponent.razorPages/MyComponent.razor.js
How to load it: In the application project, the file is imported via its public path within the project itself:
moduleJS = await JS.InvokeAsync<IJSObjectReference>(
"import", "./Components/Pages/MyComponent.razor.js");If the component belongs to a Razor Class Library, the path uses ./_content/{PACKAGE_ID}/... before the file path.
Watch out for caching: During development, browsers sometimes aggressively cache JS modules. If you change the JS code and don’t see changes, try a “Hard Refresh” (Ctrl + F5) or open the DevTools and check “Disable Cache”.