65.9K
CodeProject 正在变化。 阅读更多。
Home

为 Blazor 应用组件添加动态路由、布局和路由视图

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.20/5 (2投票s)

2021 年 4 月 13 日

CPOL

8分钟阅读

viewsIcon

23595

本文介绍如何为 Blazor 应用组件添加动态路由、布局和路由视图。

概述

App 是 Blazor UI 的根组件。本文将探讨它的工作原理,并演示如何

  1. 添加动态布局 - 在运行时更改默认布局
  2. 添加动态路由 - 在运行时添加和删除额外的路由
  3. 添加动态路由视图 - 直接更改 RouteView 组件,无需路由

代码和示例

此项目的存储库在此处,并基于我的 Blazor AllInOne 模板

您可以在我的 Blazor.Database 网站上的 https://cec-blazor-database.azurewebsites.net/ 处查看组件的演示,通过突出显示的链接。

Blazor 应用程序

App 通常在 App.razor 中定义。WebAssembly 和 Server 上下文都使用相同的组件。

在 WebAssembly 上下文中,SPA 的启动页面包含一个元素占位符,当 Program 在 WebAssembly 上下文中启动时,该占位符会被替换。

....
<body>
    <div id="app">Loading...</div>
    ...
</body>

Program 中定义替换的这行代码是

    // Replace the app id element with the component App
    builder.RootComponents.Add<App>("#app");

在 Server 上下文中,App 直接在 Razor 标记中声明为 Razor 组件。它会被服务器预渲染,然后由浏览器中的 Blazor Server 客户端更新。

...
<body>
    <component type="typeof(Blazor.App)" render-mode="ServerPrerendered" />
...
</body>

App 组件

下面显示了 App 的代码。它是一个标准的 Razor 组件,继承自 ComponentBase

Router 是本地的根组件,并将 AppAssembly 设置为包含 Program 的程序集。初始化时,它会遍历 Assembly 中所有带有 Route 属性的类,并注册 NavigationManager 服务的 NavigationChanged 事件。在导航事件上,它会尝试将导航 URL 与路由匹配。如果找到匹配项,则渲染 Found 渲染片段,否则渲染 NotFound

<Router AppAssembly="@typeof(Program).Assembly" PreferExactMatches="@true">
    <Found Context="routeData">
        <RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
    </Found>
    <NotFound>
        <LayoutView Layout="@typeof(MainLayout)">
            <p>Sorry, there's nothing at this address.</p>
        </LayoutView>
    </NotFound>
</Router>

RouteViewFound 中声明。RouteData 设置为路由器的当前 routeData 对象,DefaultLayout 设置为应用程序布局的 TypeRouteView 在页面特定布局或默认布局中渲染 RouteData.Type 的实例作为组件,并应用 RouteData.RouteValues 中的任何参数。

NotFound 包含一个 LayoutView 组件,指定一个布局来渲染任何子内容。

RouteViewService

RouteViewService 是新组件的状态管理服务。它在 WASM 和 Server 服务中注册。Server 版本可以是 Singleton 或 Scoped,具体取决于应用程序的需求。您可以拥有两个独立的服务来分别管理应用程序和用户上下文。

public class RouteViewService 
{
  ....
}

在 Server 中,它在 ConfigServicesStartup 中添加。

services.AddSingleton<RouteViewService>();

在 WebAssembly 上下文中,它在 Program 中添加。

builder.Services.AddScoped<RouteViewService>();

RouteViewManager

RouteViewManager 替换了 RouteView

它实现了 RouteView 的功能。由于篇幅过长,无法在此完整展示,我们将分段介绍关键功能。

当发生路由事件时,RouteViewManager.RouteData 会更新,Router 会重新渲染。Renderer 调用 RouteViewManagerSetParametersAsync,传递更新后的 _Parameters_。SetParametersAsync 检查它是否具有有效的 RouteData,将 _ViewData 设置为 null 并渲染组件。将 _ViewData 设置为 null 是为了确保组件加载路由。有效的 ViewData 对象在渲染过程中具有优先于有效 RouteData 对象的优先级。

public await Task SetParametersAsync(ParameterView parameters)
{
    // Sets the component parameters
    parameters.SetParameterProperties(this);
    // Check if we have either RouteData or ViewData
    if (RouteData == null)
    {
        throw new InvalidOperationException($"The {nameof(RouteView)} 
        component requires a non-null value for the parameter {nameof(RouteData)}.");
    }
    // we've routed and need to clear the ViewData
    this._ViewData = null;
    // Render the component
    await this.RenderAsync();
}

Render 使用 InvokeAsync 来确保 render 事件在正确的线程上下文中运行。_RenderEventQueued 确保 Renderer 的队列中只有一个 render 事件。

public async Task RenderAsync() => await InvokeAsync(() =>
    {
        if (!this._RenderEventQueued)
        {
            this._RenderEventQueued = true;
            _renderHandle.Render(_renderDelegate);
        }
    }
);

对于好奇的读者,InvokeAsync 如下所示

protected Task InvokeAsync(Action workItem) => _renderHandle.Dispatcher.InvokeAsync(workItem);

RouteViewManager 的内容由一组组件构成,每个组件都在 RenderFragment 中定义。

_renderDelegate 定义了本地根组件,将其自身级联,并将 _layoutViewFragment 片段作为其 ChildContent

private RenderFragment _renderDelegate => builder =>
{
    // We're being executed so no longer queued
    _RenderEventQueued = false;
    // Adds cascadingvalue for the ViewManager
    builder.OpenComponent<CascadingValue<RouteViewManager>>(0);
    builder.AddAttribute(1, "Value", this);
    // Get the layout render fragment
    builder.AddAttribute(2, "ChildContent", this._layoutViewFragment);
    builder.CloseComponent();
};

_layoutViewFragment 选择布局,添加它,并将 _renderComponentWithParameters 设置为其 ChildContent

private RenderFragment _layoutViewFragment => builder =>
{
    Type _pageLayoutType = 
        RouteData?.PageType.GetCustomAttribute<LayoutAttribute>()?.LayoutType
        ?? RouteViewService.Layout
        ?? DefaultLayout;

    builder.OpenComponent<LayoutView>(0);
    builder.AddAttribute(1, nameof(LayoutView.Layout), _pageLayoutType);
    builder.AddAttribute(2, nameof(LayoutView.ChildContent), _renderComponentWithParameters);
    builder.CloseComponent();
};

_renderComponentWithParameters 选择要渲染的视图/路由组件,并使用提供的参数添加它。有效的视图优先于有效的路由。

private RenderFragment _renderComponentWithParameters => builder =>
{
    Type componentType = null;
    IReadOnlyDictionary<string, object> parameters = new Dictionary<string, object>();

    if (_ViewData != null)
    {
        componentType = _ViewData.ViewType;
        parameters = _ViewData.ViewParameters;
    }
    else if (RouteData != null)
    {
        componentType = RouteData.PageType;
        parameters = RouteData.RouteValues;
    }

    if (componentType != null)
    {
        builder.OpenComponent(0, componentType);
        foreach (var kvp in parameters)
        {
            builder.AddAttribute(1, kvp.Key, kvp.Value);
        }
        builder.CloseComponent();
    }
    else
    {
        builder.OpenElement(0, "div");
        builder.AddContent(1, "No Route or View Configured to Display");
        builder.CloseElement();
    }
};

动态布局

开箱即用,Blazor 布局在编译时定义和固定。@Layout 是 Razor 的说法,在 Razor 被预编译时会转换为

[Microsoft.AspNetCore.Components.LayoutAttribute(typeof(MainLayout))]
[Microsoft.AspNetCore.Components.RouteAttribute("/")]
[Microsoft.AspNetCore.Components.RouteAttribute("/index")]
public partial class Index : Microsoft.AspNetCore.Components.ComponentBase
....

要动态更改布局,我们使用 RouteViewService 来存储布局。它可以从任何注入了该服务的组件设置。

public class RouteViewService
{
    public Type Layout { get; set; }
    ....
}

RouteViewManager 中的 _layoutViewFragment 选择布局 - RouteViewService.Layout 的设置优先级高于默认布局。

private RenderFragment _layoutViewFragment => builder =>
{
    Type _pageLayoutType = 
        RouteData?.PageType.GetCustomAttribute<LayoutAttribute>()?.LayoutType
        ?? RouteViewService.Layout
        ?? DefaultLayout;

    builder.OpenComponent<LayoutView>(0);
    builder.AddAttribute(1, nameof(LayoutView.Layout), _pageLayoutType);
    builder.AddAttribute(2, nameof(LayoutView.ChildContent), _renderComponentWithParameters);
    builder.CloseComponent();
};

在演示页面中演示了布局的更改。

动态路由

动态路由有点复杂。Router 是一个封闭的盒子,所以要么接受它,要么重写它。除非绝对必要,否则不要重写它。我们不是要更改现有路由,只是添加和删除新的动态路由。

路由在编译时定义,并在 Router 组件内部使用。

RouteView Razor 页面的标记如下

@page "/"
@page "/index"

这是 Razor 的说法,在预编译时会转换为 C# 类中的以下内容。

[Microsoft.AspNetCore.Components.RouteAttribute("/")]
[Microsoft.AspNetCore.Components.RouteAttribute("/index")]
public partial class Index : Microsoft.AspNetCore.Components.ComponentBase
.....

Router 初始化时,它会遍历提供的任何程序集,并构建一个组件/路由对的路由字典。

您可以像这样获取路由属性组件的列表

static public IEnumerable<Type> 
    GetTypeListWithCustomAttribute(Assembly assembly, Type attribute)
    => assembly.GetTypes().Where(item => 
    (item.GetCustomAttributes(attribute, true).Length > 0));

在初始渲染时,RouterNavigationManager.LocationChanged 事件注册一个委托。该委托查找路由并触发 Router 上的渲染事件。如果找到路由,则渲染 Found,它会渲染我们新的 RouteViewManagerRouteViewManager 构建布局并添加 RouteData 中定义的组件的新实例。

当它找不到路由时,会发生什么取决于事件提供的 LocationChangedEventArgsIsNavigationIntercepted 属性

  1. 如果它在 DOM 中拦截导航(锚点等),则为 True
  2. 如果 UI 组件调用其 NavigateTo 方法并设置 ForceLoad,则为 True
  3. 如果 UI 组件调用其 NavigateTo 方法并设置 ForceLoad,则为 False

如果我们能避免在 Router 中引起硬导航事件,我们就可以在 NotFound 中添加一个组件来处理额外的动态路由。这并不难,这是我们的代码!有一个增强的 NavLink 控件可以帮助控制导航 - 稍后会介绍。在硬导航事件发生时,路由仍然会工作,但应用程序会重新加载。任何错误的导航事件都应在测试期间检测并修复。

CustomRouteData

CustomRouteData 包含进行路由决策所需的信息。该类带有内联的详细说明如下。

    public class CustomRouteData
    {
        /// The standard RouteData.
        public RouteData RouteData { get; private set; }
        /// The PageType to load on a match 
        public Type PageType { get; set; }
        /// The Regex String to define the route
        public string RouteMatch { get; set; }
        /// Parameter values to add to the Route when created name/defaultvalue
        public SortedDictionary<string, object> ComponentParameters 
               { get; set; } = new SortedDictionary<string, object>();

        /// Method to check if we have a route match
        public bool IsMatch(string url)
        {
            // get the match
            var match = Regex.Match(url, this.RouteMatch,RegexOptions.IgnoreCase);
            if (match.Success)
            {
                // create new dictionary object to add to the RouteData
                var dict = new Dictionary<string, object>();
                //  check we have the same or fewer groups as parameters to map the to
                if (match.Groups.Count >= ComponentParameters.Count)
                {
                    var i = 1;
                    // iterate through the parameters and add the next match
                    foreach (var pars in ComponentParameters)
                    {
                        string matchValue = string.Empty;
                        if (i < match.Groups.Count)
                            matchValue = match.Groups[i].Value;
                        // Use a StypeSwitch object to do the Type Matching 
                        // and create the dictionary pair 
                        var ts = new TypeSwitch()
                            .Case((int x) =>
                            {
                                if (int.TryParse(matchValue, out int value))
                                    dict.Add(pars.Key, value);
                                else
                                    dict.Add(pars.Key, pars.Value);
                            })
                            .Case((float x) =>
                            {
                                if (float.TryParse(matchValue, out float value))
                                    dict.Add(pars.Key, value);
                                else
                                    dict.Add(pars.Key, pars.Value);
                            })
                            .Case((decimal x) =>
                            {
                                if (decimal.TryParse(matchValue, out decimal value))
                                    dict.Add(pars.Key, value);
                                else
                                    dict.Add(pars.Key, pars.Value);
                            })
                            .Case((string x) =>
                            {
                                dict.Add(pars.Key, matchValue);
                            });

                        ts.Switch(pars.Value);
                        i++;
                    }
                }
                // create a new RouteData object and assign it to the RouteData property.  
                this.RouteData = new RouteData(this.PageType, dict);
            }
            return match.Success;
        }

        /// Method to check if we have a route match and return the RouteData
        public bool IsMatch(string url, out RouteData routeData)
        {
            routeData = this.RouteData;
            return IsMatch(url);
        }
    }

对于感兴趣的读者,TypeSwitch 如下所示(感谢 StackOverflow 上的 cdiggins 提供的代码)

/// =================================
/// Author: stackoverflow: cdiggins
/// ==================================
    public class TypeSwitch
    {
        public TypeSwitch Case<T>(Action<T> action) { matches.Add(typeof(T), 
                          (x) => action((T)x)); return this; }
        private Dictionary<Type, Action<object>> matches = 
                          new Dictionary<Type, Action<object>>();
        public void Switch(object x) { matches[x.GetType()](x); }
    }

RouteViewService 的更新

下面显示了 RouteViewService 的更新部分。Routes 包含自定义路由列表 - 它故意保持开放以供定制。

public List<CustomRouteData> Routes { get; private set; } = new List<CustomRouteData>();

public bool GetRouteMatch(string url, out RouteData routeData)
{
    var route = Routes?.FirstOrDefault(item => item.IsMatch(url)) ?? null;
    routeData = route?.RouteData ?? null;
    return route != null;
}

RouteNotFoundManager 组件

RouteNotFoundManagerRouteViewManager 的简化版本。

SetParametersAsync 在组件加载时调用。它获取本地 URL,在 RouteViewService 上调用 GetRouteMatch,并渲染组件。如果没有布局,它只渲染 ChildContent

public Task SetParametersAsync(ParameterView parameters)
{
    parameters.SetParameterProperties(this);
    // Get the route url
    var url = $"/{NavManager.Uri.Replace(NavManager.BaseUri, "")}";
    // check if we have a custom route and if so use it
    if (RouteViewService.GetRouteMatch(url, out var routedata))
        _routeData = routedata;
    // if The layout is blank show the ChildContent without a layout 
    if (_pageLayoutType == null)
        _renderHandle.Render(ChildContent);
    // otherwise show the route or ChildContent inside the layout
    else
        _renderHandle.Render(_ViewFragment);
    return Task.CompletedTask;
}

_ViewFragment 要么渲染 RouteViewManager,如果找到自定义路由则设置 RouteData,要么渲染 RouteNotFoundManager 的内容。

/// Layouted Render Fragment
private RenderFragment _ViewFragment => builder =>
{
    // check if we have a RouteData object and if so load the RouteViewManager, 
    // otherwise the ChildContent
    if (_routeData != null)
    {
        builder.OpenComponent<RouteViewManager>(0);
        builder.AddAttribute(1, nameof(RouteViewManager.DefaultLayout), _pageLayoutType);
        builder.AddAttribute(1, nameof(RouteViewManager.RouteData), _routeData);
        builder.CloseComponent();
    }
    else
    {
        builder.OpenComponent<LayoutView>(0);
        builder.AddAttribute(1, nameof(LayoutView.Layout), _pageLayoutType);
        builder.AddAttribute(2, nameof(LayoutView.ChildContent), this.ChildContent);
        builder.CloseComponent();
    }
};

在不进行路由的情况下切换 RouteView

在不进行路由的情况下切换 RouteView 有多种应用。以下是我使用过的一些:

  1. 隐藏页面的直接访问。它只能在应用程序内访问。
  2. 具有单一入口点的多部分表单/流程。保存的表单/流程的状态决定加载哪个表单。
  3. 取决于上下文的表单或信息。登录/注销/注册是一个很好的例子。相同的 URL,但根据上下文加载不同的路由视图。

视图数据

等同于 RouteData

public class ViewData
{
    /// Gets the type of the View.
    public Type ViewType { get; set; }

    /// Gets the type of the page matching the route.
    public Type LayoutType { get; set; }

    /// Parameter values to add to the Route when created
    public Dictionary<string, object> 
    ViewParameters { get; private set; } = new Dictionary<string, object>();

    /// Constructs an instance of <see cref="ViewData"/>.
    public ViewData(Type viewType, Dictionary<string, object> viewValues = null)
    {
        if (viewType == null) throw new ArgumentNullException(nameof(viewType));
        this.ViewType = viewType;
        if (viewValues != null) this.ViewParameters = viewValues;
    }
}

所有功能都在 RouteViewManager 中实现。

RouteViewManager

首先是一些属性和字段。

/// The size of the History list used for Views.
[Parameter] public int ViewHistorySize { get; set; } = 10;

/// Gets and sets the view data.
public ViewData ViewData
{
    get => this._ViewData;
    protected set
    {
        this.AddViewToHistory(this._ViewData);
        this._ViewData = value;
    }
}

/// Property that stores the View History. It's size is controlled by ViewHistorySize
public SortedList<DateTime, ViewData> ViewHistory { get; private set; } = 
                                      new SortedList<DateTime, ViewData>();

/// Gets the last view data.
public ViewData LastViewData
{
    get
    {
        var newest = ViewHistory.Max(item => item.Key);
        if (newest != default) return ViewHistory[newest];
        else return null;
    }
}

/// Method to check if <param name="view"> is the current View
public bool IsCurrentView(Type view) => this.ViewData?.ViewType == view;

/// Boolean to check if we have a View set
public bool HasView => this._ViewData?.ViewType != null;

/// Internal ViewData used by the component
private ViewData _ViewData { get; set; }

接下来,一组 LoadViewAsync 方法,提供了加载新视图的各种方式。主方法设置内部 viewData 字段并调用 Render 来重新渲染组件。

// The main method
public await Task LoadViewAsync(ViewData viewData = null)
{
    if (viewData != null) this.ViewData = viewData;
    if (ViewData == null)
    {
        throw new InvalidOperationException($"The {nameof(RouteViewManager)} 
        component requires a non-null value for the parameter {nameof(ViewData)}.");
    }
    await this.RenderAsync();
}

public async Task LoadViewAsync(Type viewtype)
    => await this.LoadViewAsync(new ViewData(viewtype, new Dictionary<string, object>()));

public async Task LoadViewAsync<TView>(Dictionary<string, object> data = null)
    => await this.LoadViewAsync(new ViewData(typeof(TView), data));

我们已经看到了 _renderComponentWithParameters。使用有效的 _ViewData 对象,它使用 _ViewData 渲染组件。

private RenderFragment _renderComponentWithParameters => builder =>
{
    Type componentType = null;
    IReadOnlyDictionary<string, object> parameters = new Dictionary<string, object>();

    if (_ViewData != null)
    {
        componentType = _ViewData.ViewType;
        parameters = _ViewData.ViewParameters;
    }
    else if (RouteData != null)
    {
        componentType = RouteData.PageType;
        parameters = RouteData.RouteValues;
    }

    if (componentType != null)
    {
        builder.OpenComponent(0, componentType);
        foreach (var kvp in parameters)
        {
            builder.AddAttribute(1, kvp.Key, kvp.Value);
        }
        builder.CloseComponent();
    }
    else
    {
        builder.OpenElement(0, "div");
        builder.AddContent(1, "No Route or View Configured to Display");
        builder.CloseElement();
    }
};

RouteNavLink

RouteNavLink 是一个增强的 NavLink 控件。代码是直接复制的,添加了少量代码。它不继承,因为 NavLink 是一个黑箱。它确保导航是通过 NavigationManager 而不是 HTML 锚点链接,并提供直接访问 RouteView 加载。代码在 Repo 中 - 太长了,无法在此处复制。

示例页面

应用程序中有 RouteViews/Pages 来演示新组件。您可以在 Repo 中查看源代码。您也可以在演示站点上查看页面。

RouteViewer.razor

https://cec-blazor-database.azurewebsites.net/routeviewer

此演示了

  1. 动态添加路由到应用程序。选择一个页面以添加自定义路由,添加路由名称,然后单击 **Go To Route**。
  2. 在不导航的情况下加载 RouteView。选择一个 **Page**,然后单击 **Go To View**。页面会显示,但 URL 不会改变!令人困惑,但它演示了原理。
  3. 更改默认布局。单击 **Red Layout**,布局将变为红色。基本的 FetchData 有一个特定的布局定义,所以它将使用原始布局。单击 **Normal Layout** 以改回。

Form.Razor

https://cec-blazor-database.azurewebsites.net/form

这演示了一个多部分表单。有四个表单

  1. Form.Razor,基础表单和第一个表单
  2. Form2.Razor,第二个表单 - 继承自第一个表单
  3. Form3.Razor,第三个表单 - 继承自第一个表单
  4. Form4.Razor,结果表单 - 继承自第一个表单

表单链接到 WeathForecastService 中的数据,该服务维护表单状态。尝试在中间离开表单然后返回。只要 SPA 会话保持,State 就会被保留。

总结

希望我已演示了可以用于构建额外功能到核心 Blazor 框架中的原则。没有任何组件是成品。随意使用和开发它们。

如果您在很久以后阅读本文,请在此处 查看最新版本

历史

  • 2021 年 4 月 13 日:初始版本
© . All rights reserved.