Eintrag

Typsichere Sprache, typsichere Navigation

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:

  1. 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.
  2. God Object: Die Routes-Klasse “weiß alles” über die Navigation in der Anwendung, was zu einer unerwünschten Kopplung führen kann.
  3. 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. eine GetRoute-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.

Dieser Eintrag ist vom Autor unter CC BY 4.0 lizensiert.