Microsoft Blazor - 用于动态内容的自定义控件
演示如何创建可外部扩展的动态页面,该页面将支持我们以后在单独的程序集中添加的所有控件,而无需重新编译动态页面。
引言
各位好!欢迎继续阅读我的上一篇博客“Microsoft Blazor - 动态内容”。在这篇文章中,我想演示如何创建一个真正的动态页面,该页面可以生成并绑定页面本身未知的控件。这是一个重要功能,因为正如我在上一篇博客文章中所述,动态内容是使用 `switch` 语句生成的,其中应添加所有可用的控件。
您可能会注意到,我有时在博客文章中使用“控件”,有时使用“组件”。请不要混淆 – 这些术语是可互换的,它们是完全相同的东西。它们的意思只是 UI 控件,并且这两个术语都被开发者社区使用。
用户故事 #2:自定义控件的动态生成
- 为动态 UI 生成添加自定义控件支持
- 自定义控件可以位于单独的程序集中,以便动态加载程序集。
- 自定义控件应接受两个必需参数:`ControlDetails` 控件和 `Dictionary<string, string>` 值。
实现 - Razor
我将以我之前的解决方案为起点,将所有代码复制到一个新文件夹,并重命名解决方案文件,这样最终的代码就与上一篇博客(故事 #1)的代码分开了。同样,您可以从我的 GitHub 页面下载代码。
让我们从更改 `Counter.razor` 文件开始,我们需要添加一个 `Type` 未知的控件并生成该控件的 case。
default:
var customComponent = GetCustomComponent(control.Type);
RenderFragment renderFragment = (builder) =>
{
builder.OpenComponent(0, customComponent);
builder.AddAttribute(0, "Control", control);
builder.AddAttribute(0, "Values", Values);
builder.CloseComponent();
};
<div>
@renderFragment
</div>
break;
此代码使用 `RenderTreeBuilder` 类进行自定义渲染。我们需要提供组件类型 - 不是组件的文本名称,而是真实的 .NET 类型,然后我们提供任意数量的组件参数。因为 **用户故事 #2** 指定了 2 个必需参数,所以我们只提供它们。
现在,我们需要实现一个新方法:`GetCustomComponent`,该方法应以某种方式按名称查找渲染控件(组件)的 .NET 类型。当然,我们将为此使用依赖注入,但在编写代码之前,我们需要考虑将自定义控件存储在单独的库中的可能性。
如果我们将控件存储在单独的库中,我们可能需要在同一个库中实现类型解析逻辑(以访问控件的 .NET 类型),如果我们以最优雅的方式来实现,我们将使用一个接口(我们称之为 `IComponentTypeResolver`),将类型解析逻辑放入实现该接口的服务中。因此,`IComponentTypeResolver` 接口应该是类型解析服务可见的。
同时,`IComponentTypeResolver` 应该从我们的动态页面可见,以便能够使用它,当我们想在两个没有显式依赖关系的程序集中使用一个接口时 - 我们需要创建一个共享程序集并将接口放在那里。
实现 - 库
所以,让我们先创建一个 `Razor` 组件库。
默认情况下,它将使用 .NET Standard 2.0 创建库,所以请将其更改为版本 2.1。
我相信 Microsoft 使用 .NET Standard 而不是 .NET Core 是有目的的,因为 Blazor `WebAssembly` 只能在 .NET Standard 上构建,如果您想在将来重用您的组件在 `WebAssembly` 中,最好使用 .NET Standard 框架。
现在,我们需要创建一个共享程序集,并且它应该可以从我们刚刚创建的 .NET Standard 库中使用。
别忘了将框架从 .NET Standard 2.0 更改为版本 2.1,并添加主应用程序和 `Razor` 库到共享库的项目引用。
现在我们可以实现 `IComponentTypeResolver` 接口,让我们向共享库添加新项。
using System;
namespace DemoShared
{
public interface IComponentTypeResolver
{
Type GetComponentTypeByName(string name);
}
}
现在我们可以从动态 razor 页面使用此接口按名称查找控件类型,我们需要在文件顶部注入 `IComponentTypeResolver`。
...
@inject DemoShared.IComponentTypeResolver _componentResolverService
...
private Type GetCustomComponent(string name)
{
return _componentResolverService.GetComponentTypeByName(name);
}
...
因此,`Counter.razor` 页面的最终代码将如下所示:
@page "/counter"
@inject ControlService _controlService
@inject DemoShared.IComponentTypeResolver _componentResolverService
@foreach (var control in ControlList)
{
@if (control.IsRequired)
{
<div>@(control.Label)*</div>
}
else
{
<div>@control.Label</div>
}
@switch (control.Type)
{
case "TextEdit":
<input @bind-value="@Values[control.Label]" required="@control.IsRequired" />
break;
case "DateEdit":
<input type="date" value="@Values[control.Label]"
@onchange="@(a => ValueChanged(a, control.Label))"
required="@control.IsRequired" />
break;
default:
var customComponent = GetCustomComponent(control.Type);
RenderFragment renderFragment = (builder) =>
{
builder.OpenComponent(0, customComponent);
builder.AddAttribute(0, "Control", control);
builder.AddAttribute(0, "Values", Values);
builder.CloseComponent();
};
<div>
@renderFragment
</div>
break;
}
}
<br />
<button @onclick="OnClick">Submit</button>
@code
{
private List<ControlDetails> ControlList;
private Dictionary<string, string> Values;
protected override async Task OnInitializedAsync()
{
ControlList = _controlService.GetControls();
Values = ControlList.ToDictionary(c => c.Label, c => "");
}
void ValueChanged(ChangeEventArgs a, string label)
{
Values[label] = a.Value.ToString();
}
string GetValue(string label)
{
return Values[label];
}
private void OnClick(MouseEventArgs e)
{
// send your Values
}
private Type GetCustomComponent(string name)
{
return _componentResolverService.GetComponentTypeByName(name);
}
}
现在可以编译和运行该解决方案,但它会抛出异常,抱怨 `_componentResolverService` 无法解析,因为它未在依赖注入中注册。我们将在最后一步注册类型解析服务。
实现 - 自定义控件
现在让我们创建一个自定义控件,但在此之前,我们需要将 `ControlDetails.cs` 移动到共享库,因为这个类也应该可以从 `Razor` 库访问。
控件代码将如下所示:
@namespace DemoRazorClassLibrary
<div class="my-component">
This Blazor component is defined in the <strong>DemoRazorClassLibrary</strong> package.
<input @bind-value="@Values[Control.Label]" required="@Control.IsRequired" />
</div>
@code
{
[Parameter]
public DemoDynamicContent.ControlDetails Control { get; set; }
[Parameter]
public Dictionary<string, string> Values { get; set; }
}
我使用了 `@namespace` 来显式指定控件类型的完整名称 - 现在它将是 `DemoRazorClassLibrary.Component1`,无论您将其移动到哪个文件夹,现在我们可以创建一个 `ComponentResolverService` 类,该类将在 `Dictionary` 中注册创建的控件类型,以便在 Blazor 引擎想要重新渲染页面时能够快速按名称找到其类型。
控件输入参数由 `Parameter` 属性标记,它们的名称与我们在动态页面渲染代码中提供的名称相同。
最后一部分是解析器,它看起来像这样:
using DemoShared;
using System;
using System.Collections.Generic;
using System.Text;
namespace DemoRazorClassLibrary
{
public class ComponentResolverService : IComponentTypeResolver
{
private readonly Dictionary<string, Type> _types = new Dictionary<string, Type>();
public ComponentResolverService()
{
_types["Component1"] = typeof(DemoRazorClassLibrary.Component1);
}
public Type GetComponentTypeByName(string name)
{
return _types[name];
}
}
}
现在,如果我们想注册另一个自定义控件,我们只需添加一个新的控件 Razor 文件并在 `ComponentResolverService` 构造函数中注册其类型。
实现 - 运行
如果我们现在运行我们的解决方案,它将不起作用,因为我们忘记在依赖注入中注册 `ComponentResolverService`。我们需要打开 `Startup.cs` 并添加注册代码行,这样 `ConfigureServices` 方法将如下所示:
public void ConfigureServices(IServiceCollection services)
{
services.AddRazorPages();
services.AddServerSideBlazor();
services.AddSingleton<WeatherForecastService>();
// added line for ControlService
services.AddSingleton<ControlService>();
// added line for Type Resolution Service
services.AddSingleton<DemoShared.IComponentTypeResolver,
DemoRazorClassLibrary.ComponentResolverService>();
}
但这还不够!我们还需要从主项目添加对 `Razor` 库的项目引用 - 尽管我们试图避免进行此引用。
但是,我们可以尝试将 `Counter.Razor` 页面移动到一个新程序集中,这样动态页面和自定义控件之间就不会有任何依赖关系。
或者,可以通过在运行时将程序集加载到 `AppDomain` 并使用反射查找所需类型并注册它来完成 `ComponentResolverService` 的依赖注入注册。我们现在不这样做只是为了简化。
在 Pro Coders,我们经常使用反射,也许在接下来的博客文章中,我将向您展示如何动态加载未引用的程序集中的组件 - 这是一种众所周知的插件实践。
我们修改了主项目的 `ControlService` 存根类以使用创建的自定义控件 `Component1`。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace DemoDynamicContent
{
public class ControlService
{
public List<ControlDetails> GetControls()
{
var result = new List<ControlDetails>();
result.Add(new ControlDetails { Type = "TextEdit",
Label = "First Name", IsRequired = true });
result.Add(new ControlDetails { Type = "TextEdit",
Label = "Last Name", IsRequired = true });
result.Add(new ControlDetails { Type = "DateEdit",
Label = "Birth Date", IsRequired = false });
// add custom control
result.Add(new ControlDetails { Type = "Component1",
Label = "Custom1", IsRequired = false });
return result;
}
}
}
全部完成!现在,让我们运行并查看结果。
填写控件后,我点击了 **Submit**(提交)按钮,让我们在调试中查看我们的 `Values` Dictionary。
如您所见,所有输入的 are 存储在 `Dictionary` 中,如果需要,我们可以将其保存到数据库。
用户故事 #2 已完成。
摘要
本文演示了一种动态 UI 生成方法,在这种方法中,您事先不知道所有控件的类型。这对于可扩展的内容管理系统或动态表单框架来说是一个众所周知的挑战,因为将新控件嵌入到页面生成逻辑中受到限制。
感谢 Microsoft Blazor 开发者提供了使用 `RenderTreeBuilder` 类进行自定义渲染的优雅方式。
下次再见,感谢您的阅读。
历史
- 2020 年 10 月 16 日:初始版本