In der Entwicklung von Webanwendungen und so auch in der Entwicklung von Blazor WebAssembly SPAs ist es wichtig, die Laufzeitperformance der Anwendung immer im Auge zu behalten und wenn nötig zu optimieren. Daher schauen wir uns in diesem Artikel die Optimierungsmöglichkeiten einer Blazor-Komponente etwas genauer an.
ZUM NEWSLETTER
Regelmäßig News zur Konferenz und der .NET-Community
Renderlebenszyklus einer Komponente
die Demoanwendung läuft mit dem .NET SDK 6.0.201, ASP.NET Core Blazor WebAssembly 6.0.4 und MudBlazor 6.0.9. Der Beispielcode für diesen Artikel findet sich unter [1]. Bevor wir aber mit der Optimierung von Komponenten beginnen können, ist es wichtig zu verstehen, welchen Lebenszyklus eine Komponente beim Rendern durchläuft und wie dieser ausgelöst werden kann.
Lebenszyklus
Der Lebenszyklus einer Blazor-Komponente beginnt, wenn sie auf der Seite gerendert wird. Sie wird zum ersten Mal sichtbar.
Abb. 1: Blazor-WebAssembly-Komponenten-Rendering-Lebenszyklus
In Abbildung 1 sehen wir die verschiedenen Schritte, die durchlaufen werden, sobald das Rendering einer Komponente angestoßen wird. Im ersten Schritt wird geprüft, ob es sich um das initiale Rendern der Komponente handelt oder die Komponente andernfalls neu gerendert werden soll. Das wird anhand von zwei Optionen geprüft:
-
Innerhalb der Basisklasse einer Blazor-Komponente, der ComponentBase-Klasse [2], wird geprüft, ob es sich um das erste Rendern der Komponente handelt.
-
Handelt es sich nicht um das erste Rendering, wird die Methode ShouldRender(), die im nächsten Abschnitt näher erläutert wird, der Basisklasse aufgerufen. Die Methode gibt einen bool-Wert zurück, der angibt, ob der Renderprozess durchgeführt werden soll. Der Defaultrückgabewert ist true.
Trifft keine der beiden Optionen zu, wird der Renderprozess beendet (2a in Abb. 1). Andernfalls wird ein neuer Render Tree erstellt (2b). Der Render Tree beschreibt die HTML-Elemente des HTML-Dokuments, die in der Blazor-Welt aktualisiert werden sollen. Danach wird dieser an das Document Object Model (DOM) weitergeben. Anhand des Render Trees werden die Änderungen im DOM aktualisiert. Ist das Update abgeschlossen, werden schließlich die Methoden OnAfterRender und OnAfterRenderAsync aufgerufen und der Prozess ist beendet.
Hinweis: Der Render Tree beinhaltet nur die Änderungen der Komponenten, die sich im Vergleich zum aktuell gerenderten Zustand im DOM geändert haben. Dadurch wird nicht bei jedem Renderprozess das DOM-Element vollständig ersetzt, es werden nur die aktuellen Änderungen im DOM aktualisiert.
Weitere Informationen und Details über den Lebenszyklus von Blazor-Komponenten finden sich bei meinem Kollegen Pawel Gerr [3] in seinem englischen Artikel „Blazor Components Deep Dive – Lifecycle Is Not Always Straightforward“ [4].
Was kann ein Re-Rendering auslösen?
Nachdem wir gesehen haben, welchen Prozess eine Komponente beim Rendern durchläuft, schauen wir uns jetzt an, was den Renderprozess einer Komponente auslösen kann:
-
Das erste Rendern findet, wie zu erwarten, beim Initialisieren einer Blazor-Komponente statt.
-
Das zweite Szenario, bei der die Komponente neu gerendert wird, ist die Änderung von Parametern. Dabei wird zwischen eigenen Parametern und Parametern der übergeordneten Komponente unterschieden:
-
SetParametersAsync: Diese Methode wird aufgerufen, sobald Parameter des übergeordneten Elements geändert wurden oder ein Route-Parameter in den URL gesetzt wird.
-
OnParametersSet/OnParametersSetAsync: Diese Methoden werden aufgerufen, sobald sich die Parameter der Komponente ändern oder die übergeordnete Komponente neu gerendert wird.
-
-
Wird ein DOM-Event ausgelöst, beispielsweise durch ein onclick-Event, wird die Komponente ebenfalls neu gerendert. Aber nicht nur die Komponente, bei der das Event ausgelöst wurde, sondern auch alle untergeordneten Elemente.
-
Zuletzt kann der Prozess über die Methode StateHasChanged aus der Basisklasse ComponentBase der Renderprozess angestoßen werden. Die Methode StateHasChanged() benachrichtigt die Komponente, dass sich der aktuelle Status geändert hat. Das führt dann dazu, dass die Komponente neu gerendert wird.
Optimierung des Renderprozesses
Betrachtet man die unterschiedlichen Szenarien, wird ersichtlich, dass der Renderprozess sehr oft getriggert werden kann. Doch ist das wirklich immer notwendig? Im weiteren Verlauf dieses Artikels schauen wir uns mögliche Optionen an, durch die wir den Renderprozess einer Komponente optimieren können.
ShouldRender überschreiben
Die erste Option, die wir nutzen können, um den Renderprozess zu unterbinden, ist, die Methode ShouldRender zu überschreiben. Wie wir in Abbildung 1 gesehen haben, wird in Schritt 2, sobald es sich nicht um das initiale Rendering handelt und die Methode ShouldRender ein false zurückgibt, der Renderprozess beendet. Die Methode ShouldRender ist eine Methode der Basisklasse ComponentBase. Der Defaultrückgabewert der Methode ist true. Infolgedessen wird bei jedem Rendervorgang der Prozess durchgeführt. Nehmen wir beispielsweise ein Formular. Wird in einem Formular ein Feld geändert, wird dadurch der Renderprozess der Komponenten angestoßen. Das führt dazu, dass zusätzlich auch all die Komponenten neu gerendert werden, die sich im Formular befinden. In einem Formular ist es natürlich wichtig, zu beachten, ob das aktuelle Feld Abhängigkeiten zu anderen Feldern hat. Hat das Feld keine Abhängigkeiten, muss auch nicht bei jeder Änderung des Felds neu gerendert werden. Um das zu vermeiden, können wir die ShouldRender-Methode überschreiben (Listing 1).
Listing 1
// CustomInputText.razor.cs
private int _valueHashCode;
protected override bool ShouldRender()
{
var lastHashCode = _valueHashCode;
_valueHashCode = Value?.GetHashCode() ?? 0;
return _valueHashCode != lastHashCode;
}
In Listing 1 sehen wir, dass anhand des HashCode die Property Value der Komponente geprüft wird, ob sich diese geändert hat. Ist das nicht der Fall, gibt die Methode false zurück und der Renderprozess wird beendet. Sollte die Komponente keine Properties haben, die sich ändern, kann hier natürlich auch immer direkt false zurückgegeben werden.
Das ist eine Möglichkeit, den Renderprozess frühzeitig zu beenden, wenn es nicht zwingend notwendig ist, die Komponente neu zu rendern. Im nächsten Abschnitt schauen wir uns an, wie wir beim Auslösen eines Events den Renderprozess optimieren können.
IHandleEvent implementieren
Wird ein Event ausgelöst, wird die Methode StateHasChanged der ComponentBase-Klasse aufgerufen. Das führt dazu, dass der Renderprozess der Komponente angestoßen wird. Das hat zwar den Vorteil, dass der Entwickler die Methode StateHasChanged nicht selbst aufrufen muss, wenn ein Event angestoßen wird. Jedoch hat es den Nachteil, dass die Komponente auch ohne jegliche Änderungen neu gerendert wird. Um in diesen Prozess einzugreifen, kann das Interface IHandleEvent implementiert werden. Über das Interface wird die Methode HandleEventAsync implementiert, die bei einem Event der Komponente aufgerufen wird.
Listing 2
// HandleEventInputText.razor.cs
public partial class HandleEventInputText : ComponentBase, IHandleEvent
{
private bool _preventRender;
public Task HandleEventAsync(EventCallbackWorkItem item, object? arg)
{
try
{
var task = item.InvokeAsync(arg);
var shouldAwaitTask = task.Status != TaskStatus.RanToCompletion && task.Status != TaskStatus.Canceled;
if (!_preventRender)
{
StateHasChanged();
}
return shouldAwaitTask
? CallStateHasChangedOnAsyncCompletion(task, _supressRender)
: Task.CompletedTask;
}
finally
{
_preventRender = false;
}
}
private async Task CallStateHasChangedOnAsyncCompletion(Task task, bool preventRender)
{
try
{
await task;
}
catch
{
if (task.IsCanceled)
{
return;
}
throw;
}
if (!preventRender)
{
StateHasChanged();
}
}
void PreventRender()
{
_preventRender = true;
}
}
<label>
@Label <input id="@Id" class="form-control @CssClass" type="text" placeholder="Override ShouldRender" />
</label>
In Listing 2 sehen wir die Klasse HandleEventInputText, die von der Basisklasse ComponentBase ableitet und das Interface IHandleEvent implementiert. In der Methode HandleEventAsync wird anhand der Property _preventRender geprüft, ob die Komponente gerendert werden soll oder nicht. Dadurch haben wir in der Komponente die Möglichkeit, bei einem Event die Methode PreventRender aufzurufen, wie wir zuvor im Codebeispiel beim Event oninput sehen konnten. Infolgedessen bekommt die Property _preventRender den Wert true zugewiesen. Solange die Property den Wert true besitzt, wird der Renderprozess nicht angestoßen.
Event Utilities
Eine weitere Variante, das erneute Rendern bei Events zu vermeiden, ist der Einsatz der Hilfsklasse EventUtil [5], die von Microsoft empfohlen wird (Listing 3).
Listing 3
public static class EventUtil
{
public static Action AsNonRenderingEventHandler(Action callback)
=> new SyncReceiver(callback).Invoke;
public static Action AsNonRenderingEventHandler(Action callback)
=> new SyncReceiver(callback).Invoke;
public static Func AsNonRenderingEventHandler(Func callback)
=> new AsyncReceiver(callback).Invoke;
public static Func<TValue, Task> AsNonRenderingEventHandler(Func<TValue, Task> callback)
=> new AsyncReceiver(callback).Invoke;
private record SyncReceiver(Action callback)
: ReceiverBase { public void Invoke() => callback(); }
private record SyncReceiver(Action callback)
: ReceiverBase { public void Invoke(T arg) => callback(arg); }
private record AsyncReceiver(Func callback)
: ReceiverBase { public Task Invoke() => callback(); }
private record AsyncReceiver(Func<T, Task> callback)
: ReceiverBase { public Task Invoke(T arg) => callback(arg); }
private record ReceiverBase : IHandleEvent
{
public Task HandleEventAsync(EventCallbackWorkItem item, object arg) => item.InvokeAsync(arg);
}
}
Die Hilfsklasse stellt die Methode AsNonRenderingEventHandler bereit. Die Methode kann genutzt werden, um die Func oder Action zwar auszuführen, aber kein erneutes Rendering des Event Handlers auszulösen.
Listing 4
Beim Klick auf diesen Button wird kein neues Rendering ausgelöst
</MudButton>
In Listing 4 ist ein Button zu sehen, der einen OnClick Event Handler mit Hilfe der Methode AsNonRenderingEventHandler aufruft. Ähnlich wie im vorherigen Abschnitt wird auch hier das IHandleEvent-Interface eingesetzt, wie es beim Datentyp ReceiverBase zu sehen ist. Der Unterschied zum vorherigen Ansatz liegt darin, dass das Rendering direkt vermieden wird und vorab nicht geprüft werden kann, ob gerendert werden soll oder nicht. Im nächsten Abschnitt schauen wir uns an, wie wir mit dem Einsatz von JavaScript ein DOM Event verzögern können, um dadurch die Anzahl der Rendervorgänge zu reduzieren.
SIE LIEBEN UI?
Entdecken Sie die BASTA! Tracks
Debounce DOM Event
Hier vorab ein kleiner Hinweis: In diesem Abschnitt wird JavaScript-Interop eingesetzt. Da wir uns in diesem Artikel aber auf die Optimierung von Komponenten fokussieren, gehe ich nicht weiter auf JS-Interop ein. Mehr Informationen hierzu finden sich unter [6].
Ein aus der JavaScript-Welt bekanntes Verfahren, um das Anstoßen von Events zu verzögern, ist das Debouncing. Beim Debounce-Verfahren wird der Event Callback erst dann ausgeführt, wenn nach einer bestimmten Zeitspanne keine neuen Events mehr geworfen werden. Das heißt, wenn wir z. B. in einem Textfeld tippen, wird das Event oninput erst dann gefeuert, sobald nicht mehr getippt wird und eine selbst gesetzte Zeitspanne abgelaufen ist. Dies hat den Vorteil, dass das Event nur einmal ausgeführt wird. (Abb. 2 und 3).
Abb. 2: Rendering-Anzahl ohne Debouncing
Abb. 3: Rendering-Anzahl mit Debouncing
Um das Debounce-Verfahren in einer Blazor-WebAssembly-Komponente einzusetzen, müssen wir sowohl im C#-Code als auch im JavaScript-Code Methoden implementieren.
C#-/Razor-Code
Betrachten wir zunächst den C#-Code in Listing 5. In der Methode OnAfterRenderAsync wird beim initialen Rendering mit Hilfe von JS-Interop die Funktion onDebounceInput in JavaScript aufgerufen. Zum Aufrufen werden zwei Referenzen erwartet:
-
Die erste Referenz ist eine Objektreferenz der Komponente selbst, die mit Hilfe der Klasse DotNetObjectReference erstellt werden kann. Hinweis: Wird ein Disposeable-Objekt in einer Komponente genutzt, muss das Interface IDisposable implementiert werden. In der Methode Dispose müssen dann alle Objekte zerstört werden.
-
Die zweite Referenz verweist auf das HTML-Objekt. Im Codebeispiel wird hierfür im HTML-Code das Attribut @ref genutzt, um eine ElementReference im C#-Code zu erstellen.
Die anderen beiden Parameter, um die JavaScript-Methode aufzurufen, sind natürlich der Name der Methode und das Intervall für das Verzögern des Events. Weiter schauen wir uns die HandleOnInput-Methode näher an. Diese wird vom JavaScript-Code aufgerufen, sobald das Event geworfen wurde. Damit der JavaScript-Code die Methode aufrufen kann, wird das JSInvokable-Attribut benötigt. In der Methode selbst wird lediglich die Methode StateHasChanged aufgerufen, sobald sich der Wert der Property Value geändert hat.
Listing 5
// DebounceTextArea.razor.cs
public partial class DebounceTextArea : IDisposable
{
//... Code above
[Inject] public IJSRuntime JS { get; set; }
private IJSObjectReference _module;
private ElementReference _textareaElement;
private DotNetObjectReference _selfReference;
// JavaScript-Datei laden
protected override async Task OnInitializedAsync()
{
_module = await JS.InvokeAsync("import","./Components/FormFields/DebounceTextArea.razor.js");
await base.OnInitializedAsync();
}
// JavaScript Event registrieren
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
_selfReference = DotNetObjectReference.Create(this);
// Event wird nach 500 ms geworfen
var minInterval = 500;
// JavaScript-Code aufrufen mit dem HTML-Element,
// einer ObjectReferenz und dem Timeout bis das Event geworfen wird
await _module.InvokeVoidAsync("onDebounceInput", _textareaElement, _selfReference, minInterval);
}
Console.WriteLine("Debounced TextArea: After Render called.");
}
// Methode die vom JavaScript-Code aufgerufen werden kann.
// Als Parameter wird der aktuelle Wert der TextArea übergeben.
[JSInvokable]
public void HandleOnInput(string value)
{
Console.WriteLine($"TextChanged {Value}. JS Value {value}");
if (Value != value)
{
StateHasChanged();
}
}
//... Code below
public void Dispose() => _selfReference?.Dispose();
}
<label>
@Label <textarea @ref="_textareaElement" placeholder="Debounce input" class="form-control @CssClass" id="@Id" @bind="CurrentValue">
</label>
JavaScript-Code
Um die Methode HandleOnInput, die wir im vorigen Abschnitt gesehen haben, aufrufen zu können, müssen wir die JavaScript-Funktion onDebounceInput hinzufügen. Diese wird dann aus dem C#-Code via JS-Interop aufgerufen.
Listing 6
// DebounceTextArea.razor.js
export function onDebounceInput(elem, component, interval) {
elem.addEventListener('input', debounce(e => {
component.invokeMethodAsync('HandleOnInput', e.target.value);
}, interval));
}
function debounce(func, timeout = 300) {
let timer;
return (...args) => {
clearTimeout(timer);
timer = setTimeout(() => { func.apply(this, args); }, timeout);
};
}
Wie wir in Listing 6 sehen, wird im JavaScript-Code die Funktion onDebounceInput implementiert. Diese finden wir auch im C#-Code wieder, wenn wir nochmal auf die Methode OnAfterRenderAsync schauen. Hier wird, nachdem alle Parameter definiert wurden, die Funktion mit der folgenden Codezeile aufgerufen:
// DebounceTextArea.razor.cs
await JS.InvokeVoidAsync("onDebounceInput", _textareaElement, _selfReference, minInterval);
Nachdem die C#-Methode InvokeVoidAsync aufgerufen wurde, wird via JS-Interop die JavaScript-Funktion onDebounceInput aufgerufen. Die Funktion registriert einen EventListener der auf das Event input hört. Mit Hilfe der debounce-Funktion, wird der Aufruf der C#-Methode HandleOnInput so lange verzögert, bis das Intervall abgelaufen ist, das startet, sobald nicht mehr im Textfeld getippt wird.
Bevor wir uns nun im nächsten Abschnitt mit dem Darstellen von Listen beschäftigen, hier noch ein Hinweis: Das Überschreiben von ShouldRender oder das Implementieren des Interface IHandleEvent kann mit dem Debounce-Verfahren auch kombiniert werden.
Listenoptimierung mit Virtualize
Nachdem wir Möglichkeiten gesehen haben, um den Renderprozess von Komponenten in Blazor WebAssembly zu optimieren, schauen wir uns jetzt an, was passiert, wenn wir eine Liste rendern. Als Beispiel nutzen wir hier eine Liste, in der wir einzelne Einträge selektieren können (Abb. 4).
Abb. 4: Virtualisierung der Liste
Wie wir im vorherigen Abschnitt des Artikels schon gesehen haben, wird bei einem Event wie zum Beispiel bei einem OnClick der Renderprozess angestoßen. Infolgedessen haben wir bei einer Liste das Problem, dass bei jedem Klick auf ein Listenelement alle Einträge neu gerendert werden. Zusätzlich müssen bei einem for-Loop von Anfang an alle Einträge in das DOM geladen werden. Zur Optimierung kann die Virtualize-Komponente genutzt werden, die von Blazor WebAssembly zur Verfügung gestellt wird. Die Komponente bewirkt, dass nur die Komponenten geladen werden, die sich im Sichtbereich der Anwendung befinden. Das hat den großen Vorteil, dass, auch wenn über das API die Daten nicht nach und nach geladen werden können, nicht alle Einträge direkt dem DOM hinzugefügt werden. Zusätzlich kann über den Parameter OverscanCount vor und nach dem Sichtbereich eine gewisse Anzahl an Einträgen im DOM vorgeladen werden. Daher ist eine wichtige Voraussetzung zum Einsetzten der Virtualize-Komponente, dass nicht alle Einträge einer Liste im DOM gerendert sein müssen.
Listing 7
<Virtualize Context="contribution" ItemsProvider="@LoadContributions" OverscanCount="10">
<ItemContent>
<ContributionCard Contribution="@contribution">
</ItemContent>
<Placeholder>
<PlaceholderCard>
</Placeholder>
</Virtualize>
// Contributions.razor.cs
public partial class Contributions
{
[Inject] public ContributionService ContributionService { get; set; }
private async ValueTask<ItemsProviderResult> LoadContributions(
ItemsProviderRequest request)
{
var maxCount = 200;
var numConfs = Math.Min(request.Count, maxCount - request.StartIndex);
var contributions = await ContributionService.GetContributionsAsync(request.StartIndex, numConfs, request.CancellationToken);
return new ItemsProviderResult(contributions, maxCount);
}
}
In Listing 7 sehen wir die Virtualize-Komponente. Die Liste an Personen wird mit dem ItemProvider geladen. Damit beim Scrollen der Liste keine zu große Verzögerung auftritt, werden mit dem OverscanCount vor und nach dem Sichtbereich 10 Einträge vorgehalten. Der ItemProvider bietet die Möglichkeit, die Daten via Lazy Loading nachzuladen. Wir sehen in Listing 7 die Methode LoadContributions, die immer dann aufgerufen wird, wenn neue Einträge geladen werden müssen. Können die Daten nicht schnell genug nachgeladen werden, beispielsweise durch ein langsames API oder eine schlechte Internetverbindung, bietet die Virtualize-Komponente die Möglichkeit, einen Placeholder einzusetzen. Dieser wird so lange angezeigt, bis die nächsten Einträge geladen wurden.
Durch den Einsatz der Virtualize-Komponente haben wir also die Möglichkeit, die Performance zu optimieren. Jedoch kann dies nicht immer mit den Optimierungen der vorherigen Abschnitte verbunden werden. Das liegt daran, dass Komponenten, die nicht im Sichtbereich liegen oder vorgeladen wurden, aus dem DOM entfernt wurden. Wenn diese wieder geladen werden, handelt es sich um das Initialisieren der Komponente, was automatisch zu einem Renderprozess führt.
ZUM NEWSLETTER
Regelmäßig News zur Konferenz und der .NET-Community
Fazit
In diesem Artikel haben wir mögliche Varianten gesehen, den Renderprozess von Blazor-WebAssembly-Komponenten zu verbessern. Schon mit kleinen Anpassungen können viele Rendervorgänge vermieden werden, was sich positiv auf die Performance der ganzen Anwendung auswirken kann. Wichtig dabei ist zu beachten, wann welche Komponente gerendert werden muss und wann nicht.
Beim Überschreiben der Methode ShouldRender und dem Implementieren des Interface IHandleEvent konnten wir sehen, dass bereits viele Rendervorgänge eingespart wurden. Doch auch wenn sich ein Rendering nicht vermeiden ließ, konnten wir durch den Einsatz von JavaScript bei einem Event die Anzahl der Rendervorgänge minimieren.
Mit Hilfe der Virtualize-Komponente konnten viele Funktionen eingesetzt werden, wie beispielsweise Lazy Loading oder eine Ladeanimation als Placeholder. Das trägt nicht nur zur besseren Performance bei, sondern auch zu einer erhöhten Usability der Anwendung. Daher lässt sich abschließend sagen, dass die Performance deutlich gesteigert werden kann, wenn man beim Entwickeln von Komponenten darauf achtet, so selten wie möglich zu (re-)rendern.
Links & Literatur
[1] https://github.com/thinktecture-labs/article-blazor-component-performance
[2] https://github.com/dotnet/aspnetcore/blob/main/src/Components/Components/src/ComponentBase.cs
[3] https://www.thinktecture.com/de/pawel-gerr
[4] https://www.thinktecture.com/de/blazor/blazor-components-lifecycle-is-not-always-straightforward/
[5] https://gist.github.com/SteveSandersonMS/8a19d8e992f127bb2d2a315ec6c5a373
[6] https://docs.microsoft.com/de-de/aspnet/core/blazor/call-javascript-from-dotnet?view=aspnetcore-6.0