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 this article:

pg
Pawel Gerr is architect consultant at Thinktecture. He focuses on backends with .NET Core and knows Entity Framework inside out.

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.

Free
Newsletter

Current articles, screencasts and interviews by our experts

Don’t miss any content on Angular, .NET Core, Blazor, Azure, and Kubernetes and sign up for our free monthly dev newsletter.

EN Newsletter Anmeldung (#7)
Related Articles
Angular
SL-rund
If you previously wanted to integrate view transitions into your Angular application, this was only possible in a very cumbersome way that needed a lot of detailed knowledge about Angular internals. Now, Angular 17 introduced a feature to integrate the View Transition API with the router. In this two-part series, we will look at how to leverage the feature for route transitions and how we could use it for single-page animations.
15.04.2024
.NET
KP-round
.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
.NET
KP-round
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