Exploring advanced DOM interop with Blazor WebAssembly

With the establishment of WebAssembly in the browser space, JavaScript is not the only option anymore to run code in the browser. You can start building your modern web applications in any language of choice. Recently Microsoft has released the first version of Blazor WebAssembly. It enables you as a .NET developer to run .NET code through WebAssembly. Blazor also comes with a websocket based server model.

You can check out Blazor on Microsoft Docs: https://docs.microsoft.com/en-us/aspnet/core/blazor

I have been building a couple of Blazor experiments myself, of which the following two stand out:

  1. Real-time Blazor server web app. I have added a second SignalR hub to this app in order to communicate signals from one Blazor session to another. [Blogged] [Source code] [Try it online]
  2. Deep zoom (perturbation based!) Mandelbrot with Blazor Source code. If I find time I will blog about all my adventures to getting this deep zoom Mandelbrot to work! Try it online

It was the latter side-project that got me into the exploration of other ways to interact with the DOM. The challenge here is that the generated Mandelbrot 2d pixel array must be rendered to an html5 canvas element, but the regular libraries don't provide means. See the issue I have raised on this Canvas Blazor community component repo: https://github.com/BlazorExtensions/Canvas/issues/66

Interacting with the DOM

With Blazor WebAssembly you don't interact with the Html Document Object Model (DOM) directly. You write a tree of Razor Components. The runtime then executes this tree, calculates the differences with the actual DOM and finally synchronizes these. Processing only the differences into the DOM is a very efficient approach that is applied by all modern web frameworks such as Angular, React and Vue.

Additionally, the WebAssembly standard in general does not allow direct interaction with the DOM. All interaction must flow through JavaScript interop. Therefore Blazor WebAssembly ships with a small JavaScript engine that takes painting instructions from the WebAssembly side en executes these against the DOM. This JavaScript engine takes DOM events and pass them to the WebAssembly side.

WebAssembly and .NET

Blazor WebAssembly ships with a .NET Runtime compiled into WebAssembly: dotnet.wasm. This runtime loads all required assemblies and executes your web application. .NET interacts with the outside world through bindings that are exposed by this WebAssembly runtime.

Blazor WebAssembly

WebAssembly JavaScript interop

WebAssembly and JavaScript can interoperate both ways. A WebAssembly module is initialized from JavaScript as follows (taken from https://developer.mozilla.org/en-US/docs/WebAssembly/Loading_and_running):

WebAssembly.instantiateStreaming(fetch('myModule.wasm'), importObject)
.then(obj => {
  // Call an exported function:
  obj.instance.exports.exported_func();

  // or access the buffer contents of an exported memory:
  var i32 = new Uint32Array(obj.instance.exports.memory.buffer);

  // or access the elements of an exported table:
  var table = obj.instance.exports.table;
  console.log(table.get(0)());
})

obj.instance.exports contain all the exports that the instantiated WebAssembly is providing, whereas importObject is an object you provide from JavaScript with which the WebAssembly is allowed to interact.

Blazor and JavaScript interop

Blazor comes with means to interop between .NET and JavaScript. If you would like to expose .NET methods to JavaScript you decorate your code with the JSInvokable attribute (documented at https://docs.microsoft.com/en-us/aspnet/core/blazor/call-dotnet-from-javascript?view=aspnetcore-3.1):

[JSInvokable]
public static Task<int[]> ReturnArrayAsync()
{
    return Task.FromResult(new int[] { 1, 2, 3 });
}

The method is then callable from JavaScript as follows:

DotNet.invokeMethodAsync('BlazorSample', 'ReturnArrayAsync')
    .then(data => {
        data.push(4);
        console.log(data);
    });

Calling instance methods is also possible.

If you would like to call JavaScript from .NET you will have to use JSRuntime (documented at https://docs.microsoft.com/en-us/aspnet/core/blazor/call-javascript-from-dotnet?view=aspnetcore-3.1):

Given the following global JavaScript function:

window.convertArray = (win1251Array) => {
    var win1251decoder = new TextDecoder('windows-1251');
    var bytes = new Uint8Array(win1251Array);
    var decodedArray = win1251decoder.decode(bytes);
    console.log(decodedArray);
    return decodedArray;
  };

You can call this from a Blazor component as follows:

var text = await JSRuntime.InvokeAsync<string>("convertArray", quoteArray);

Using JSRuntime makes your code run with both Blazor WebAssembly as well as Blazor Server.

Mono and WebAssembly

Mono is a cross-platform .NET runtime and framework that originates back from the time when Microsoft did not ship their own cross-platform version.

Currently Blazor is using the Mono WebAssembly runtime. The Mono WebAssembly documentation is not found easily. The following pointer gets you started:
https://github.com/mono/mono/tree/master/sdks/wasm/docs/getting-started

Mono WebAssembly comes with low-level bindings to interop from .NET with JavaScript through the assembly WebAssembly.Bindings.dll. Interestingly, this very same assembly is also added to your Blazor application output by the Blazor build process.

In the .NET 6 timeframe Mono WebAssembly will get merged into the regular .NET codebase and Blazor will adopt the more efficient regular .NET runtime.

WebAssembly DOM interop

When looking for frameworks that do ship with a DOM interop story in the box, you will find Rust in combination with the wasm-bindgen library. Documentation can be found here: https://rustwasm.github.io/docs/wasm-bindgen/

An example of a Rust WebAssembly program that interacts with Canvas (taken from https://rustwasm.github.io/docs/wasm-bindgen/examples/2d-canvas.html)

let document = web_sys::window().unwrap().document().unwrap();
    let canvas = document.get_element_by_id("canvas").unwrap();
    let canvas: web_sys::HtmlCanvasElement = canvas
        .dyn_into::<web_sys::HtmlCanvasElement>()
        .map_err(|_| ())
        .unwrap();

    let context = canvas
        .get_context("2d")
        .unwrap()
        .unwrap()
        .dyn_into::<web_sys::CanvasRenderingContext2d>()
        .unwrap();

    context.begin_path();

    // Draw the outer circle.
    context
        .arc(75.0, 75.0, 50.0, 0.0, f64::consts::PI * 2.0)
        .unwrap();
    ...

You can try it out here: https://rustwasm.github.io/wasm-bindgen/exbuild/canvas/

By the way, the Rust ecosystem comes with an experimental Rust WebAssembly web application framework that is interesting to keep track of, called Yew. See https://yew.rs/docs/ Although on version 0.17 it looks quite usable already.

Mono Html5 bindings

So is such a library also available to Blazor users? You can answer this question two-fold:

  1. No and it will not be recommended. You should interact with the DOM through Razor components or Blazor JavaScript interop infrastructure;
  2. Not yet, Mono is working on it. See https://github.com/mono/mono/issues/19650

In the meanwhile, one of the Mono core team's members (kjpou1) has created an experimental .NET assembly that provides you Html5 Bindings. https://github.com/kjpou1/wasm-dom

For example, it enables interacting with a Canvas element as follows:

// Get the canvas object
var document = Web.Document;
var canvas = document.QuerySelector<HTMLCanvasElement>("canvas");

// resize the canvas 
canvas.Width = window.InnerWidth;
canvas.Height = window.InnerHeight;

// Obtain a reference to the canvas's 2D context
var ctx = canvas.GetContext2D();
ctx.FillStyle = "rgba(255,0,0,0.5)";
ctx.FillRect(100, 100, 100, 100);
ctx.FillStyle = "rgba(0,255,0,0.5)";
ctx.FillRect(400, 100, 100, 100);
ctx.FillStyle = "rgba(0,0,255,0.5)";
ctx.FillRect(300, 300, 100, 100);

Inner workings

The method Web.Document returns a Document singleton which is implemented as follows:

public sealed partial class Document : Node
{
    internal Document(JSObject handle) : base(handle) { }

    public Document() : base("document") { }
    ...
}

Node eventually boils down to parent class DOMObject with two interesting members:

  1. This constructor:
public DOMObject(string globalName)
{
    ManagedJSObject = (JSObject)Runtime.GetGlobalObject(globalName);
}

Runtime.GetGlobalObject is a method that is defined by the assembly WebAssembly.Bindings.dll. This method gets a particular JavaScript object from the browser's global namespace and returns a JSObject reference.

So Web.Document essentially just gets the browser's Document object and stores the JSObject reference in an instance of the .NET class `Document'.

  1. This static member:

static readonly JSObject domBrowserInterface = (JSObject)((JSObject)Runtime.GetGlobalObject("WebAssembly_Browser_DOM")).GetObjectProperty("prototype");

WebAssembly_Browser_DOM refers to a custom JavaScript object, which can be found in his custom runtime.js.

This object takes care of managing event bindings.

Enabling DOM interop in a Blazor app

Surprisingly, it is not easy to utilize WebAssembly.Bindings.dll that comes with Blazor. There is no Nuget package for it. To get a copy, I just downloaded the latest Mono WebAssembly SDK as described in their documentation and took the assembly from there.

Then I integrated the work from kjpou1:

  1. Referenced his WebAssembly.Browser assembly;
  2. Copied over the C# files of his CanvasAnimation example;
  3. Copied the WebAssembly_Browser_DOM code from his runtime.js JavaScript file.

You can try out the result online; just press the Test interop button: https://blazorwasminterop.azurewebsites.net/counter

image

Caveat: there is no cleaning up implemented, so try to kick off the interop multiple times and see the circles come to a halt 😉

Also check out the source code I have published.

Reacties

Populaire posts van deze blog

TypeScript async/await example for the browser

Troubleshooting restoring a site collection