DEV Community πŸ‘©β€πŸ’»πŸ‘¨β€πŸ’»

Cover image for Mastering Blazor - Part 1: DOM Integration
Florian Rappl
Florian Rappl

Posted on

Mastering Blazor - Part 1: DOM Integration

Photo by Kaleidico on Unsplash

In the recent two years I did quite a bit of web development also using Microsoft's new web framework called Blazor. Blazor adds component-first support to ASP.NET by introducing "Razor components". With Razor components Blazor is capable of providing a full single-page application framework.

Bootstrapping

The way Blazor works is by running your .NET code either on the server-side with a bridge written in WebSocket or on the client-side using WebAssembly. In both cases Blazor integrates into your page via a couple of globals. Most importantly Blazor. For instance, using window.Blazor.start() you can start Blazor manually if you prevented the automatic start. This works via

<script src="blazor.webassembly.js" autostart="false"></script>
Enter fullscreen mode Exit fullscreen mode

The location where this file is received from is quite crucial as it will take and load another file from it: blazor.boot.json.

The Blazor boot metadata file contains important information such as cacheBootResources (should the resources be cached or not?), the entry assembly, or all the resources to load. The latter is an object using the resources property with sub properties assembly, lazyAssembly, pdb, runtime, and satelliteResources.

Using this information Blazor will begin downloading everything. Crucial here is the runtime, notably dotnet.5.0.5.js and dotnet.wasm. The former brings another global: DotNet. The latter brings the CLR, which is responsible for actually running the .NET code. Actually, the .dll files are technically not different than when you'd run on the server. It's just that instead of having the CLR somewhat integrated into the OS we integrated it into the browser. It's now run via WebAssembly.

This is not the only available mode. There is no requirement on including those files. Alternatively, one may actually choose the WebSocket renderer. In this variant, instead of talking to WASM sitting in the same browser, the runtime of Blazor will communicate with some server via WebSocket. Now instead of receiving frames and doing interop via JS <-> WASM it is done via JS <-> WebSocket <-> Server.

As mentioned, Blazor assumes that the manifest and its dependencies are all coming from the same directory as the loading page. Right now, there is no easy way to change it. What we can do, however, is to rewire that. The following snippet creates a function to start up Blazor, i.e., to not just call Blazor.start() but instead call starter() where starter was created from calling the following function:

function createBlazorStarter(publicPath) {
  if (publicPath) {
    const baseElement =
      document.head.querySelector('base') || document.head.appendChild(document.createElement('base'));
    const originalBase = baseElement.href;
    baseElement.href = publicPath;
    return () => {
      window.Blazor._internal.navigationManager.getBaseURI = () => originalBase;
      return window.Blazor.start().then(() => {
        baseElement.href = originalBase;
      });
    };
  }

  return () => window.Blazor.start();
}
Enter fullscreen mode Exit fullscreen mode

Already from this little snippet you can see that there is a bit more to it than meets the eye. You can spot the _internal property on window.Blazor, which hosts a couple of necessary interop services. One of these is navigationManager, which is the JS counterpart to the Blazor router.

In the previous snippet we modify the getBaseURI function to return the base URL, which will be used to construct general calls. Besides this, there are a couple other functions on _internal, too:

  • attachRootComponentToElement
  • getApplicationEnvironment
  • getConfig
  • getSatelliteAssemblies
  • renderBatch

These functions are not so much of practical use. They are used by Blazor to trigger behavior, e.g., render outstanding frames or get the initial application environment. The calls are always executed via DotNet. In C#/.NET this would look like:

Microsoft.JSInterop.JSRuntime.InvokeVoidAsync("Blazor._internal.renderBatch", arg1, arg2, ...);
Enter fullscreen mode Exit fullscreen mode

This will use special functionality that comes with the respective .NET bridge. In WebAssembly this will make a call via the WASM runtime. On the other hand, we can also call .NET code from JavaScript via DotNet. Example:

window.DotNet.invokeMethodAsync('MyLib', 'DotNetFunctionName', arg1, arg2, ...);
Enter fullscreen mode Exit fullscreen mode

While JavaScript functions need to be globally available (i.e., attached to window) the basic requirement for .NET functions to be called is a special attribute: JSInvokable. There's a little bit more to it (like instance-bound vs static), but in general that's covered pretty well by the official documentation about it.

With that being written let's move on to look at what events are handled / forwarded specifically in Blazor.

Events

The following events are treated specifically by Blazor and need to be dispatched to it:

  • abort
  • blur
  • change
  • error
  • focus
  • load
  • loadend
  • loadstart
  • mouseenter
  • mouseleave
  • progress
  • reset
  • scroll
  • submit
  • unload
  • DOMNodeInsertedIntoDocument
  • DOMNodeRemovedFromDocument
  • click
  • dblclick
  • mousedown
  • mousemove
  • mouseup

Blazor listens to these events on the "root node", i.e., the element where you bootstrap the application to. This is usually a custom element called <app>, but you can actually change this in the Blazor startup.

The following snippet sets the node to be an element with the ID blazor-root:

public static async Task Main(string[] args)
{
    var builder = WebAssemblyHostBuilder.CreateDefault(args);
    var baseAddress = new Uri(builder.HostEnvironment.BaseAddress);

    builder.RootComponents
        .Add<App>("#blazor-root");

    builder.Services
        .AddSingleton(new HttpClient { BaseAddress = baseAddress });

    await builder.Build().RunAsync();
}
Enter fullscreen mode Exit fullscreen mode

Usually, you'll not need to know these events and how Blazor demands and listens to them. However, if you want to do some special stuff - such as DOM projections, where you take out a node below the root node and attach it somewhere else in the DOM then you need to use the list above for forwarding such events.

Event forwarding is in general not difficult, however, it might come with some edge cases. For instance, on some browsers the MutationEvent coming from events like DOMNodeInsertedIntoDocument cannot be cloned and therefore not be dispatched again (easily).

In general an event clone / re-dispatch looks as follows:

function reDispatchEvent(newTarget, originalEvent) {
  const eventClone = new originalEvent.constructor(originalEvent.type, originalEvent);
  newTarget.dispatchEvent(eventClone);
}
Enter fullscreen mode Exit fullscreen mode

With that in mind let's also go briefly over the topic of serialization as it gets important for any kind of interop.

Serialization

As mentioned initially Blazor does not really live in JavaScript. Instead, only a "tiny" management layer lives in JavaScript - exposed via global Blazor and DotNet variables. Instead, Blazor either renders on the server or inside a WASM-powered CLR runtime. Theoretically, we could also introduce another way of including Blazor - the possibility is there.

In any case, the chosen approach means that we need to have a message exchange between the system running Blazor (e.g., inside WebAssembly) and the page. The message exchange is based on a string, so anything send there must be serializable. The easiest format is a JSON-based serialization, which allows us to use plain JS objects as message input.

The downfall of this approach is that there are certain things that seem possible at first, but cannot be serialized. Take for instance the following snippet:

JSON.stringify({
  a: true,
  b: 'foo',
  c: () => console.log('Hello'),
});
Enter fullscreen mode Exit fullscreen mode

It may seem possible at first, however, since JSON is platform and language independent there would be no notion of a function. Therefore, functions are just discarded resulting in:

{"a":true,"b":"foo"}
Enter fullscreen mode Exit fullscreen mode

So far, so good. Nevertheless, even worse than discarding certain elements is that the serialization can also just fail and error out. Consider this:

const obj = {};
obj.parent = obj;
JSON.stringify(obj);
Enter fullscreen mode Exit fullscreen mode

Running this will result in an error being thrown: Uncaught TypeError: cyclic object value. Apparently, since the JSON format is just a string there is no capability of including references there. Just serializing the object again would yield an infinite long strong (due to the endless recursion). One way of dealing with that is to perform some sanatization when serializing:

const obj = {};
obj.parent = obj;
JSON.stringify(obj, (key, value) => {
  if (key == 'parent') {
    return '$self';
  } else {
    return value;
  }
});
Enter fullscreen mode Exit fullscreen mode

Now this results in the following JSON:

{"parent":"$self"}
Enter fullscreen mode Exit fullscreen mode

The $self we could now use as a special notation when deserializing. Alternatively, we could have also discarded it by returning undefined.

Alright, but there is a bit more to serialization than just understanding of JSON. For many things, e.g., network calls, using JSON as serialization format would not be good. In fact, using a string as a message would not be good. Instead, we need to understand that the native way of communicating with WebAssembly is a chunk of bytes - an ArrayBuffer.

In order to work with all these serialization types (and more) the Blazor.platform utilities may be helpful. The implementation of these are really exclusive to the WebAssembly platform (called MonoPlatform, see, e.g., an older GitHub snapshot for more details).

We find:

  • start: Begins bootstrapping WebAssembly
  • callEntryPoint: Actually does the CLR bootstrapping against Microsoft.AspNetCore.Components.WebAssembly using Microsoft.AspNetCore.Components.WebAssembly.Hosting.EntrypointInvoker
  • getArrayEntryPtr: Find the address of a field within a .NET array
  • getArrayLength: Gets the length of a .NET array
  • getObjectFieldsBaseAddress: Same as with arrays, just for a .NET object
  • readFloatField: Gets the single value from an object and an offset
  • readInt16Field: Gets the short value from an object and an offset
  • readInt32Field: Gets the int value from an object and an offset
  • readObjectField: Gets an arbitrary class instance from an object and an offset
  • readStringField: Gets the string value from an object and an offset
  • readStructField: Gets an arbitrary struct value from an object and an offset
  • readUint64Field: Gets the long value from an object and an offset
  • toUint8Array: Converts a .NET array to an Uint8Array

Long story short, these functions are used under the hood to actually convert .NET's data types to some JavaScript. Note that all these different number types are still just number in JavaScript, but need to be distinguished as they use different amounts of bytes and / or representations. For instance, both, a single floating point number (float) and a standard integer (int) are both 32 bytes, but one is using IEEE 754 while the other has no IEEE standard and follows standard weighted ordering.

A great use-case of these platform functions is to help dealing with larger files. As described by GΓ©rald BarrΓ© the actual message cost can be severely reduced using things such as BINDING.conv_string and Blazor.platform.toUint8Array.

Using this instead you'd get quite a speed up (in his sample the orange line represents the approach where we'd need to use the Blazor.platform function):

Speedup by better serialization

Doing less work can mean choosing fewer (and right) serialization schemes.

Conclusion

In this article we started with a closer look at how Blazor works internally by inspecting how Blazor is actually coupled to the DOM and what implications arise from its integration.

In the next article I'll write about how Blazor's virtual DOM actually works and how it renders things out.

Top comments (0)

πŸŒ–πŸŒ—πŸŒ˜ Turn on dark mode in Settings