メインコンテンツまでスキップ

Say goodbye to JavaScript in your Blazor web app

· 約10分
阮誠忠 (げんまさただ)
ソフトウェアメーカー

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

  • JavaScript and Blazor knowledge.
  • Interop between .NET/Blazor and JavaScript.

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\"]" in window.
    • (function(a) {return window.alert(a)}): IJSRuntime and its underlying helpers do not use any kind of eval, and therefore cannot parse this function. The TypeScript code would decompose that to something similar to window["(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 the if (segment in result) check, where result is currently document.title (or "Home"), and segment is "valueOf". The same applies for other primitives like number (try document.body.childNodes.length.valueOf) or boolean.

    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

These are JavaScript objects declared as ES6 classes.

Attempt - Reflect.construct

To work around this new keyword requirement, we might be able to use the static function Reflect.construct:

For these, we first fetch a function reference, then wrap our args into a JS array, then call Reflect.construct on that:

let ctor = Set.bind();
let args = Array("lxmonika")
Reflect.construct(ctor, args);

In Blazor, it can be something like:

@inject IJSRuntime JSRuntime

<button @onclick="Button_Click">Click me</button>

@code {
private async Task Button_Click()
{
await using var ctor = await JSRuntime.InvokeAsync<IJSObjectReference>("Set.bind");
await using var set = await JSRuntime.InvokeAsync<IJSObjectReference>("Reflect.construct",
new object?[] { ctor, new string[] { "lxmonika" } });
await JSRuntime.InvokeVoidAsync("console.log", set);
}

}

Looks perfect, doesn't it? But no:

Microsoft.AspNetCore.Components.WebAssembly.Rendering.WebAssemblyRenderer[100]
Unhandled exception rendering component: Cannot create a JSObjectReference from the value 'function () { [native code] }'.
Error: Cannot create a JSObjectReference from the value 'function () { [native code] }'.

Microsoft has an artificial restriction preventing functions from being bound to a JSObjectReference:

  /**
* Creates a JavaScript object reference that can be passed to .NET via interop calls.
*
* @param jsObject The JavaScript Object used to create the JavaScript object reference.
* @returns The JavaScript object reference (this will be the same instance as the given object).
* @throws Error if the given value is not an Object.
*/
export function createJSObjectReference(jsObject: any): any {
if (jsObject && typeof jsObject === "object") {
cachedJSObjectsById[nextJsObjectId] = new JSObject(jsObject);

const result = {
[jsObjectIdKey]: nextJsObjectId
};

nextJsObjectId++;

return result;
}

throw new Error(`Cannot create a JSObjectReference from the value '${jsObject}'.`);
}

I have started a discussion with Microsoft here; hopefully they can come with a clever solution for this.

eval

We have managed to avoid eval all the way until here, both because of its bad reputation and the fact that many pages have content security policies preventing code from accessing that function. eval is so powerful that it alone could have solved all our method call/property get/property set issues above, and it can also call constructors for us:

await using var set = await JSRuntime.InvokeAsync<IJSObjectReference>(
"window.eval", @"new Set(""lxmonika"")"
);

Wrapper functions

If you cannot use eval at all costs, then the only solution left is a wrapper function:

function constructByName(name, ...args) {
return Reflect.construct(window[name], args);
}
constructByName("Set", "lxmonika");

But seriously, do we ever need a JavaScript Set that bad from Blazor code?

Future .NET

ASP.NET Core has an issue open for accessing constructors, invoking callbacks, and getting/setting properties from Blazor code.

It has been open since the .NET 5 era (2021), once planned for .NET 8, then delayed to .NET 9 and now .NET 10. We will have to wait quite a long time as the talented Microsoft engineers are wasting their time on the AI/Cloud hype...

Goodbye to JavaScript

With the tricks above, we can basically say bye-bye to JavaScript in most cases.

We could even go further and build an entire type-safe wrapper for the JavaScript browser API based on IJS[InProcess]Runtime similar to what Trungnt2910.Browser did. With this much uni work though, I cannot revive that project myself right now.

That said, for performance and maintainability reasons, if your project has substantial script portions, it is still best to isolate these parts (and maybe use something like TypeScript instead of pure JavaScript) from C# code. These tricks are only best when you want to avoid creating a whole script to just click a button or get the document title.

Thanks for visiting my site, and, as usual, happy coding!