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






4.20/5 (2投票s)
本文介绍如何为 Blazor 应用组件添加动态路由、布局和路由视图。
概述
App
是 Blazor UI 的根组件。本文将探讨它的工作原理,并演示如何
- 添加动态布局 - 在运行时更改默认布局
- 添加动态路由 - 在运行时添加和删除额外的路由
- 添加动态路由视图 - 直接更改
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>
RouteView
在 Found
中声明。RouteData
设置为路由器的当前 routeData
对象,DefaultLayout
设置为应用程序布局的 Type
。RouteView
在页面特定布局或默认布局中渲染 RouteData.Type
的实例作为组件,并应用 RouteData.RouteValues
中的任何参数。
NotFound
包含一个 LayoutView
组件,指定一个布局来渲染任何子内容。
RouteViewService
RouteViewService
是新组件的状态管理服务。它在 WASM 和 Server 服务中注册。Server 版本可以是 Singleton 或 Scoped,具体取决于应用程序的需求。您可以拥有两个独立的服务来分别管理应用程序和用户上下文。
public class RouteViewService
{
....
}
在 Server 中,它在 ConfigServices
的 Startup
中添加。
services.AddSingleton<RouteViewService>();
在 WebAssembly 上下文中,它在 Program
中添加。
builder.Services.AddScoped<RouteViewService>();
RouteViewManager
RouteViewManager
替换了 RouteView
。
它实现了 RouteView
的功能。由于篇幅过长,无法在此完整展示,我们将分段介绍关键功能。
当发生路由事件时,RouteViewManager.RouteData
会更新,Router
会重新渲染。Renderer
调用 RouteViewManager
的 SetParametersAsync
,传递更新后的 _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));
在初始渲染时,Router
向 NavigationManager.LocationChanged
事件注册一个委托。该委托查找路由并触发 Router
上的渲染事件。如果找到路由,则渲染 Found
,它会渲染我们新的 RouteViewManager
。RouteViewManager
构建布局并添加 RouteData
中定义的组件的新实例。
当它找不到路由时,会发生什么取决于事件提供的 LocationChangedEventArgs
的 IsNavigationIntercepted
属性
- 如果它在 DOM 中拦截导航(锚点等),则为
True
。 - 如果 UI 组件调用其
NavigateTo
方法并设置ForceLoad
,则为True
。 - 如果 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 组件
RouteNotFoundManager
是 RouteViewManager
的简化版本。
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
有多种应用。以下是我使用过的一些:
- 隐藏页面的直接访问。它只能在应用程序内访问。
- 具有单一入口点的多部分表单/流程。保存的表单/流程的状态决定加载哪个表单。
- 取决于上下文的表单或信息。登录/注销/注册是一个很好的例子。相同的 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
此演示了
- 动态添加路由到应用程序。选择一个页面以添加自定义路由,添加路由名称,然后单击 **Go To Route**。
- 在不导航的情况下加载
RouteView
。选择一个 **Page**,然后单击 **Go To View**。页面会显示,但 URL 不会改变!令人困惑,但它演示了原理。 - 更改默认布局。单击 **Red Layout**,布局将变为红色。基本的
FetchData
有一个特定的布局定义,所以它将使用原始布局。单击 **Normal Layout** 以改回。
Form.Razor
https://cec-blazor-database.azurewebsites.net/form
这演示了一个多部分表单。有四个表单
Form.Razor
,基础表单和第一个表单Form2.Razor
,第二个表单 - 继承自第一个表单Form3.Razor
,第三个表单 - 继承自第一个表单Form4.Razor
,结果表单 - 继承自第一个表单
表单链接到 WeathForecastService
中的数据,该服务维护表单状态。尝试在中间离开表单然后返回。只要 SPA 会话保持,State
就会被保留。
总结
希望我已演示了可以用于构建额外功能到核心 Blazor 框架中的原则。没有任何组件是成品。随意使用和开发它们。
如果您在很久以后阅读本文,请在此处 查看最新版本。
历史
- 2021 年 4 月 13 日:初始版本