Blazor WebAssembly – Unleash The Power Of Dynamic Template-Based UIs With Razor Engine

In general, you can divide template engines into two types. The relatively simple ones are using template strings with placeholders to be replaced by some concrete values. The other template engines can do everything the simple ones can but additionally provide means for control of the code flow, like if-else statements, loops, and further. In this article, I will focus on the latter by using the Razor engine inside a Blazor WebAssembly application.

In diesem Artikel:

pg
Pawel Gerr ist Architekt und Consultant bei Thinktecture. Er hat sich auf .NET Core Backends spezialisiert und kennt Entity Framework von vorne bis hinten.

Problem Scenario

The main use case for a template engine is formatting a piece of information, for example to become readable by humans. This information could be something big, like a report or a form mail, or something very small like a notification or a label in a UI.

There are several benefits to using a template engine like Razor engine in Blazor WebAssembly:

  • Razor is very powerful, so there is virtually nothing that can’t be done with the Razor engine.
  • The templates are mere strings; thus can be persisted in a database of the application or even in the user profile, so the rendered message can be user-specific.
  • Blazor WebAssembly can be used not just to display rendered templates but also to edit them. Sure, there will be no IntelliSense, but it should work for minor changes, especially if you see the outcome right away.
  • The approach works very well in offline-first mobile applications because there is no server involved in parsing and compilation of the Razor views. The required assemblies as well as the templates can be persisted in the client-side storage like IndexedDB.
  • There are no license fees. Everything we use is part of (ASP).NET Core.

Version information

  • .NET Core SDK: 3.1.402
  • ASP.NET Core Blazor WebAssembly: 3.2.1
  • Microsoft.AspNetCore.Mvc.Razor.Extensions: 3.1.9

The approach I will present is being used with .NET Core in web server environments for quite some time. Today we want to concentrate on the required changes to achieve the same, but in Blazor WebAssembly i.e., running client-side in a browser.

You can find the source of the prototypical Razor template engine integration into Blazor WebAssembly in this GitHub repository.

Please note that the template engine integration presented in this article is a prototype!

Demo Application

To get started right away, I use the demo application that comes with the SDK. For that, we create a new Blazor WebAssembly project and make sure it works properly.

				
					dotnet new blazorwasm -o BlazorApp1
cd BlazorApp1
dotnet run
				
			

Today’s demo will be a greeting of the current user using the name from the user profile. First, create a new class UserProfile with a property Name.

				
					public class UserProfile
{
   public string Name { get; set; }
}
				
			

The greeting itself is a very simple Razor template: Hello @Model.Name. I omitted any complex logic like if-else branches or loops for the sake of simplicity. I hope you believe me when I say that Razor will not have any difficulties to process such statements.

Dynamic Razor View Compilation

The core component of using Razor as a template engine inside our apps is the RazorProjectEngine. Install the Nuget package Microsoft.AspNetCore.Mvc.Razor.Extensions into your Blazor WebAssembly project to get all required dependencies.

For easier work with the template engine we introduce an abstraction Template<TModel> with one method GetRenderedTextAsync(TModel). The method gets the model (in our case the UserProfile) and returns the rendered text/html. That way we can re-use the template with different instances of TModel.

Template<TModel> creates a new instance of the Razor view on each call of GetRenderedTextAsync because the view may have some state, and we want to be thread-safe. For heavy-load applications, you might want to reuse the instance.

				
					public class Template<TModel>
  where TModel : class
{
  private readonly IServiceProvider _serviceProvider;
  private readonly Type _viewType;

  public Template(
     IServiceProvider serviceProvider,
     Type viewType)
  {
     _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
     _viewType = viewType ?? throw new ArgumentNullException(nameof(viewType));
  }

  public async Task<string> GetRenderedTextAsync(TModel model)
  {
     var razorView = (TemplateBase<TModel>)ActivatorUtilities.CreateInstance(_serviceProvider, _viewType);

     if (razorView == null)
        throw new Exception("Could not create an instance of previously compiled razor view.");

     razorView.Model = model;
     await razorView.ExecuteAsync();

     return razorView.GetRenderedText();
  }
}
				
			

In the following sections, you will see some things required by the Razor engine since the engine is not used in its natural environment.

Redirecting Razor Output to a StringBuilder

Usually, Razor writes its output to some kind of response stream (as in the default server-rendered scenario), but we need the output as a string for our use case. For that, we use a custom base class that redirects the output to a StringBuilder.

During rendering, Razor will call WriteLiteral and Write. if you want to optimize the rendering output, this is probably the best place to do that.

Please note that most of the classes we see in this article are internal but not the TemplateBase. The base class must be public so Razor can find it.

				
					public abstract class TemplateBase<TModel>
   where TModel : class
{
   public TModel? Model { get; set; }

   private readonly StringWriter _writer;

   protected TemplateBase()
   {
      _writer = new StringWriter();
   }

   public string GetRenderedText()
   {
      return _writer.ToString();
   }

   public void WriteLiteral(string literal)
   {
      _writer.Write(literal);
   }

   public void Write(object obj)
   {
      _writer.Write(obj);
   }

   public virtual Task ExecuteAsync()
   {
      return Task.CompletedTask;
   }
}
				
			

Simulation of an (Empty) File System

In Razor, a template/component/page might reference other razor templates, which may be a part of the current project residing on the file system. To find the other template, Razor uses an abstraction, RazorProjectFileSystem which works directly on the file system. In our case, we neither have a file system nor other templates we depend upon, so we provide Razor an instance of RazorProjectFileSystem simulating an empty file system.

For more advanced use cases, you might want to implement a more sophisticated simulation for the file system.

				
					internal class EmptyProjectFileSystem : RazorProjectFileSystem
{
   public static readonly EmptyProjectFileSystem Instance = new EmptyProjectFileSystem();

   public override IEnumerable<RazorProjectItem> EnumerateItems(string basePath)
   {
      return Enumerable.Empty<RazorProjectItem>();
   }

   public override RazorProjectItem GetItem(string path)
   {
      return GetItem(path, String.Empty);
   }

   public override RazorProjectItem GetItem(string path, string fileKind)
   {
      return new NotFoundProjectItem(String.Empty, path);
   }

   private class NotFoundProjectItem : RazorProjectItem
   {
      public override string BasePath { get; }
      public override string FilePath { get; }
      public override bool Exists => false;
      public override string PhysicalPath => throw new NotSupportedException();
      public override Stream Read() => throw new NotSupportedException();

      public NotFoundProjectItem(string basePath, string path)
      {
         BasePath = basePath;
         FilePath = path;
      }
   }
}
				
			

Preparing the ‚Razor Work Item‘

Razor engine is not working on the string-template directly but expecting an instance of RazorCodeDocument containing the template and some other data. This is our implementation for it:

				
					internal class VirtualRazorCodeDocument : RazorCodeDocument
{
   public override IReadOnlyList<RazorSourceDocument> Imports => Array.Empty<RazorSourceDocument>();
   public override ItemCollection Items { get; }
   public override RazorSourceDocument Source { get; }

   public VirtualRazorCodeDocument(string template)
   {
      Items = new ItemCollection();
      Source = RazorSourceDocument.Create(template, "DynamicTemplate.cshtml");
   }
}

				
			

The Razor-Based Template Engine

Finally, we reached the actual template engine. I split the class into multiple parts to point out some aspects you might want to change depending on your specific use case.

To create instances of the Razor view, the engine requires an IServiceProvider to pass it to Template<TModel>. Furthermore, we need an HttpClient to fetch assemblies, like mscorlib.dll, required for C# code compilation. 

				
					public class TemplateEngine
{
   private const string _DLL_NAME = "TemplatingEngine.DynamicCodeCompilation";

   private readonly IServiceProvider _serviceProvider;
   private readonly HttpClient _httpClient;

   public TemplateEngine(
      IServiceProvider serviceProvider,
      HttpClient httpClient)
   {
      _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
      _httpClient = httpClient ?? throw new ArgumentNullException(nameof(httpClient));
   }

				
			

The only public method CompileAsync<TModel> delegates the actual work to other methods:

  • GenerateView<TModel>: parses the string template and generates C# code.
  • CompileViewAsync<TModel>: compiles the view and returns an assembly containing the Razor view.

After the compilation is finished we search for the Razor view with the name Template, which is the default configuration.

				
					  public async Task<Template<TModel>> CompileAsync<TModel>(
      string template,
      TModel? model = null)
      where TModel : class
   {
      if (String.IsNullOrWhiteSpace(template))
         throw new ArgumentException("Template cannot be empty.", nameof(template));

      var modelRuntimeType = model?.GetType();
      var razorCodeDoc = GenerateView<TModel>(template, modelRuntimeType);
      var assembly = await CompileViewAsync<TModel>(razorCodeDoc, modelRuntimeType);

      var razorViewType = assembly.GetTypes().FirstOrDefault(t => t.Name == "Template");

      if (razorViewType == null)
         throw new Exception("Previously compiled razor view not found");

      return new Template<TModel>(_serviceProvider, razorViewType);
   }
				
			

The method GenerateView<TModel> changes the base class of the Razor view to TemplateBase<TModel>. Additionally, we need to specify all namespaces required for proper compilation.

Depending on the demands, you might need further namespaces. In this case, improve the logic for figuring out the necessary namespace, make it configurable, or let them be passed by the caller.

				
					   private static RazorCodeDocument GenerateView<TModel>(
      string template,
      Type? modelRuntimeType)
      where TModel : class
   {
      var modelTypeName = GetModelTypeName<TModel>();
      template = $@"@inherits {typeof(TemplateBase<>).Namespace}.TemplateBase<{modelTypeName}>
{template}";

      var engine = RazorProjectEngine.Create(RazorConfiguration.Default, 
                                 EmptyProjectFileSystem.Instance,
                                 builder =>
                                 {
                                    builder.SetNamespace("System");
                                    builder.SetNamespace("System.Array");
                                    builder.SetNamespace("System.Collections");
                                    builder.SetNamespace("System.Collections.Generics");

                                    var modelNamespace = modelRuntimeType?.Namespace;

                                    if (modelNamespace != null)
                                       builder.SetNamespace(modelNamespace);
                                 });

      var doc = new VirtualRazorCodeDocument(template);
      engine.Engine.Process(doc);

      return doc;
   }

   // helper methods for handling nested types
   private static string GetModelTypeName<TModel>()
      where TModel : class
   {
      var type = typeof(TModel);

      if (type.IsGenericType)
         throw new NotSupportedException($"Generic models are not supported. Model: {type.Name}");

      var name = type.FullName ?? throw new Exception($"The full name of the model type is empty. Type: '{type}'.");

      name = name.Replace("+", "."); // for nested types

      return name;
   }

				
			

The previously generated C# code will be compiled to an assembly. The core .NET libraries are required in any case. Depending on the template and the model, other assemblies may be necessary for the compilation. The most significant change in comparison to a non-WebAssembly application is how we fetch the DLLs i.e., the assemblies, in the method GetMetadataReferencesAsync.

As with namespaces, we have to figure out the required assemblies that depend on the types used in the template.

				
					   private async Task<Assembly> CompileViewAsync<TModel>(
      RazorCodeDocument razorCodeDoc,
      Type? modelRuntimeType)
      where TModel : class
   {
      var csharpDoc = razorCodeDoc.GetCSharpDocument();
      var tree = CSharpSyntaxTree.ParseText(csharpDoc.GeneratedCode);
      var assemblyLocations = new HashSet<string>
                              {
                                 "mscorlib.dll",
                                 "netstandard.dll",

                                 // add current DLL
                                 Path.GetFileName(Assembly.GetExecutingAssembly().Location),

                                 // add model DLL
                                 typeof(TModel).Assembly.Location
                              };

      if (modelRuntimeType != null)
         assemblyLocations.Add(modelRuntimeType.Assembly.Location);

      var compilation = CSharpCompilation.Create(_DLL_NAME, new[] { tree },
                             await GetMetadataReferencesAsync(assemblyLocations),
                             new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));

      await using var assemblyStream = new MemoryStream();

      var result = compilation.Emit(assemblyStream);

      if (!result.Success)
         throw new Exception($"Could not compile the provided template. Errors:{Environment.NewLine}{String.Join(Environment.NewLine, result.Diagnostics)}");

      return Assembly.Load(assemblyStream.ToArray());
   }
				
			

Usually, the assemblies are read by the file system, but when running in a browser, they are fetched from the server using the default HTTP endpoint provided by Blazor WebAssembly.

For faster compilation, you might want to cache the responses/assemblies, which can lead to higher memory consumption.

				
					   private async Task<IReadOnlyList<MetadataReference>> GetMetadataReferencesAsync(
      IEnumerable<string> assemblyLocations)
   {
      var tasks = assemblyLocations.Select(GetMetadataReferenceAsync);

      return await Task.WhenAll(tasks);
   }

   private async Task<MetadataReference> GetMetadataReferenceAsync(string name)
   {
      var responseMessage = await _httpClient
                .GetAsync($"_framework/_bin/{WebUtility.UrlEncode(name)}");
      responseMessage.EnsureSuccessStatusCode();

      return MetadataReference
                .CreateFromStream(await responseMessage.Content.ReadAsStreamAsync());
   }
} // end of the class `TemplateEngine`

				
			

Configuration and Usage of the Template Engine

To the engine in your code, register the TemplateEngine in Program.cs with the dependency injection framework so that it can be injected into any Blazor component or service.

				
					public class Program
 {
     public static async Task Main(string[] args)
     {
         var builder = WebAssemblyHostBuilder.CreateDefault(args);
         builder.RootComponents.Add<App>("app");

         builder.Services.AddScoped(sp => new HttpClient {
                BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) });

         // registration of the template engine
         builder.Services.AddScoped<TemplateEngine>();

         await builder.Build().RunAsync();
     }
 }
				
			

To test the template engine, replace the content of the file Index.razor with the following code.

Please adjust the namespace import @using BlazorApp1.Templating accordingly.

				
					@page "/"
@using BlazorApp1.Templating
@inject TemplateEngine TemplateEngine

<div>
    <strong>Template:</strong> @_TEMPLATE
</div>
<div>
    <strong>Profile.Name:</strong> <input value="@_profile.Name" @oninput="CreateBinder()"/>
</div>

<h3>Rendered message</h3>
@_renderedMessage

@code
{
    private const string _TEMPLATE = "Hello @Model.Name";

    private UserProfile _profile;
    private Template<UserProfile> _compiledTemplate;
    private string _renderedMessage;

    protected override async Task OnInitializedAsync()
    {
        _profile = new UserProfile { Name = "John" };
        _compiledTemplate = await TemplateEngine.CompileAsync(_TEMPLATE, _profile);

        await RenderMessageAsync();
    }

    private async Task RenderMessageAsync()
    {
        _renderedMessage = await _compiledTemplate.GetRenderedTextAsync(_profile);
    }

    private EventCallback<ChangeEventArgs> CreateBinder()
    {
        return EventCallback.Factory.CreateBinder<string>(this, 
                async value =>
                      {
                          _profile.Name = value;
                          await RenderMessageAsync();
                      }, _profile.Name);
    }
}
				
			

Demo in Action

Here is a short animation to give you an idea of how fast the instantiation and rendering of the Razor view are. There is no noticeable delay between me changing the name and the displaying of the output by Blazor WebAssembly. The compilation itself took about 2 seconds for the 1st time after the start of the Blazor application and < 1 second for further compilations, despite being a prototype without any fine-tuning.

Summary

In this article, I wanted to point out two things:

  1. How to build a prototype of a template engine using built-in means, and
  2. The capabilities of Blazor WebAssembly, beyond simple static forms-over-data.

As you can see, it is remarkable how easy dynamic code compilation can be achieved when running in a browser.

Kostenloser
Newsletter

Aktuelle Artikel, Screencasts, Webinare und Interviews unserer Experten für Sie

Verpassen Sie keine Inhalte zu Angular, .NET Core, Blazor, Azure und Kubernetes und melden Sie sich zu unserem kostenlosen monatlichen Dev-Newsletter an.

Newsletter Anmeldung
Diese Artikel könnten Sie interessieren
Database Access with Sessions
.NET
KP-round

Data Access in .NET Native AOT with Sessions

.NET 8 brings Native AOT to ASP.NET Core, but many frameworks and libraries rely on unbound reflection internally and thus cannot support this scenario yet. This is true for ORMs, too: EF Core and Dapper will only bring full support for Native AOT in later releases. In this post, we will implement a database access layer with Sessions using the Humble Object pattern to get a similar developer experience. We will use Npgsql as a plain ADO.NET provider targeting PostgreSQL.
15.11.2023
Old computer with native code
.NET
KP-round

Native AOT with ASP.NET Core – Overview

Originally introduced in .NET 7, Native AOT can be used with ASP.NET Core in the upcoming .NET 8 release. In this post, we look at the benefits and drawbacks from a general perspective and perform measurements to quantify the improvements on different platforms.
02.11.2023
ASP.NET Core
favicon

Architektur-Modernisierung: Migration von WCF zu gRPC mit ASP.NET Core – ein pragmatischer Ansatz

Viele Projekte mit verteilten Anwendungen in der .NET-Welt basieren noch auf der Windows Communication Foundation (WCF). Doch wie kommt man weg von der "Altlast" und wie stellt man seinen Code auf sowohl moderne als auch zukunftssichere Beine? Eine mögliche Lösung ist gRPC.

13.04.2023
Blazor
favicon

gRPC Code-First mit ASP.NET Core 7 und Blazor WebAssembly

Wie in allen anderen browserbasierten Single-Page-Application (SPA) Frameworks, ist Blazor WebAssembly JSON-over-HTTP (über Web- oder REST-APIs) die bei weitem häufigste Methode, um Daten auszutauschen und serverseitige Vorgänge auszulösen. Der Client sendet eine HTTP-Anfrage mit JSON-Daten an eine URL, mitunter über unterschiedliche HTTP-Verben. Anschließend führt der Server eine Operation aus und antwortet mit einem HTTP-Statuscode und den resultierenden JSON-Daten. Warum sollte man das ändern? Nun, es gibt Gründe - vor allem wenn man in einem geschlossenen System ist und .NET sowohl im Frontend als auch im Backend einsetzt.
30.03.2023
Blazor
favicon

Blazor WebAssembly in .NET 7: UI-Performance-Optimierung auf Komponentenebene

Stockende UI, keine Reaktion nach dem Klick auf einen Button oder einer Eingabe in einem Feld - dies sind nur wenige Beispiele alltäglicher Probleme, die der Nutzung von Client-Anwendungen im Allgemeinen, und bei Webanwendungen im Speziellen, immer wieder auftreten können. In diesem Artikel schauen wir uns an, wie wir komponentenbasierte UIs in Blazor WebAssembly optimieren können, um dadurch eine für die Benutzer zufriedenstellende Geschwindigkeit und ein flüssiges UI zu bekommen.
29.03.2023
Blazor
sg

Understanding and Controlling the Blazor WebAssembly Startup Process

There are a lot of things going on in the background, when a Blazor WebAssembly application is being started. In some cases you might want to take a bit more control over that process. One example might be the wish to display a loading screen for applications that take some time for initial preparation, or when users are on a slow internet connection. However, in order to control something, we need to understand what is happening first. This article takes you down the rabbit hole of how a Blazor WASM application starts up.
07.03.2023