Typsichere Sprache, typsichere Navigation
Typsichere Navigation in Blazor
Einleitung
Navigation ist ein wichtiger Bestandteil jeder Webanwendung.
Weil sich das Blazor-Team sehr bewusst dafür entschieden hat, das Framework unopinionated zu gestalten, gibt es auch bei der Implementierung der Navigation verschiedene, rudimentäre Möglichkeiten zur Auswahl:
Anchor Element: Das klassische HTML-Element:
1
<a href="@($"/route/subroute/{_parameter}">Link</a>
NavLink Component: Eine Blazor Komponente, die das Anchor Element kapselt:
1
<NavLink href="@($"/route/subroute/{_parameter}">Link</NavLink>
NavigationManager: Einen Service zur programmatischen Navigation:
1
NavigationManager.NavigateTo($"/route/subroute/{_parameter}");
Problemstellung
Die drei Beispiel zeigen, wie die Navigation zu einer anderen Seite standardmäßig implementiert wird, indem eine auf der Zielseite zuvor mittels @page
-Direktive definierte Route hartkodiert wird. Während dies für einfache Projekte gut funktioniert, führt es bei umfangreicheren Anwendungen zu unübersichtlichem und schwer wartbarem Code. Infolgedessen kann es zu Fehlern kommen, die erst zur Laufzeit sichtbar werden.
In diesem Artikel zeige ich, wie eine typsichere Navigation in Blazor-Anwendungen umgesetzt werden könnte.
Zur Vollständigkeit möchte ich jedoch zuvor auf einen etablierten Lösungsansatz eingehen, der zwar funktioniert, aber nicht optimal ist.
Ein nicht optimaler Lösungsansatz: Navigation über globale Konstanten
Eine Möglichkeit, die Navigation in Blazor sicherer zu gestalten, besteht darin, globale Konstanten zu verwenden, um die Routen zu definieren. Dies ermöglicht es, die Routen an einer zentralen Stelle zu verwalten und die potentiell auseinanderlaufende Wiederholung von Code zu minimieren.
Eine beispielhafte Implementierung könnte wie folgt aussehen:
1
2
3
4
5
6
7
8
// Constants/Routes.cs
public static class Routes
{
public const string Home = "/";
public const string About = "/about";
public const string Articles = "/articles";
}
Auf der Page, zu der navigiert werden soll, wird dann die Konstante verwendet:
1
2
3
4
5
<!-- AboutPage.razor -->
@attribute [Route(Routes.AboutPage)]
<h1>About</h1>
TIP
Die
@page
-Direktive ist syntaktischer Zucker und wird zur Compilezeit in das Route-Attribut umgewandelt.
Um zur About-Seite zu navigieren, wird die Konstante in einem Anchor-Element verwendet:
1
2
3
<!-- HomePage.razor -->
<a href="@Routes.About">About</a>
Dieser Ansatz hat allerdings einige Nachteile:
- Discoverability: Es ist schwierig die verfügbaren Routen zu entdecken, da sie in einer separaten Klasse definiert sind und nicht direkt in den Seiten, zu denen sie führen.
- God Object: Die
Routes
-Klasse “weiß alles” über die Navigation in der Anwendung, was zu einer unerwünschten Kopplung führen kann. - Route-Parameter: Die fehleranfällige Erstellung einer Route mit Parametern muss weiterhin in der aufrufenden Klasse durchgeführt werden.
Ein Lösungsansatz: Typsichere Navigation
Eine elegante Lösung für diese Probleme könnte darin bestehen, den Seiten ein wenig mehr Eigenverantwortung zu geben, indem wir sie ermächtigen, ihre eigene Schnittstelle zu definieren.
Implementierung
Wir definieren zunächst ein Interface INavigablePage
, das einen generischen Parameter TRoutableComponent
verwendet, um den Typ der Seite anzugeben. Dieses Interface enthält eine Eigenschaft RouteTemplate
, welche den Pfad zur Seite angibt, sowie eine Methode NavigateTo
, die verwendet wird, um zur Seite zu navigieren.
1
2
3
4
5
6
7
8
9
public interface INavigablePage<TRoutableComponent> where TRoutableComponent : INavigablePage<TRoutableComponent>
{
static abstract string RouteTemplate { get; }
static void NavigateTo(NavigationManager navigationManager)
{
navigationManager.NavigateTo(TRoutableComponent.RouteTemplate);
}
}
Damit haben wir eine Schnittstelle definiert, die jede implementierende Seite um eine Funktion erweitert, die es ermöglicht zu ihr zu navigieren.
Um das Problem der Route-Parameter zu adressieren, definieren wir eine zweite Schnittstelle INavigablePage
mit einem zusätzlichen generischen Parameter TRouteParameter
, der die Parameter der Page beschreibt. Wie bei einer Web API können wir diesen Parameter als Contract unserer Seite betrachten.
1
2
3
4
5
6
public interface INavigablePage<in TRoutableComponent, in TRouteParameter> where TRoutableComponent : INavigablePage<TRoutableComponent, TRouteParameter>
{
static abstract string RouteTemplate { get; }
static abstract void NavigateTo(NavigationManager navigationManager, TRouteParameter routeParameter);
}
Anders als bei der ersten Schnittstelle, wird hier die Methode NavigateTo
als abstrakt definiert, um sicherzustellen, dass jede Seite eine eigene Implementierung bereitstellt.
1
2
3
4
5
6
7
8
9
10
11
<!-- AboutPage.razor -->
@page "/about"
@implements INavigablePage<AboutPage>
<h1>About</h1>
@code {
public static string RouteTemplate => "/about";
}
Auf der konsumierenden Seite können wir nun die Navigation durchführen:
1
2
3
4
5
6
7
8
9
10
// HomePage.razor
<button @onclick="NavigateToAbout">About</button>
@code {
private void NavigateToAbout()
{
AboutPage.NavigateTo(NavigationManager);
}
}
Fast schon gut, aber ergonomisch noch nicht optimal. Wir können die Navigation weiter vereinfachen, indem wir Erweiterungsmethoden für den NavigationManager
hinzufügen:
1
2
3
4
5
6
7
8
9
10
11
12
public static class NavigationManagerExtension
{
public static void NavigateTo<TRoutableComponent>(this NavigationManager navigationManager) where TRoutableComponent : INavigablePage<TRoutableComponent>
{
navigationManager.NavigateTo(TRoutableComponent.RouteTemplate);
}
public static void NavigateTo<TRoutableComponent, TRouteParameter>(this NavigationManager navigationManager, TRouteParameter routeParameter) where TRoutableComponent : INavigablePage<TRoutableComponent, TRouteParameter>
{
TRoutableComponent.NavigateTo(navigationManager, routeParameter);
}
}
Dadurch können wir Logik der Navigation zur About-Seite so schreiben:
1
2
3
4
5
6
7
8
9
10
// HomePage.razor
<button @onclick="NavigateToAbout">About</button>
@code {
private void NavigateToAbout()
{
NavigationManager.NavigateTo<AboutPage>();
}
}
Eine Implementierung mit Route-Parametern könnte beispielsweise wie folgt aussehen:
1
2
3
4
5
6
7
8
9
10
// HomePage.razor
<button @onclick="NavigateToArticles">Articles</button>
@code {
private void NavigateToArticles()
{
NavigationManager.NavigateTo<ArticlePage, int>(1);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// ArticlePage.razor
@page "articles/{Id:int}"
@implements INavigablePage<ArticlePage, int>
<h1>Article</h1>
@code {
[Parameter, EditorRequired]
public int Id { get; set; }
public static string RouteTemplate => "articles/";
public static void NavigateTo(NavigationManager navigationManager, int articleId)
{
navigationManager.NavigateTo($"{RouteTemplate}{articleId}");
}
}
Weitere Überlegungen
- Vermeidung der doppelten Definition von Routen: Mithilfe eines Source Generators könnte die RouteTemplate-Property automatisch generiert werden.
- Mehrere @page-Direktiven: Eine Seite kann mehrere @page-Direktiven haben, um verschiedene Routen zu unterstützen. In diesem Fall könnte die RouteTemplate-Property eine Liste von Routen zurückgeben.
- Komplexe Parameter: Statt einfacher Typen wie
int
könnten auch komplexe Typen als Parameter verwendet werden. In diesem Fall müsste die Route-Generierung entsprechend angepasst werden. - Bloßes Anzeigen der Route: Zusätzlich zur
NavigateTo
-Methode könnte z. B. eineGetRoute
-Methode implementiert werden, die die Route zurückgibt, ohne zu navigieren. - Ein alternativer Ansatz: FLAMESpl stellt einen Ansatz vor, bei dem das Route-Attribut der Page ausgewertet wird um die Navigation zu erleichtern oder eine spezielle
Route<T>
-Klasse erstellt wird, um die Erstellung der Route zu kapseln. https://github.com/FLAMESpl/oaklab-blazor-navigation
Fazit
Durch die Verwendung von typsicherer Navigation können wir die Navigation in Blazor-Anwendungen sicherer und wartbarer gestalten.