DEV Community

Chris Sainty
Chris Sainty

Posted on • Originally published at chrissainty.com on

Building a Custom Router for Blazor

Building a Custom Router for Blazor

In this post we are going to build a simple custom router component which will replace the default router Blazor ships with. I just want to say from the start that this isn't meant to be an all singing all dancing replacement for the default router. The default router is quite sophisticated and replicating all that functionality is a bit much for a blog post. Hopefully though, this will give you an idea of what's possible and maybe provide some inspiration.

To help guide things a bit, I want to set a few requirement for our new router, they are:

  • Convention based routing - no @page directive to be used
  • Should be able to handle nested routes e.g. /nested/mycomponent
  • Parameters to be passed via query string e.g. /counter?startingCount=4
  • Only support string parameters
  • Still allow external links to work
  • Re-use code from the default router where possible

All code from this post is available on GitHub

The Plan

With the requirements set, let's start by creating a plan for how our new router will work.

The first requirement is that is should be convention based and not use the @page directive. In order to achieve this we are going to use namespaces to define a page component. Taking the default project as a base, we'll assume any components in the ProjectName.Pages.* namespace are page components.

Taking this approach should also allow us to achieve the second requirement, handling nested routes. If a user requests https://coolblazorapp.com/admin/settings, we will look for a component called Settings.razor in the following namespace ProjectName.Pages.Admin.

As we'll be passing parameters via the query string and only be supporting strings, we'll have to deal with type conversions somehow. We can do this by using the getter and setter on the target parameters to convert incoming values to the correct type. Not very pretty, but it should work for our scenario.

Allowing external links to work should come for free. If you read last weeks blog post you'll know why. Blazor's JavaScript NavigationManager should handle this requirement for us.

We shouldn't have to reinvent the wheel when it comes to rendering page components. Once we have located the correct page component, based on our convention, we should be able to render it using the same Found and NotFound template approach which is used in the default router. We should also be able to use the existing RouteView and LayoutView components as well. That takes care of our final requirement to reuse any existing code, if possible.

I think that's everything covered, so lets get into the code.

Creating The New Router

We're going to start by creating the new router component, named ConventionRouter. This is going to be defined as a C# class, the same as the default router is. Here is the full code.

public class ConventionRouter : IComponent, IHandleAfterRender, IDisposable
{
    RenderHandle _renderHandle;
    bool _navigationInterceptionEnabled;
    string _location;

    [Inject] private NavigationManager NavigationManager { get; set; }
    [Inject] private INavigationInterception NavigationInterception { get; set; }
    [Inject] RouteManager RouteManager { get; set; }

    [Parameter] public RenderFragment NotFound { get; set; }
    [Parameter] public RenderFragment<RouteData> Found { get; set; }

    public void Attach(RenderHandle renderHandle)
    {
        _renderHandle = renderHandle;
        _location = NavigationManager.Uri;
        NavigationManager.LocationChanged += HandleLocationChanged;
    }

    public Task SetParametersAsync(ParameterView parameters)
    {
        parameters.SetParameterProperties(this);

        if (Found == null)
        {
            throw new InvalidOperationException($"The {nameof(ConventionRouter)} component requires a value for the parameter {nameof(Found)}.");
        }

        if (NotFound == null)
        {
            throw new InvalidOperationException($"The {nameof(ConventionRouter)} component requires a value for the parameter {nameof(NotFound)}.");
        }

        RouteManager.Initialise();
        Refresh();

        return Task.CompletedTask;
    }

    public Task OnAfterRenderAsync()
    {
        if (!_navigationInterceptionEnabled)
        {
            _navigationInterceptionEnabled = true;
            return NavigationInterception.EnableNavigationInterceptionAsync();
        }

        return Task.CompletedTask;
    }

    public void Dispose()
    {
        NavigationManager.LocationChanged -= HandleLocationChanged;
    }

    private void HandleLocationChanged(object sender, LocationChangedEventArgs args)
    {
        _location = args.Location;
        Refresh();
    }

    private void Refresh()
    {
        var relativeUri = NavigationManager.ToBaseRelativePath(_location);
        var parameters = ParseQueryString(relativeUri);

        if (relativeUri.IndexOf('?') > -1)
        {
            relativeUri = relativeUri.Substring(0, relativeUri.IndexOf('?'));
        }

        var segments = relativeUri.Trim().Split('/', StringSplitOptions.RemoveEmptyEntries);
        var matchResult = RouteManager.Match(segments);

        if (matchResult.IsMatch)
        {
            var routeData = new RouteData(
                matchResult.MatchedRoute.Handler,
                parameters);

            _renderHandle.Render(Found(routeData));
        }
        else
        {
            _renderHandle.Render(NotFound);
        }
    }

    private Dictionary<string, object> ParseQueryString(string uri)
    {
        var querystring = new Dictionary<string, object>();

        foreach (string kvp in uri.Substring(uri.IndexOf("?") + 1).Split(new[] { '&' }, StringSplitOptions.RemoveEmptyEntries))
        {
            if (kvp != "" && kvp.Contains("="))
            {
                var pair = kvp.Split('=');
                querystring.Add(pair[0], pair[1]);
            }
        }

        return querystring;
    }
}

Let's work through this see how it works. We won't cover every single method, as some of it's self explanatory.

RenderHandle _renderHandle;
bool _navigationInterceptionEnabled;
string _location;

[Inject] private NavigationManager NavigationManager { get; set; }
[Inject] private INavigationInterception NavigationInterception { get; set; }
[Inject] RouteManager RouteManager { get; set; }

[Parameter] public RenderFragment NotFound { get; set; }
[Parameter] public RenderFragment<RouteData> Found { get; set; }

We start by defining some local members and injecting some services - we'll talk about these later. We also define two parameters Found and NotFound, these are lifted straight from the default router.

public void Attach(RenderHandle renderHandle)
{
    _renderHandle = renderHandle;
    _location = NavigationManager.Uri;
    NavigationManager.LocationChanged += HandleLocationChanged;
}

Next we have the Attach method. We have to implement this as we're implementing the IComponent interface. Normally this is not something which we'd need to care about as it's dealt with in the ComponentBase class which most components inherit from.

This is a low level method which attaches the component to a RenderHandle. The RenderHandle provides a link between the component and it's renderer, allowing the component to be rendered.

Here we're saving a reference to the RenderHandle as well as recording the current URI. We're also registering a handler for the NavigationManager's LocationChanged event. This handler updates the routers _location field with the new location. Then calls the Refresh method to update the view with the new page, if one is found.

public Task OnAfterRenderAsync()
{
    if (!_navigationInterceptionEnabled)
    {
        _navigationInterceptionEnabled = true;
        return NavigationInterception.EnableNavigationInterceptionAsync();
    }

    return Task.CompletedTask;
}

In the OnAfterRender method, we're setting up navigation interception. This instructs Blazor to intercept any link click events within the application. If you want to understand how all this works, I suggest reading my last post which covered this in detail.

public Task SetParametersAsync(ParameterView parameters)
{
    parameters.SetParameterProperties(this);

    if (Found == null)
    {
        throw new InvalidOperationException($"The {nameof(ConventionRouter)} component requires a value for the parameter {nameof(Found)}.");
    }

    if (NotFound == null)
    {
        throw new InvalidOperationException($"The {nameof(ConventionRouter)} component requires a value for the parameter {nameof(NotFound)}.");
    }

    RouteManager.Initialise();
    Refresh();

    return Task.CompletedTask;
}

The SetParametersAsync method is also part of the IComponent interface. We're doing some basic checks to make sure we have values for the Found and NotFound parameters.

We then call RouteManager.Initialise. We'll look at the RouteManager class in detail in the next section but essentially, it's going to go off and find all of the page components in our project and store them.

Finally, we call the Refresh method. Let's check that out now.

private void Refresh()
{
    var relativeUri = NavigationManager.ToBaseRelativePath(_location);
    var parameters = ParseQueryString(relativeUri);

    if (relativeUri.IndexOf('?') > -1)
    {
        relativeUri = relativeUri.Substring(0, relativeUri.IndexOf('?'));
    }

    var segments = relativeUri.Trim().Split('/', StringSplitOptions.RemoveEmptyEntries);
    var matchResult = RouteManager.Match(segments);

    if (matchResult.IsMatch)
    {
        var routeData = new RouteData(
            matchResult.MatchedRoute.Handler,
            parameters);

        _renderHandle.Render(Found(routeData));
    }
    else
    {
        _renderHandle.Render(NotFound);
    }
}

Similar to the Refresh method on the default router, our version is going to look at the current URI and try and load the correct page component for it. If it can't find a matching page component, then it will render the NotFound template.

We start by getting the relative URI and extracting any query string parameters. We store these so they can be passed to the matching page component, if one is found. Once this is complete, we remove the query string from the relative URI, if present. Then split the URI into segments removing any empty ones. The array of segments is then passed to the RouteManager's Match method which will attempt to find a page component for that route.

A MatchResult is returned which shows if a match was found. If a match was found then the matching Route will be included. The Route and any parameters found in the query string are then used to construct a RouteData object. This is the same RouteData object from the default router implementation. The renderer is then instructed to render the Found template using the RouteData object. This results in the page component being displayed to the user.

If a match isn't found then the renderer is instructed to render the NotFound template.

Finding Page Components With RouteManager

The RouteManager class is used to find page components when the application first starts up. It is also responsible for finding page components which match the requested route.

public class RouteManager
{
    public Route[] Routes { get; private set; }

    public void Initialise()
    {
        var pageComponentTypes = Assembly.GetExecutingAssembly()
                                         .ExportedTypes
                                         .Where(t => t.IsSubclassOf(typeof(ComponentBase))
                                                     && t.Namespace.Contains(".Pages"));

        var routesList = new List<Route>();
        foreach (var pageType in pageComponentTypes)
        {
            var newRoute = new Route
            {
                UriSegments = pageType.FullName.Substring(pageType.FullName.IndexOf("Pages") + 6).Split('.'),
                Handler = pageType
            };

            routesList.Add(newRoute);
        }

        Routes = routesList.ToArray();
    }

    public MatchResult Match(string[] segments)
    {
        if (segments.Length == 0)
        {
            var indexRoute = Routes.SingleOrDefault(x => x.Handler.FullName.ToLower().EndsWith("index"));
            return MatchResult.Match(indexRoute);
        }

        foreach (var route in Routes)
        {
            var matchResult = route.Match(segments);

            if (matchResult.IsMatch)
            {
                return matchResult;
            }
        }

        return MatchResult.NoMatch();
    }
}

The Initialise method is called in the router's SetParametersAsync, we saw that earlier. It uses some reflection to scan the current assembly and find any components with .Pages in their namespace, as per our convention we stated at the start.

Once we have the page components we create each one as a Route. We break the full name into segments which we will use to compare to the requested route. We also store the handler for the route, which is the type of the component. Once all of the Routes are created they're stored as an array on the RouteManager.

The Route class looks like this.

public class Route
{
    public string[] UriSegments { get; set; }
    public Type Handler { get; set; }

    public MatchResult Match(string[] segments)
    {
        if (segments.Length != UriSegments.Length)
        {
            return MatchResult.NoMatch();
        }

        for (var i = 0; i < UriSegments.Length; i++)
        {
            if (string.Compare(segments[i], UriSegments[i], StringComparison.OrdinalIgnoreCase) != 0)
            {
                return MatchResult.NoMatch();
            }
        }

        return MatchResult.Match(this);
    }
}

It's Match method is the most interesting part. It starts by checking if the number of segments in the requested route matches the number of segments it has. If that's not the case then a NotMatch result is returned. It then loops over each of it's segments and compares them to the segments passed in. If they all match then a Match result is returned, if they don't, then a NoMatch result is returned.

Back to the RouteManager and it's Match method.

public MatchResult Match(string[] segments)
{
    if (segments.Length == 0)
    {
        var indexRoute = Routes.SingleOrDefault(x => x.Handler.FullName.ToLower().EndsWith("index"));
        return MatchResult.Match(indexRoute);
    }

    foreach (var route in Routes)
    {
        var matchResult = route.Match(segments);

        if (matchResult.IsMatch)
        {
            return matchResult;
        }
    }

    return MatchResult.NoMatch();
}

Match is called by the router's Refresh method. It's job is to find a page component which matches the requested route. It starts by checking if the segments length is zero. If it is, we'll assume the request is for the root page, so https://mycoolblazorapp.com/ for example. By convention, we'll look for a page component called Index.razor and return the MatchResult.

Otherwise, we'll loop over each route we have stored on the RouteManager, calling it's Match method. If a match is found, then we'll return it. If we get through all the routes and a match isn't found then we return a NoMatch result.

This is what the MatchResult class looks like.

public class MatchResult
{
    public bool IsMatch { get; set; }
    public Route MatchedRoute { get; set; }

    public MatchResult(bool isMatch, Route matchedRoute)
    {
        IsMatch = isMatch;
        MatchedRoute = matchedRoute;
    }

    public static MatchResult Match(Route matchedRoute)
    {
        return new MatchResult(true, matchedRoute);
    }

    public static MatchResult NoMatch()
    {
        return new MatchResult(false, null);
    }
}

This is a simple class which gives us a consistent way of returning the result of a route match.

Summary

I think that about wraps things up. We've built a new router to replace the existing default router. It works on a convention basis and while it is nowhere near as feature rich and flexible as the default one, we've manged to hit all of the requirements set out at the start of the post.

I think it's really cool that Blazor has been built in such as way that we can easily replace parts as we choose. If you would like to see another, and far more sophisticated, example of a custom router for Blazor. I would recommend checking out this post by Shaun Walker who's building an open source CMS using Blazor, called Oqtane.

Top comments (0)