Blazing.Mvvm - 使用 Mvvm Community Toolkit 的 Blazor Server、WebAssembly 和混合应用





5.00/5 (12投票s)
通过使用 Microsoft Community Toolkit 的 Blazing.Mvvm 库简化 MVVM
目录
概述
开发 Blazor 应用不需要 MVVM。其绑定系统比 WinForms 和 WPF 等其他应用程序框架更简单。
然而,MVVM 模式有许多优点,例如逻辑与视图分离、可测试性、降低风险和协作。
Blazor 有几个库试图支持 MVVM 设计模式,但它们使用起来并不最简单。同时,还有支持 WPF、Xamarin 和 MAUI 应用程序框架的 CommunityToolkit.Mvvm。
为什么不支持 Blazor?本文介绍了一个使用 CommunityToolkit.Mvvm 的 Blazor MVVM 实现,通过一个名为 Blazing.MVVM 的库。如果您熟悉 CommunityToolkit.Mvvm,那么您已经知道如何使用此实现。
下载次数
源代码 (通过 GitHub)
** 如果您觉得此库有用,请给 Github 仓库 点个星。
Nuget
第 1 部分 - Blazing.Mvvm 库
这是 blazor-mvvm 仓库的扩展,由 Kelly Adams 实现,通过 CommunityToolkit.Mvvm 提供完整的 MVVM 支持。进行了少量更改以防止跨线程异常,添加了额外的基类类型、MVVM 风格的导航,并将其转换为一个可用的库。
入门
-
将 Blazing.Mvvm Nuget 包 添加到您的项目。
-
在您的 Program.cs 文件中启用
MvvmNavigation
支持-
Blazor Server App
builder.Services.AddMvvmNavigation(options => { options.HostingModel = BlazorHostingModel.Server; });
-
Blazor WebAssembly App
builder.Services.AddMvvmNavigation();
-
Blazor Web App(.NET 8.0 新增)
builder.Services.AddMvvmNavigation(options => { options.HostingModel = BlazorHostingModel.WebApp; });
-
Blazor Hybrid App (WinForm, WPF, Avalonia, MAUI)
builder.Services.AddMvvmNavigation(options => { options.HostingModel = BlazorHostingModel.Hybrid; });
-
- 创建一个继承
ViewModelBase
类的ViewModel
public partial class FetchDataViewModel : ViewModelBase { [ObservableProperty] private ObservableCollection<WeatherForecast> _weatherForecasts = new(); public override async Task Loaded() => WeatherForecasts = new ObservableCollection<WeatherForecast>(Get()); private static readonly string[] Summaries = { "Freezing", "Bracing", "Chilly", "Cool", "Mild", "Warm", "Balmy", "Hot", "Sweltering", "Scorching" }; public IEnumerable<WeatherForecast> Get() => Enumerable.Range(1, 5).Select(index => new WeatherForecast { Date = DateTime.Now.AddDays(index), TemperatureC = Random.Shared.Next(-20, 55), Summary = Summaries[Random.Shared.Next(Summaries.Length)] }) .ToArray(); }
- 在您的 Program.cs 文件中注册
ViewModel
builder.Services.AddTransient<FetchDataViewModel>();
- 创建您的页面,继承
MvvmComponentBase<TViewModel>
组件@page "/fetchdata" @inherits MvvmComponentBase<FetchDataViewModel> <PageTitle>Weather forecast</PageTitle> <h1>Weather forecast</h1> <p>This component demonstrates fetching data from the server.</p> @if (!ViewModel.WeatherForecasts.Any()) { <p><em>Loading...</em></p> } else { <table class="table"> <thead> <tr> <th>Date</th> <th>Temp. (C)</th> <th>Temp. (F)</th> <th>Summary</th> </tr> </thead> <tbody> @foreach (var forecast in ViewModel.WeatherForecasts) { <tr> <td>@forecast.Date.ToShortDateString()</td> <td>@forecast.TemperatureC</td> <td>@forecast.TemperatureF</td> <td>@forecast.Summary</td> </tr> } </tbody> </table> }
- (可选)修改
NavMenu.razor
以使用MvvmNavLink
进行ViewModel
导航<div class="nav-item px-3"> <MvvmNavLink class="nav-link" TViewModel=FetchDataViewModel> <span class="oi oi-list-rich" aria-hidden="true"></span> Fetch data </MvvmNavLink> </div>
现在运行应用程序。
通过代码使用 MvvmNavigationManager
进行 ViewModel
导航,将类注入到您的页面或 ViewModel
中,然后使用 NavigateTo
方法
mvvmNavigationManager.NavigateTo<FetchDataViewModel>();
NavigateTo
方法与标准 Blazor NavigationManager
的工作方式相同,并且还支持传递相对 URL 和/或查询字符串。
如果您喜欢抽象,那么您也可以通过接口导航
mvvmNavigationManager.NavigateTo<ITestNavigationViewModel>();
相同的原理适用于 MvvmNavLink
组件
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
RelativeUri="this is a MvvmNavLink test"
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test + Params
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
RelativeUri="?test=this%20is%20a%20MvvmNavLink%20querystring%20test"
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test + QueryString
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link"
TViewModel=ITestNavigationViewModel
RelativeUri="this is a MvvmNvLink test/?
test=this%20is%20a%20MvvmNavLink%20querystring%20test"
Match="NavLinkMatch.All">
<span class="oi oi-calculator" aria-hidden="true"></span>Test + Both
</MvvmNavLink>
</div>
MVVM 的工作原理
分为两个部分
ViewModelBase
MvvmComponentBase
MvvmComponentBase
负责将 ViewModel
连接到组件。
public abstract class MvvmComponentBase<TViewModel>
: ComponentBase, IView<TViewModel>
where TViewModel : IViewModelBase
{
[Inject]
protected TViewModel? ViewModel { get; set; }
protected override void OnInitialized()
{
// Cause changes to the ViewModel to make Blazor re-render
ViewModel!.PropertyChanged += (_, _) => InvokeAsync(StateHasChanged);
base.OnInitialized();
}
protected override Task OnInitializedAsync()
=> ViewModel!.OnInitializedAsync();
}
这是包装 ObservableObject
的 ViewModelBase
类
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
namespace Blazing.Mvvm.ComponentModel;
public abstract partial class ViewModelBase : ObservableObject, IViewModelBase
{
public virtual async Task OnInitializedAsync()
=> await Loaded().ConfigureAwait(true);
protected virtual void NotifyStateChanged() => OnPropertyChanged((string?)null);
[RelayCommand]
public virtual async Task Loaded()
=> await Task.CompletedTask.ConfigureAwait(false);
}
由于 MvvmComponentBase
正在监听 ViewModelBase
实现的 PropertyChanged
事件,因此当 ViewModelBase
实现中的属性发生更改或调用 NotifyStateChanged
时,MvvmComponentBase
会自动处理 UI 刷新。
还支持 EditForm
验证和消息传递。请参阅示例代码,了解大多数用例的用法示例。
MVVM 导航的工作原理
不再有魔术字符串!现在可以进行强类型导航。如果页面 URI 发生变化,不再需要翻阅源代码进行更改。它会在运行时自动为您解析!
MvvmNavigationManager 类
当 MvvmNavigationManager
由 IOC 容器作为 Singleton 初始化时,该类将检查所有程序集并内部缓存所有 ViewModel
(类和接口)及其关联的页面。然后,当需要导航时,会进行快速查找,然后使用 Blazor NavigationManager
导航到正确的页面。如果通过 NavigateTo
方法调用传入了任何相对 Uri 和/或 QueryString
,也会一并传递。
注意:MvvmNavigationManager
类不能完全替代 Blazor NavigationManager
类,只实现了对 MVVM 的支持。对于标准的“魔术字符串”导航,请使用 NavigationManager
类。
/// <summary>
/// Provides an abstraction for querying and managing navigation via ViewModel
// (class/interface).
/// </summary>
public class MvvmNavigationManager : IMvvmNavigationManager
{
private readonly NavigationManager _navigationManager;
private readonly ILogger<MvvmNavigationManager> _logger;
private readonly Dictionary<Type, string> _references = new();
public MvvmNavigationManager(NavigationManager navigationManager,
ILogger<MvvmNavigationManager> logger)
{
_navigationManager = navigationManager;
_logger = logger;
GenerateReferenceCache();
}
/// <summary>
/// Navigates to the specified associated URI.
/// </summary>
/// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/>
/// to use to determine the
/// URI to navigate to.</typeparam>
/// <param name="forceLoad">If true, bypasses client-side routing
/// and forces the browser to load
/// the new page from the server, whether or not the URI would normally
/// be handled by the client-side router.</param>
/// <param name="replace">If true, replaces the current entry in the history stack.
/// If false,
/// appends the new entry to the history stack.</param>
public void NavigateTo<TViewModel>(bool? forceLoad = false, bool? replace = false)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
}
/// <summary>
/// Navigates to the specified associated URI.
/// </summary>
/// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/> to use to
/// determine the URI to navigate to.</typeparam>
/// <param name="options">Provides additional <see cref="NavigationOptions"/>
/// .</param>
public void NavigateTo<TViewModel>(NavigationOptions options)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, options);
}
/// <summary>
/// Navigates to the specified associated URI.
/// </summary>
/// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/>
/// to use to determine
/// the URI to navigate to.</typeparam>
/// <param name="relativeUri">relative URI &/or QueryString appended to
/// the navigation Uri
/// .</param>
/// <param name="forceLoad">If true, bypasses client-side routing and
/// forces the browser to load
/// the new page from the server, whether or not the URI would normally
/// be handled by the client-side router.</param>
/// <param name="replace">If true, replaces the current entry
/// in the history stack. If false,
/// appends the new entry to the history stack.</param>
public void NavigateTo<TViewModel>(string? relativeUri = null,
bool? forceLoad = false, bool? replace = false)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, (bool)forceLoad!, (bool)replace!);
}
/// <summary>
/// Navigates to the specified associated URI.
/// </summary>
/// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/>
/// to use to determine
/// the URI to navigate to.</typeparam>
/// <param name="relativeUri">relative URI &/or QueryString appended
/// to the navigation Uri.</param>
/// <param name="options">Provides additional
/// <see cref="NavigationOptions"/>.</param>
public void NavigateTo<TViewModel>(string relativeUri, NavigationOptions options)
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
uri = BuildUri(_navigationManager.ToAbsoluteUri(uri).AbsoluteUri, relativeUri);
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Navigating '{typeof(TViewModel).FullName}'
to uri '{uri}'");
_navigationManager.NavigateTo(uri, options);
}
/// <summary>
/// Get the <see cref="IViewModelBase"/> associated URI.
/// </summary>
/// <typeparam name="TViewModel">The type <see cref="IViewModelBase"/>
/// to use to determine the URI to navigate to.</typeparam>
/// <returns>A relative URI path.</returns>
/// <exception cref="ArgumentException"></exception>
public string GetUri<TViewModel>()
where TViewModel : IViewModelBase
{
if (!_references.TryGetValue(typeof(TViewModel), out string? uri))
throw new ArgumentException($"{typeof(TViewModel)} has no associated page");
return uri;
}
#region Internals
private static string BuildUri(string uri, string? relativeUri)
{
if (string.IsNullOrWhiteSpace(relativeUri))
return uri;
UriBuilder builder = new(uri);
if (relativeUri.StartsWith('?'))
builder.Query = relativeUri.TrimStart('?');
else if (relativeUri.Contains('?'))
{
string[] parts = relativeUri.Split('?');
builder.Path = builder.Path.TrimEnd('/') + "/" + parts[0].TrimStart('/');
builder.Query = parts[1];
}
else
builder.Path = builder.Path.TrimEnd('/') + "/" + relativeUri.TrimStart('/');
return builder.ToString();
}
private void GenerateReferenceCache()
{
Assembly[] assemblies = AppDomain.CurrentDomain.GetAssemblies();
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Starting generation of a new Reference Cache");
foreach (Assembly assembly in assemblies)
{
List<(Type Type, Type? Argument)> items;
try
{
items = assembly
.GetTypes()
.Select(GetViewArgumentType)
.Where(t => t.Argument is not null)
.ToList();
}
catch (Exception)
{
// avoid issue with unit tests
continue;
}
// does the assembly contain the required types?
if (!items.Any())
continue;
foreach ((Type Type, Type? Argument) item in items)
{
Attribute? attribute = item.Type.GetCustomAttributes()
.FirstOrDefault(a => a is RouteAttribute);
// is this a page or a component?
if (attribute is null)
continue;
// we have a page, let's reference it!
string uri = ((RouteAttribute)attribute).Template;
_references.Add(item.Argument!, uri);
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug($"Caching navigation reference
'{item.Argument!}' with
uri '{uri}' for '{item.Type.FullName}'");
}
}
if (_logger.IsEnabled(LogLevel.Debug))
_logger.LogDebug("Completed generating the Reference Cache");
}
private static (Type Type, Type? Argument) GetViewArgumentType(Type type)
{
Type viewInterfaceType = typeof(IView<>);
Type viewModelType = typeof(IViewModelBase);
Type ComponentBaseGenericType = typeof(MvvmComponentBase<>);
Type? ComponentBaseType = null;
Type? typeArgument = null;
// Find the generic type definition for MvvmComponentBase<>
// with the correct type argument
foreach (Type interfaceType in type.GetInterfaces())
{
if (!interfaceType.IsGenericType ||
interfaceType.GetGenericTypeDefinition() != viewInterfaceType)
continue;
typeArgument = interfaceType.GetGenericArguments()[0];
ComponentBaseType = ComponentBaseGenericType.MakeGenericType(typeArgument);
break;
}
if (ComponentBaseType == null)
return default;
// Check if the type constraint is a subtype of MvvmComponentBase<>
if (!ComponentBaseType.IsAssignableFrom(type))
return default;
// get all interfaces
Type[] interfaces = ComponentBaseType
.GetGenericArguments()[0]
.GetInterfaces();
// Check if the type argument of IView<> implements IViewModel
if (interfaces.FirstOrDefault(i => i.Name == $"{viewModelType.Name}") is null)
return default;
// all checks passed, so return the type with the argument type declared
return (type, typeArgument);
}
#endregion
}
注意:如果启用 Debug
级别日志记录,MvvmNavigationManager
将输出构建缓存时建立的关联。例如
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Starting generation of a new Reference Cache
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.FetchDataViewModel'
with uri '/fetchdata' for 'Blazing.Mvvm.Sample.Wasm.Pages.FetchData'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.EditContactViewModel'
with uri '/form' for 'Blazing.Mvvm.Sample.Wasm.Pages.Form'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.HexTranslateViewModel'
with uri '/hextranslate' for 'Blazing.Mvvm.Sample.Wasm.Pages.HexTranslate'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Caching navigation reference
'Blazing.Mvvm.Sample.Wasm.ViewModels.ITestNavigationViewModel'
with uri '/test' for 'Blazing.Mvvm.Sample.Wasm.Pages.TestNavigation'
dbug: Blazing.Mvvm.Components.MvvmNavigationManager[0]
Completed generating the Reference Cache
MvvmNavLink 组件
MvvmNavLink
组件基于 Blazor Navlink
组件,并具有额外的 TViewModel
和 RelativeUri
属性。在内部,它使用 MvvmNavigationManager
进行导航。
/// <summary>
/// A component that renders an anchor tag, automatically toggling its 'active'
/// class based on whether its 'href' matches the current URI. Navigation is based on
/// ViewModel (class/interface).
/// </summary>
public class MvvmNavLink<TViewModel> : ComponentBase, IDisposable
where TViewModel : IViewModelBase
{
private const string DefaultActiveClass = "active";
private bool _isActive;
private string? _hrefAbsolute;
private string? _class;
[Inject]
private IMvvmNavigationManager MvvmNavigationManager { get; set; } = default!;
[Inject]
private NavigationManager NavigationManager { get; set; } = default!;
/// <summary>
/// Gets or sets the CSS class name applied to the NavLink when the
/// current route matches the NavLink href.
/// </summary>
[Parameter]
public string? ActiveClass { get; set; }
/// <summary>
/// Gets or sets a collection of additional attributes
/// that will be added to the generated
/// <c>a</c> element.
/// </summary>
[Parameter(CaptureUnmatchedValues = true)]
public IDictionary<string, object>? AdditionalAttributes { get; set; }
/// <summary>
/// Gets or sets the computed CSS class based on whether or not the link is active.
/// </summary>
protected string? CssClass { get; set; }
/// <summary>
/// Gets or sets the child content of the component.
/// </summary>
[Parameter]
public RenderFragment? ChildContent { get; set; }
/// <summary>
/// Gets or sets a value representing the URL matching behavior.
/// </summary>
[Parameter]
public NavLinkMatch Match { get; set; }
/// <summary>
///Relative URI &/or QueryString appended to the associate URI.
/// </summary>
[Parameter]
public string? RelativeUri { get; set; }
/// <inheritdoc />
protected override void OnInitialized()
{
// We'll consider re-rendering on each location change
NavigationManager.LocationChanged += OnLocationChanged;
}
/// <inheritdoc />
protected override void OnParametersSet()
{
_hrefAbsolute = BuildUri(NavigationManager.ToAbsoluteUri(
MvvmNavigationManager.GetUri<TViewModel>()).AbsoluteUri, RelativeUri);
AdditionalAttributes?.Add("href", _hrefAbsolute);
_isActive = ShouldMatch(NavigationManager.Uri);
_class = null;
if (AdditionalAttributes != null &&
AdditionalAttributes.TryGetValue("class", out object? obj))
_class = Convert.ToString(obj, CultureInfo.InvariantCulture);
UpdateCssClass();
}
/// <inheritdoc />
public void Dispose()
{
// To avoid leaking memory, it's important to detach
// any event handlers in Dispose()
NavigationManager.LocationChanged -= OnLocationChanged;
}
private static string BuildUri(string uri, string? relativeUri)
{
if (string.IsNullOrWhiteSpace(relativeUri))
return uri;
UriBuilder builder = new(uri);
if (relativeUri.StartsWith('?'))
builder.Query = relativeUri.TrimStart('?');
else if (relativeUri.Contains('?'))
{
string[] parts = relativeUri.Split('?');
builder.Path = builder.Path.TrimEnd('/') + "/" + parts[0].TrimStart('/');
builder.Query = parts[1];
}
else
builder.Path = builder.Path.TrimEnd('/') + "/" + relativeUri.TrimStart('/');
return builder.ToString();
}
private void UpdateCssClass()
=> CssClass = _isActive
? CombineWithSpace(_class, ActiveClass ?? DefaultActiveClass)
: _class;
private void OnLocationChanged(object? sender, LocationChangedEventArgs args)
{
// We could just re-render always, but for this component we know the
// only relevant state change is to the _isActive property.
bool shouldBeActiveNow = ShouldMatch(args.Location);
if (shouldBeActiveNow != _isActive)
{
_isActive = shouldBeActiveNow;
UpdateCssClass();
StateHasChanged();
}
}
private bool ShouldMatch(string currentUriAbsolute)
{
if (_hrefAbsolute == null)
return false;
if (EqualsHrefExactlyOrIfTrailingSlashAdded(currentUriAbsolute))
return true;
return Match == NavLinkMatch.Prefix
&& IsStrictlyPrefixWithSeparator(currentUriAbsolute, _hrefAbsolute);
}
private bool EqualsHrefExactlyOrIfTrailingSlashAdded(string currentUriAbsolute)
{
Debug.Assert(_hrefAbsolute != null);
if (string.Equals(currentUriAbsolute, _hrefAbsolute,
StringComparison.OrdinalIgnoreCase))
return true;
if (currentUriAbsolute.Length == _hrefAbsolute.Length - 1)
{
// Special case: highlight links to http://host/path/ even if you're
// at http://host/path (with no trailing slash)
//
// This is because the router accepts an absolute URI value of "same
// as base URI but without trailing slash" as equivalent to "base URI",
// which in turn is because it's common for servers to return the same page
// for http://host/vdir as they do for host://host/vdir/ as it's no
// good to display a blank page in that case.
if (_hrefAbsolute[^1] == '/'
&& _hrefAbsolute.StartsWith(currentUriAbsolute,
StringComparison.OrdinalIgnoreCase))
return true;
}
return false;
}
/// <inheritdoc/>
protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "a");
builder.AddMultipleAttributes(1, AdditionalAttributes);
builder.AddAttribute(2, "class", CssClass);
builder.AddContent(3, ChildContent);
builder.CloseElement();
}
private static string CombineWithSpace(string? str1, string str2)
=> str1 == null ? str2 : $"{str1} {str2}";
private static bool IsStrictlyPrefixWithSeparator(string value, string prefix)
{
int prefixLength = prefix.Length;
if (value.Length > prefixLength)
{
return value.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
&& (
// Only match when there's a separator character
// either at the end of the
// prefix or right after it.
// Example: "/abc" is treated as a prefix of "/abc/def"
// but not "/abcdef"
// Example: "/abc/" is treated as a prefix of "/abc/def"
// but not "/abcdef"
prefixLength == 0
|| !char.IsLetterOrDigit(prefix[prefixLength - 1])
|| !char.IsLetterOrDigit(value[prefixLength])
);
}
return false;
}
}
测试
包含了导航和消息传递的测试。
第 2 部分 - 转换现有应用程序
虽然 仓库 包含了一个演示如何使用该库的基本示例项目,但我希望包含一个示例,该示例将一个针对不同应用程序类型的现有项目进行最小更改,使其适用于 Blazor。因此,我采用了 Microsoft 的 Xamarin 示例项目 并将其转换为 Blazor。
Xamarin 示例到 Blazor 的更改
MvvmSample.Core
项目基本保持不变,我已将基类添加到 ViewModel
中以启用 Blazor 绑定更新。
因此,举例来说,SamplePageViewModel
从
public class MyPageViewModel : ObservableObject
{
// code goes here
}
to
public class MyPageViewModel : ViewModelBase
{
// code goes here
}
ViewModelBase
包装了 ObservableObject
类。不需要其他更改。
对于 Xamarin 页面,DataContext
的连接方式是
BindingContext = Ioc.Default.GetRequiredService<MyPageViewModel>();
使用 Blazing.MVVM,它很简单
@inherits MvvmComponentBase<MyPageViewModel>
最后,我已将示例应用程序中使用的所有文档从 Xamarin 特定的更新为 Blazor。如果我遗漏了任何更改,请告诉我,我将进行更新。
Components
Xamarin 拥有一套丰富的控件。相比之下,Blazor 则较为精简。为了保持此项目的精简,我包含了自己编写的 ListBox
和 Tab
控件 - 尽情享用!待有时间,我将努力完成并发布一个 Blazor 控件库。
WASM + 新的 WPF 和 Avalonia Blazor 混合示例应用程序
我添加了新的 WPF/Avalonia 混合应用程序,以演示如何使用 MVVM 从 WPF/Avalonia 调用 Blazor。为此,我做了以下工作:
- 将
BlazorSample
应用的核心共享部分移动到一个新的 RCL (Razor 类库) - 将 Assets 移动到标准 Content 文件夹,因为 wwwroot 不再可访问。
BlazorWebView
主机控件使用 IP 地址0.0.0.0
,这对httpClient
无效。 - 在 WPF/Avalonia 应用中添加了新的
FileService
类,以使用File
类而不是HttpClient
。 - 在 WPF/Avalonia 应用中添加了新的
App.Razor
,用于自定义 Blazor 布局并连接共享状态以处理来自 WPF/Avalonia 的导航请求。 - 为了实现调用 Blazor 应用程序,我使用了一个
static
状态类来保存对NavigationManager
和MvvvmNavigationManager
类的引用。
Blazor Wasm 示例应用
由于我们已将 Blazor 应用程序的核心部分移至共享项目 MvvmSampleBlazor.Core
,我们只需添加一个引用即可。
Program.cs
我们需要启动并绑定应用程序
WebAssemblyHostBuilder builder = WebAssemblyHostBuilder.CreateDefault(args);
builder.RootComponents.Add<App>("#app");
builder.RootComponents.Add<HeadOutlet>("head::after");
builder.Services
.AddScoped(sp => new HttpClient
{ BaseAddress = new Uri(builder.HostEnvironment.BaseAddress) })
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServices()
.AddMvvmNavigation();
#if DEBUG
builder.Logging.SetMinimumLevel(LogLevel.Trace);
#endif
await builder.Build().RunAsync();
App.razor
接下来,我们需要在 app.razor
中指向页面所在的位置
<Router AppAssembly="@typeof(MvvmSampleBlazor.Core.Root).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(MainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(MainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
NavMenu.razor
最后,我们将连接 blazor 导航
<div class="top-row ps-3 navbar navbar-dark">
<div class="container-fluid">
@*<a class="navbar-brand" href="">Blazor Mvvm Sample</a>*@
<button title="Navigation menu" class="navbar-toggler" @onclick="ToggleNavMenu">
<span class="navbar-toggler-icon"></span>
</button>
</div>
</div>
<div class="@NavMenuCssClass nav-scrollable" @onclick="ToggleNavMenu">
<nav class="flex-column">
<div class="nav-item px-3">
<NavLink class="nav-link" href="" Match="NavLinkMatch.All">
<i class="bi bi-play" aria-hidden="true"></i> Introduction
</NavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=ObservableObjectPageViewModel>
<i class="bi bi-arrow-down-up" aria-hidden="true"></i> ObservableObject
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=RelayCommandPageViewModel>
<i class="bi bi-layer-backward" aria-hidden="true"></i> Relay Commands
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=AsyncRelayCommandPageViewModel>
<i class="bi bi-flag" aria-hidden="true"></i> Async Commands
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=MessengerPageViewModel>
<i class="bi bi-chat-left" aria-hidden="true"></i> Messenger
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=MessengerSendPageViewModel>
<i class="bi bi-send" aria-hidden="true"></i> Sending Messages
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=MessengerRequestPageViewModel>
<i class="bi bi-arrow-left-right" aria-hidden="true"></i>
Request Messages
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IocPageViewModel>
<i class="bi bi-box-arrow-in-down-right" aria-hidden="true"></i>
Inversion of Control
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=ISettingUpTheViewModelsPageViewModel>
<i class="bi bi-bounding-box" aria-hidden="true"></i> ViewModel Setup
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=ISettingsServicePageViewModel>
<i class="bi bi-wrench" aria-hidden="true"></i> Settings Service
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IRedditServicePageViewModel>
<i class="bi bi-globe-americas" aria-hidden="true"></i> Reddit Service
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IBuildingTheUIPageViewModel>
<i class="bi bi-rulers" aria-hidden="true"></i> Building the UI
</MvvmNavLink>
</div>
<div class="nav-item px-3">
<MvvmNavLink class="nav-link" TViewModel=IRedditBrowserPageViewModel>
<i class="bi bi-reddit" aria-hidden="true"></i> The Final Result
</MvvmNavLink>
</div>
</nav>
</div>
@code {
private bool collapseNavMenu = true;
private string? NavMenuCssClass => collapseNavMenu ? "collapse" : null;
private void ToggleNavMenu()
{
collapseNavMenu = !collapseNavMenu;
}
}
Blazor 混合应用
我们可以将 Blazor 应用程序嵌入到标准的桌面或 MAUI 应用程序中。接下来我们将看两个例子——WPF 和 Avalonia。同样的原理也适用于 WinForms 和 MAUI。
AppState 类
对于 Blazor 混合应用程序,我们需要一种在两个应用程序框架之间进行通信的方法。这个类充当本机应用程序和 Blazor 应用程序之间的链接。它公开了页面导航。
public static class AppState
{
public static INavigation Navigation { get; set; } = null!;
}
导航操作委托的契约定义
public interface INavigation
{
void NavigateTo(string page);
void NavigateTo<TViewModel>() where TViewModel : IViewModelBase;
}
Wpf Blazor 混合应用
如果我们要在一个原生的 Windows 应用程序中托管一个 Blazor 应用程序,一个混合 Blazor 应用程序,该怎么办?也许我们想将原生的 WPF 控件与 Blazor 内容一起使用。下面的示例应用程序将展示如何做到这一点。
MainWindow.Xaml
现在我们可以使用 BlazorWebView
控件来托管 Blazor 应用。对于导航,我使用的是 WPF Button
控件。我将 Button
绑定到 MainWindowViewModel
中保存的一个 Dictionary
条目。
<Window x:Class="MvvmSampleBlazor.Wpf.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;
assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:shared="clr-namespace:MvvmSampleBlazor.Wpf.Shared"
Title="WPF MVVM Blazor Hybrid Sample Application"
Height="800" Width="1000" WindowStartupLocation="CenterScreen">
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<ItemsControl x:Name="ButtonsList"
Grid.Column="0" Grid.Row="0" Padding="20"
ItemsSource="{Binding NavigationActions}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding Value.Title}" Padding="10 5"
Margin="0 0 0 10"
Command="{Binding ElementName=ButtonsList,
Path=DataContext.NavigateToCommand}"
CommandParameter="{Binding Key}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<blazor:BlazorWebView Grid.Column="1" Grid.Row="0"
HostPage="wwwroot\index.html"
Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app"
ComponentType="{x:Type shared:App}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
<TextBlock Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Stretch"
TextAlignment="Center"
Padding="0 10"
Background="LightGray"
FontWeight="Bold"
Text="Click on the BlazorWebView control, then CTRL-SHIFT-I or
F12 to open the Browser DevTools window..." />
</Grid>
</Window>
MainWindowViewModel 类
该类通过 AppState
类定义和管理命令导航。当命令执行时,会进行快速查找并执行相关的操作——不需要 switch
或 if ... else
逻辑。
internal class MainWindowViewModel : ViewModelBase
{
public MainWindowViewModel()
=> NavigateToCommand = new RelayCommand<string>(arg =>
NavigationActions[arg!].Action.Invoke());
public IRelayCommand<string> NavigateToCommand { get; set; }
public Dictionary<string, NavigationAction> NavigationActions { get; } = new()
{
["home"] = new("Introduction", () => NavigateTo("/")),
["observeObj"] = new("ObservableObject", NavigateTo<ObservableObjectPageViewModel>),
["relayCommand"] = new("Relay Commands", NavigateTo<RelayCommandPageViewModel>),
["asyncCommand"] = new("Async Commands", NavigateTo<AsyncRelayCommandPageViewModel>),
["msg"] = new("Messenger", NavigateTo<MessengerPageViewModel>),
["sendMsg"] = new("Sending Messages", NavigateTo<MessengerSendPageViewModel>),
["ReqMsg"] = new("Request Messages", NavigateTo<MessengerRequestPageViewModel>),
["ioc"] = new("Inversion of Control", NavigateTo<IocPageViewModel>),
["vmSetup"] = new("ViewModel Setup", NavigateTo<ISettingUpTheViewModelsPageViewModel>),
["SettingsSvc"] = new("Settings Service", NavigateTo<ISettingsServicePageViewModel>),
["redditSvc"] = new("Reddit Service", NavigateTo<IRedditServicePageViewModel>),
["buildUI"] = new("Building the UI", NavigateTo<IBuildingTheUIPageViewModel>),
["reddit"] = new("The Final Result", NavigateTo<IRedditBrowserPageViewModel>),
};
private static void NavigateTo(string url)
=> AppState.Navigation.NavigateTo(url);
private static void NavigateTo<TViewModel>() where TViewModel : IViewModelBase
=> AppState.Navigation.NavigateTo<TViewModel>();
}
包装记录类
public record NavigationAction(string Title, Action Action);
App.razor
我们需要将 Blazor 的导航暴露给原生应用。
@inject NavigationManager NavManager
@inject IMvvmNavigationManager MvvmNavManager
@implements MvvmSampleBlazor.Wpf.States.INavigation
<Router AppAssembly="@typeof(Core.Root).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(NewMainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(NewMainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code
{
protected override void OnInitialized()
{
AppState.Navigation = this;
base.OnInitialized();
// force refresh to overcome Hybrid app not initializing WebNavigation
MvvmNavManager.ForceNavigationManagerUpdate(NavManager);
}
public void NavigateTo(string page)
=> NavManager.NavigateTo(page);
public void NavigateTo<TViewModel>() where TViewModel : IViewModelBase
=> MvvmNavManager.NavigateTo<TViewModel>(new NavigationOptions());
}
注意:由于 BlazorWebView
控件的一个怪癖,使用 MvvmNavigationManager
进行 IOC 导航将抛出以下异常
System.InvalidOperationException: ''WebViewNavigationManager' has not been initialized.'
为了克服这个问题,我们需要刷新 MvvmNavigationManager
类中内部的 NavigationManager
引用。我正在使用反射来完成此操作
public static class NavigationManagerExtensions
{
public static void ForceNavigationManagerUpdate(
this IMvvmNavigationManager mvvmNavManager, NavigationManager navManager)
{
FieldInfo? prop = mvvmNavManager.GetType().GetField("_navigationManager",
BindingFlags.NonPublic | BindingFlags.Instance);
prop!.SetValue(mvvmNavManager, navManager);
}
}
App.xaml.cs
最后,我们需要将所有功能连接起来
public partial class App
{
public App()
{
HostApplicationBuilder builder = Host.CreateApplicationBuilder();
IServiceCollection services = builder.Services;
services.AddWpfBlazorWebView();
#if DEBUG
builder.Services.AddBlazorWebViewDeveloperTools();
#endif
services
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServicesWpf()
.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.Hybrid;
});
#if DEBUG
builder.Logging.SetMinimumLevel(LogLevel.Trace);
#endif
services.AddScoped<MainWindow>();
Resources.Add("services", services.BuildServiceProvider());
// will throw an error
//MainWindow = provider.GetRequiredService<MainWindow>();
//MainWindow.Show();
}
}
Avalonia(仅限 Windows)Blazor 混合应用
对于 Avalonia,我们需要为 BlazorWebView
控件提供一个包装器。幸运的是,有一个第三方类:Baksteen.Avalonia.Blazor - Github 仓库。我已将该类包含在内,因为我们需要更新它以适应最新的支持库的破坏性更改。
MainWindow.xaml
工作方式与 WPF 版本相同,但我们使用的是 Baksteen 包装器来封装 BlazorWebView
控件。
<Window
x:Class="MvvmSampleBlazor.Avalonia.MainWindow"
xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:blazor="clr-namespace:Baksteen.Avalonia.Blazor;assembly=Baksteen.Avalonia.Blazor"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:MvvmSampleBlazor.Avalonia.ViewModels"
Height="800" Width="1200" d:DesignHeight="500" d:DesignWidth="800"
x:DataType="vm:MainWindowViewModel"
Title="Avalonia MVVM Blazor Hybrid Sample Application" Background="DarkGray"
CanResize="True" SizeToContent="Manual" mc:Ignorable="d">
<Design.DataContext>
<vm:MainWindowViewModel />
</Design.DataContext>
<Grid>
<Grid.RowDefinitions>
<RowDefinition />
<RowDefinition Height="Auto"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto"/>
<ColumnDefinition/>
</Grid.ColumnDefinitions>
<ItemsControl x:Name="ButtonsList"
Grid.Column="0" Grid.Row="0" Padding="20"
ItemsSource="{Binding NavigationActions}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<Button Content="{Binding Value.Title}"
Padding="10 5" Margin="0 0 0 10"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
Command="{Binding ElementName=ButtonsList,
Path=DataContext.NavigateToCommand}"
CommandParameter="{Binding Key}"/>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
<blazor:BlazorWebView Grid.Column="1" Grid.Row="0"
HostPage="index.html"
RootComponents="{DynamicResource rootComponents}"
Services="{DynamicResource services}" />
<Label Grid.Row="1" Grid.ColumnSpan="2"
HorizontalAlignment="Center"
Padding="0 10"
Foreground="Black"
FontWeight="Bold"
Content="Click on the BlazorWebView control, then CTRL-SHIFT-I or
F12 to open the Browser DevTools window.." />
</Grid>
</Window>
MainWindow.Axaml.cs
我们现在可以在代码后台连接控件了
public partial class MainWindow : Window
{
public MainWindow()
{
IServiceProvider? services = (Application.Current as App)?.Services;
RootComponentsCollection rootComponents =
new() { new("#app", typeof(HybridApp), null) };
Resources.Add("services", services);
Resources.Add("rootComponents", rootComponents);
InitializeComponent();
}
}
HybridApp.razor
我们需要将 Blazor 的导航暴露给原生应用。
注意:我们正在为 app.razor
使用不同的名称,以解决路径/文件夹和命名问题。
@inject NavigationManager NavManager
@inject IMvvmNavigationManager MvvmNavManager
@implements MvvmSampleBlazor.Wpf.States.INavigation
<Router AppAssembly="@typeof(Core.Root).Assembly">
<Found Context="routeData">
<RouteView RouteData="@routeData" DefaultLayout="@typeof(NewMainLayout)" />
<FocusOnNavigate RouteData="@routeData" Selector="h1" />
</Found>
<NotFound>
<PageTitle>Not found</PageTitle>
<LayoutView Layout="@typeof(NewMainLayout)">
<p role="alert">Sorry, there's nothing at this address.</p>
</LayoutView>
</NotFound>
</Router>
@code
{
protected override void OnInitialized()
{
AppState.Navigation = this;
base.OnInitialized();
// force refresh to overcome Hybrid app not initializing WebNavigation
MvvmNavManager.ForceNavigationManagerUpdate(NavManager);
}
public void NavigateTo(string page)
=> NavManager.NavigateTo(page);
public void NavigateTo<TViewModel>() where TViewModel : IViewModelBase
=> MvvmNavManager.NavigateTo<TViewModel>(new NavigationOptions());
}
注意:Avalonia 与 WPF 有相同的怪癖,因此使用了相同的解决方法。
Program.cs
最后,我们需要将所有功能连接起来
internal class Program
{
[STAThread]
public static void Main(string[] args)
{
HostApplicationBuilder appBuilder = Host.CreateApplicationBuilder(args);
appBuilder.Logging.AddDebug();
appBuilder.Services.AddWindowsFormsBlazorWebView();
#if DEBUG
appBuilder.Services.AddBlazorWebViewDeveloperTools();
#endif
appBuilder.Services
.AddSingleton(RestService.For<IRedditService>("https://www.reddit.com/"))
.AddViewModels()
.AddServicesWpf()
.AddMvvmNavigation(options =>
{
options.HostingModel = BlazorHostingModel.Hybrid;
});
using IHost host = appBuilder.Build();
host.Start();
try
{
BuildAvaloniaApp(host.Services)
.StartWithClassicDesktopLifetime(args);
}
finally
{
Task.Run(async () => await host.StopAsync()).Wait();
}
}
private static AppBuilder BuildAvaloniaApp(IServiceProvider serviceProvider)
=> AppBuilder.Configure(() => new App(serviceProvider))
.UsePlatformDetect()
.LogToTrace();
}
额外 Blazor 组件 (控件)
构建 Blazor 示例应用程序时,我需要 Blazor 的 TabControl
和 ListBox
组件(控件)。于是我自行开发了它们。这些组件可以在解决方案中的各自项目中找到,并可在您自己的项目中使用。有一个支持库用于通用代码。这两个组件都支持键盘导航。
TabControl 用法
<TabControl>
<Panels>
<TabPanel Title="Interactive Sample">
<div class="posts__container">
<SubredditWidget />
<PostWidget />
</div>
</TabPanel>
<TabPanel Title="Razor">
@StaticStrings.RedditBrowser.sample1Razor.MarkDownToMarkUp()
</TabPanel>
<TabPanel Title="C#">
@StaticStrings.RedditBrowser.sample1csharp.MarkDownToMarkUp()
</TabPanel>
</Panels>
</TabControl>
上面是 Reddit 浏览器示例的代码。
ListBox 控件用法
<ListBox TItem=Post ItemSource="ViewModel!.Posts"
SelectedItem=@ViewModel.SelectedPost
SelectionChanged="@(e => InvokeAsync(() => ViewModel.SelectedPost = e.Item))">
<ItemTemplate Context="post">
<div class="list-post">
<h3 class="list-post__title">@post.Title</h3>
@if (post.Thumbnail is not null && post.Thumbnail != "self")
{
<img src="@post.Thumbnail"
onerror="this.onerror=null; this.style='display:none';"
alt="@post.Title" class="list-post__image" />
}
</div>
</ItemTemplate>
</ListBox>
属性和事件
TItem
是每个项目的类型。通过设置类型,ItemTemplate
拥有强类型的Context
ItemSource
指向TItem
类型的Collection
SelectedItem
用于设置初始的TItem
- 当项目被选中时,会触发
SelectionChanged
事件。
以上代码是 SubredditWidget
组件的一部分,用于显示特定 subreddit 的标题和图像列表(如果存在)。
参考文献
- Kelly Adams 的 blazor-mvvm - Github 仓库
- Baksteen.Avalonia.Blazor - Github 仓库
- Microsoft 的 CommunityToolkit.Mvvm - Github 仓库 / CommunityToolkit.Mvvm - Nuget
- Microsoft Learn - MVVM Toolkit 简介
- 微软的 Xamarin 示例 - Github 仓库
摘要
我们有一个易于使用的 Blazor MVVM 库,名为 Blazing.MVVM,它支持所有功能,例如源生成器支持。我们还探讨了如何将现有的 Xamarin Community Toolkit 示例应用程序 转换为 Blazor WASM 应用程序,以及 WPF 和 Avalonia 混合应用程序。如果您已经在使用 Mvvm Community Toolkit,那么在 Blazor 中使用它简直是小菜一碟。如果您已经熟悉 MVVM,那么在您自己的项目中使用 Blazing.MVVM 应该会很直接。如果您正在使用 Blazor 而不是 MVVM,但想要使用它,您可以利用现有的文档、博客文章、Code Project 的快速解答、StackOverflow 支持等,从其他应用程序框架中学习并使用 Blazing.MVVM 库将其应用于 Blazor。
历史
- 2023 年 7 月 30 日 - v1.0 - 首次发布
- 2023 年 10 月 9 日 - v1.1 - 添加了
MvvmLayoutComponentBase
以支持MainLayout.razor
中的 MVVM,并更新了示例项目 - 2023 年 11 月 1 日 - v1.2 - 添加了 .NET 7.0+
Blazor Server App
支持;新增托管模型配置支持;.NET 8.0 RC2(Auto) Blazor WebApp
预发布; - 2023 年 11 月 21 日 - v1.4 - 更新到 .NET 8.0 + 支持自动模式的 Blazor Web App 示例项目;更新了 入门 部分以支持 .NET 8.0 Blazor Web Apps