Prerender mit Blazor WebAssembly (WASM) in ASP.Net Core Teil 2 Deep Dive 

Im ersten Teil ging es um das Einbauen von Prerender in einer Blazor WebAssembly Anwendung. Ich empfehle zuerst den ersten Teil zu lesen, um die Seiteneffekte, die entstehen, besser nachzuvollziehen. 

Ich gehe etwas tiefer in die Zusammenhänge mit dem Prerender von Blazor WebAssembly ein. Wie auch im ersten Teil, ist eine ASP.NET Core Anwendung der Host für Blazor WebAssembly. 

Dependency Injection 

Eine Exception wird ausgelöst, wenn eine Razor Page vorgeladen wird, welche Services verwendet um Daten vom Server zu laden. Grund dafür ist, dass der Service auf dem Host nicht registriert ist.  

Eine unerwartete Exception wurde ausgelöst, weil der HttpWeatherService nicht Registriert ist.

Beispiel Weather.razor.cs, eine Razor Page welche öffentliche Wetterdaten von einem Service abfragt:

public partial class Weather
{
    private WeatherForecast[]? weatherForecast;

    [Inject]
    public HttpWeatherService WeatherService { get; set; }

    protected override async Task OnParametersSetAsync()
    {
        this.weatherForecast = await this.WeatherService.Get();
    }
}

Ein möglicher Ansatz ist, den gleichen Service auch auf dem Server zu registrieren. In diesem Beispiel hat der WeatherService einen HttpClient Injected, welcher ebenfalls auf dem Server registriert werden muss. Dies ist auch für alle weiteren abhängigen Services der Fall.

Ein anderer Ansatz ist, den Service auf dem Client als Interface zu injecten und auf dem Server eine alternative Implementierung ohne HttpClient zu verwenden.  

Neu implementiert die Weather Razor Page ein Interface: 

  [Inject] 
  protected IWeatherService WeatherService { get; set; } 

Die Implementierung mit dem HttpClient wird in der Blazor WASM App registriert: 

builder.Services.AddScoped<IWeatherService, HttpWeatherService>(); 

Und auf dem Server eine andere ohne HttpClient: 

builder.Services.AddTransient<IWeatherService, ServerWeatherService>();

Möglich ist es jetzt beim serverseitigen Rendern einer Razor Page eine separate Implementierung zu haben, da die Razor Page keine feste Abhängigkeit zum Service hat. Somit können die Daten beim Vorladen (Prerender) auf dem Host, ohne erneut einen Http Request auszuführen, direkt aus der Datenquelle abgerufen werden. 

Achtung: Es ist nicht empfehlenswert für Daten die eine Authentifizierung oder Autorisierung vom Benutzer benötigen. Mehr dazu unter Sicherheitsaspekte. 

Es gilt zu beachten, bei welchen Services eine eigene «Prerender Implementierung» bzw “Server Implementierung” Sinn ergibt. Da ab einem gewissen Punkt in diesem Beispiel alles doppelt implementiert werden muss. Schlussendlich geht es darum das SEO (Suchmaschinenoptimierung) und die Performance für die Suchmaschinen-Bots zu optimieren.  

Persist Component State 

Dies ist ab .NET 6 möglich.

Nachdem die Blazor WASM App vollständig geladen ist, gibt es ein «Update» der Seite. Sozusagen übernimmt Blazor WASM die Anwendung und ersetzt die App Component, was zu einem doppelten Aufruf der Seite und erneutem Laden der Daten führt.

Das erneute Laden der Seite selbst kann nicht verhindert werden, da Blazor die Events und Links verknüpfen muss. Daten allerdings, welche die Component während dem Prerender von Services geladen hat, können mit einer zusätzlichen Implementierung beibehalten werden. 

Als erstes in der _Host.cshtml den Tag-Helper nach der App Component hinzufügen: 

<component type="typeof(App)" render-mode="@renderMode"/> 

<persist-component-state /> 

In den Razor Page eine PersistingComponentStateSubscription hinzufügen und den PersistentComponentState injecten: 

 private PersistingComponentStateSubscription persistingState; 

 [Inject]
 protected PersistentComponentState ApplicationState { get; set; } 

Bevor die Daten geholt werden, die Subscription mit einer Methode registrieren, welche die Daten in den ApplicationState speichert: 

protected override async Task OnParametersSetAsync()
{
    try
    {
        this.persistingState = this.ApplicationState.RegisterOnPersisting(this.PersistWeatherInformation);
        
        if (this.ApplicationState.TryTakeFromJson<WeatherForecast[]>("weatherForecast", out var forecast))
        {
            this.weatherForecast = forecast;
        }
        else
        {
            this.weatherForecast = await this.WeatherService.Get();
        }
    }
    catch (Exception e)
    {
        this.persistingState.Dispose();
        // Exception Handling hier
    }
}

private Task PersistWeatherInformation()
{
    this.ApplicationState.PersistAsJson("weatherForecast", this.weatherForecast);
    return Task.CompletedTask;
}

public void Dispose()
{
    this.persistingState.Dispose();
    GC.SuppressFinalize(this);
}

Bei einer Exception ist es wichtig die Subscription zu disposen. Ansonsten kann Blazor bei Rerendern den Fehler nicht beheben.

Das Resultat ist, dass die Daten nicht doppelt geladen werden. Dies kann auch in den Developer Tools verifiziert werden, indem kein neuer Http Request für die Daten stattfindet.

Sicherheitsaspekte 

Die weiter oben erwähnte doppelte Implementierung für Services auf dem Server und Client ist nicht empfehlenswert für geschützte Daten.   

Folgende Abbildung zeigt einen vereinfachten Ablauf einer Datenabfrage in Blazor WASM:

Die Abbildung zeigt den Ablauf einer Datenabfrage vom Client mit Webassembly zu einer API auf dem Server

Wie in der Abbildung zu sehen ist, werden die Daten über einen Injected Service abgerufen, welcher einen Http Request an einen API-Endpunkt sendet, um geschützte Daten nur für den Admin abzufragen. Die Authentifizierung und Autorisierung findet hier über den Host also ASP.NET Core statt. 

Hier ist der Ablauf wenn eine Admin Seite vorgeladen wird:

Abbildung zeigt den Ablauf einer Abfrage vom Client zum Server mit aktivierten Prerendering

Wie gezeigt, werden die Daten ohne Http Request geladen, was dazu führen kann, dass keine Authentifizierung oder Autorisierung durch den Host vorgenommen wird. Folglich könnten bei einer Implementierung geschützte Daten ohne entsprechende Prüfung geladen werden. In OWASP fällt das aktuell in die Kategorie A01:2021 – Broken Access Control

Um dies zu vermeiden, ist ein möglicher Ansatz, das Vorladen für bestimmte Seiten zu deaktivieren. 

Prerender für bestimmte Seiten 

In einem Projekt kann es von Vorteil sein, die Hauptseite und Seiten, welche oft direkt vom Benutzer aufgerufen werden, vorzuladen. Diese können von Suchmaschinen mit den fachlichen Informationen besser indexiert werden. 

Es kann Razor Pages geben, bei denen ein Vorladen nicht möglich ist. Ein Beispiel, wenn Seiten die JSRuntime zur Seiteninitialisierung verwenden. Diese auf dem Server zu laden, führt zu einer JavaScript Exception. Somit muss gegebenenfalls das Vorladen deaktiviert werden.  In diesem Beispiel überprüfe ich die Route in der _Host.cshtml Page und bestimme dann, ob ein Prerender ausgeführt werden soll und zeige stattdessen einen Ladebildschirm an: 

@{
    var path = HttpContext.Request.Path.Value.ToLower();

    if (path.Contains("/fetchdata"))
    {
        <component type="typeof(App)" render-mode="WebAssembly" />

        <component type="typeof(PrerenderSplashScreen)"
                   render-mode="WebAssemblyPrerendered" />
    }
    else
    {
        <component type="typeof(App)" render-mode="WebAssemblyPrerendered" />
        <persist-component-state />
    }
}

Mit dem Render Mode WebAssembly wird nur der Platzhalter für die App Component zurückgegeben, anstatt die App Component zu laden. Die PrerenderSplashScreen Component muss mit Render Mode WebAssemblyPrerendered geladen werden. Die Component überprüft anhand IJSRuntime ob sie den Splash Screen anzeigt.

@if (Js is not IJSInProcessRuntime)
{
    <div style="margin: auto; height: 100%;">
        <h2>Bitte Warten</h2>
    </div>
}

Ergebnis:

Es gibt auch andere Möglichkeiten auszuwerten für welche Seite ein Vorladen möglich sein soll, da der gesamte HttpContext zu Verfügung steht. Je nach Anwendungsfall kann es verschiedene Kriterien geben, die ein Prerender verunmöglichen oder zu aufwändig erscheint. 

HeadOutlet für SEO Meta Tags

Mit HeadOutlet wird eine RootComponent in der Blazor Anwendung registriert, in welcher der HeadContent einer Razor Page am Ende des head Tag eingesetzt wird. Das Prinzip ist das gleiche wie bei der App Component: 

builder.RootComponents.Add<HeadOutlet>("head::after"); 

In der Razor Page wird der HeadContent gesetzt mit Tags wie: title, link und meta: 

<HeadContent>
    <title>Weather forecast</title>
    <link href="..." rel="..." />
    <meta content="Weather Forecast Daten"/>
</HeadContent>

Einige Anpassungen, um das gleiche Verhalten beim Prerender zu erlangen.

Das Registrieren der Head::After Root Component aus dem Program.cs der Client Anwendung entfernen und stattdessen die Component im _Host.cshtml im Head Tag ganz am Ende einfügen.

Program.cs:

//builder.RootComponents.Add<HeadOutlet>("head::after"); 

_Host.cshtml:

<head> 
@*<title>Prerender</title>*@
… 
<component type="typeof(HeadOutlet)" render-mode="WebAssemblyPrerendered" /> 
</head> 

Der Standard Titel in der _Host.cshtml sollte entfernt werden, damit es nicht doppelt gesetzt wird.

SEO-Bots können nun Meta Tags und Beschreibungen von einzelnen Seiten auslesen. Dieser Ansatz ermöglicht auch eine bessere Social Media-Integration von Inhalten. 

Blockiertes UI 

Blazor WASM Interaktionen können bis zum vollständigen Laden der Anwendung vom Benutzer nicht verwendet werden. Hierfür gibt es zurzeit keine ideale Lösung.

Damit der Benutzer keine Schaltflächen ohne Aktion betätigt, zeige ich stattdessen eine Ladeanimation an. Dies mit einer individuellen Razor Component: 

@if (Js is IJSInProcessRuntime) 
{ 
    @Client 
} 
else 
{ 
    @Server 
} 
@code{ 
    [Inject] 
    public IJSRuntime Js { get; set; } 

    /// <summary> 
    /// Content der angezeigt wird, wenn Blazor noch am laden ist (Prerender) 
    /// </summary> 
    [Parameter] 
    public RenderFragment Server { get; set; } 

    /// <summary> 
    /// Content der angezeigt wird, wenn Blazor vollständig geladen ist 
    /// </summary> 
    [Parameter] 
    public RenderFragment Client { get; set; } 
} 

Somit kann ich den Benutzer durch eine Animation darüber informieren, dass die Aktionen noch nicht zur Verfügung stehen. Sobald Blazor WASM vollständig geladen ist, wird die Animation deaktiviert. Das kann helfen tote Klicks zu vermeiden.

<Prerender>
    <Server>
        <div>
            <p>Bitte Warten...</p>
        </div>
    </Server>
    <Client>
        <button @onclick="BlazorClickEvent">Klick mich!</button>
    </Client>
</Prerender>

Zusammenfassung

Razor Pages vorladen mit abhängigen Services erfordert eine seperate Implementierung auf dem Server, sowie ein Interface das die Services abstrahiert. Dabei muss auf die Sicherheitsaspekte geachtet werden, um nicht in eine Broken Access Control Situation zu geraten.

Ab .NET 6 können die vom Host vorgeladenen Daten, in der Blazor App beibehalten werden. Dies führt zu einer Verbesserung der Benutzerfreundlichkeit.

Bestimmte Seiten können vom Prerendern ausgeschlossen und stattdessen ein Splash Screen angezeigt werden, was auch für die Sicherheit z.B. bei Admin Seiten von Vorteil ist.

Mit der HeadOutlet und HeadContent Component können wichtige Meta Tags, Links und Titel für spezifische Razor Pages gesetzt werden.

Mit einem selbst implementierten Workaround können die vom Vorladen blockierten Blazor Interaktionen durch Ladeanimationen oder Text ersetzt werden.

Ausblick Blazor .NET 8

Mit .NET 8 kann sich Prerender drastisch ändern. Angekündigt wurde eine RenderMode Klasse, welche den neuen Modus “Auto” hat.

Mit dem Modus, der für einzelne Razor Pages gesetzt wird, wird beim initialen Request die angeforderte Razor Page als Blazor Server zurückgegeben, während im Hintergrund die benötigten WASM Dateien geladen werden, ähnlich wie bei Prerender. Es unterscheidet sich aber dadurch, dass die Blazor Interaktionen bereits funktionieren. Sobald Blazor WASM geladen ist, so heisst es, wird automatisch auf WebAssembly umgeschaltet.

Ich werde dieses Beispiel mit dem finalen Release von .NET 8 im November anpassen und die Neuerungen prüfen.

Siehe dazu das Beispiel Projekt auf Github aus der Vorankündigung von Visual Studio Live 2023.