Say goodbye to JavaScript in your Blazor web app
Uni is taking too much of my time, and so I cannot work much on Project Reality and lxmonika
.
However, while building a Blazor web app as part of my course, I do have a .NET/JS interop trick
I want to share!
Prerequisites
The problem
The most annoying issue when working with Blazor is probably times when you need to call a bit of
JavaScript. While HTML elements and, to some extent, CSS styles (with Element.style
), can be
defined inline in Razor components, running bits of JavaScript is not easy.
The generally accepted way
is to create a separate file for your JavaScript and declare them all in the main App.razor
page
as <script>
tags. The custom JavaScript needs to expose a few functions to the global namespace,
allowing them to be called using IJSRuntime.InvokeAsync
.
This is especially troublesome when you sometimes have to do a few simple actions not natively
supported by Blazor, such as clicking a button
(button.click()
) or submitting a form (form.submit()
). Using the widely documented approach,
one would need to create a whole new JavaScript file, declare a new <script>
tag, and pollute the
global namespace for what could have been a few JavaScript one-liners.
Some background
IJSRuntime
internals
Before going further, let's see what this IJSRuntime
does under the hood.
When IJSRuntime.InvokeAsync
reaches the browser's JavaScript runtime, it splits the function
"identifier
"
by the .
symbol. Then, it starts accessing members, starting from the window
object:
(Extracted from Microsoft.JSInterop.ts
):
class JSObject {
// [some parts omitted]
public findFunction(identifier: string) {
// [some parts omitted]
let result: any = this._jsObject;
let lastSegmentValue: any;
identifier.split(".").forEach(segment => {
if (segment in result) {
lastSegmentValue = result;
result = result[segment];
} else {
throw new Error(`Could not find '${identifier}' ('${segment}' was undefined).`);
}
});
if (result instanceof Function) {
result = result.bind(lastSegmentValue);
this._cachedFunctions.set(identifier, result);
return result;
}
throw new Error(`The value '${identifier}' is not a function.`);
}
// [some parts omitted]
}
const windowJSObjectId = 0;
const cachedJSObjectsById: { [id: number]: JSObject } = {
[windowJSObjectId]: new JSObject(window)
};
Why does it start looking from the window
object, you may ask? Let's move on to the next section.
Global symbols and window
In browser JavaScript, the global scope is the same as the window
object. Everything accessible
globally can also be accessed through window.
. For example,
var foo = 2910;
console.log(foo);
console.log(window.foo);
should output:
2910
2910
This is not restricted to variables or user-defined symbols. Built-in classes and functions can also be referenced this way:
window.JSON
outputs (at least, on Microsoft Edge):
JSON {Symbol(Symbol.toStringTag): 'JSON', parse: ƒ, stringify: ƒ, rawJSON: ƒ, isRawJSON: ƒ}
Conditions for functions passed to IJSRuntime.InvokeAsync
Knowing the search strategy and source code, we can determine the conditions of the JavaScript
expression passed to IJSRuntime.InvokeAsync
method:
-
The expression must be a function (otherwise,
result instanceof Function
would fail). -
The expression must be a chain of attributes, separated by a
"."
. These examples would fail:window["alert"]
: Despite returning the same function,JSObject
cannot look this up since it would translate to an attempt to look for a member named"window[\"alert\"]"
inwindow
.(function(a) {return window.alert(a)})
:IJSRuntime
and its underlying helpers do not use any kind ofeval
, and therefore cannot parse this function. The TypeScript code would decompose that to something similar towindow["(function(a) {return window"]["alert(a)})"]
.
-
No primitives may appear in this chain of attributes. This expression would fail:
document.title.valueOf
:Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
Unhandled exception rendering component: Cannot use 'in' operator to search for 'valueOf' in Home
TypeError: Cannot use 'in' operator to search for 'valueOf' in Home"Home" here is actually the primitive string that
document.title
is currently holding for the test page. What failed here was theif (segment in result)
check, whereresult
is currentlydocument.title
(or"Home"
), andsegment
is"valueOf"
. The same applies for other primitives likenumber
(trydocument.body.childNodes.length.valueOf
) orboolean
.There will be more discussion about
valueOf
in the sections below.
The solution
Functions
Let's go back to the button click example.
We have a reference to a button (probably as a ElementReference
), and we now need to call click
on it.
We cannot directly invoke JavaScript methods on ElementReference
, but IJSRuntime
can marshal it
to the corresponding JavaScript object. We also know that the HTML <button>
has a corresponding
JavaScript class called
HTMLButtonElement
.
From this, we can easily get the click
method from the prototype:
window.HTMLButtonElement.prototype.click
As a member of prototype
, the function is bound to the prototype object. To invoke it with the
button reference we have as the this
argument, we can use
.call
:
This gives us:
window.HTMLButtonElement.prototype.click.call
All we need now is to pass the button reference we have to the function above. We can try it in Blazor:
@inject IJSRuntime JSRuntime
<button @onclick="Button1_Click">Click me</button>
<button @ref="_button2" @onclick="Button2_Click" style="display: none;"></button>
<p>Counter value: @_value</p>
@code {
private ElementReference _button1;
private ElementReference _button2;
private int _value = 0;
private async Task Button1_Click()
{
await JSRuntime.InvokeVoidAsync(
"window.HTMLButtonElement.prototype.click.call", _button2);
}
private void Button2_Click()
{
++_value;
StateHasChanged();
}
}
Attributes/Properties
The Reflect
JavaScript global object contains a few useful static functions for manipulating JS objects.
Getting the window
object
Before being able to access anything, we would need to grab a reference to window
, or a similar
global object like document
. For most JavaScript objects, we can use
valueOf
to grab itself:
await using var window = await JSRuntime.InvokeAsync<IJSObjectReference>("window.valueOf");
Or maybe just:
await using var window = await JSRuntime.InvokeAsync<IJSObjectReference>("valueOf");
A similar trick can be done for other global objects like document
.
Getting
To get properties, first, acquire a reference. Then, we can call
Reflect.get
on that reference:
let ref = document.valueOf();
let title = Reflect.get(ref, "title");
console.log(title);
Or, in Blazor:
@inject IJSRuntime JSRuntime
<button @onclick="Button_Click">Click me</button>
<p>Title: @_title</p>
@code {
private string? _title;
private async Task Button_Click()
{
await using var document = await JSRuntime.InvokeAsync<IJSObjectReference>(
"document.valueOf"
);
_title = await JSRuntime.InvokeAsync<string>("Reflect.get", document, "title");
StateHasChanged();
}
}
Another way to get values is using valueOf
, just like what we did for window
and document
.
This has an advantage of saving us from creating an extra object reference. However, as discussed
above, valueOf
will not work with primitive types as .NET tries to look for that member using the
in
operator. Furthermore, valueOf
may be overriden by classes to return something other than
itself.
Setting
To set properties, we can use
Reflect.set
on a reference obtained by the same way:
let ref = document.valueOf();
Reflect.set(ref, "title", "Just Monika!");
In Blazor:
@inject IJSRuntime JSRuntime
<button @onclick="Button_Click">Click me</button>
@code {
private int _value = 0;
private async Task Button_Click()
{
++_value;
await using var document = await JSRuntime.InvokeAsync<IJSObjectReference>(
"document.valueOf"
);
await JSRuntime.InvokeVoidAsync("Reflect.set", document, "title",
$"Just Monika | Counter value: {_value}");
}
}
Constructors
Now that we can manipulate all objects that we can get our hands on, the final task we need to do before getting rid of JavaScript completely is constructing our own objects from C#. In other words, we need to find a way to invoke constructors.
Non-class
objects
Some JavaScript constructors are accessible through <ClassName>.prototype.constructor
, or directly
using <ClassName>
itself:
// Boxes this primitive
Object(2910);
// This also works
Object.prototype.constructor(2910);
Output:
Number {2910}
Number {2910}
class
objects
However, others aren't:
Set.prototype.constructor("lxmonika");
Output:
Uncaught TypeError: Constructor Set requires 'new'
at Set (<anonymous>)
at <anonymous>:1:15