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

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

starIconstarIconstarIconstarIconstarIcon

5.00/5 (12投票s)

2023 年 7 月 30 日

CPOL

10分钟阅读

viewsIcon

33241

通过使用 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 风格的导航,并将其转换为一个可用的库。

入门

  1. Blazing.Mvvm Nuget 包 添加到您的项目。

  2. 在您的 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;
      });  
  3. 创建一个继承 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();
    }
  4. 在您的 Program.cs 文件中注册 ViewModel
    builder.Services.AddTransient<FetchDataViewModel>();
  5. 创建您的页面,继承 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>
    }
  6. (可选)修改 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 的工作原理

分为两个部分

  1. ViewModelBase
  2. 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();
}

这是包装 ObservableObjectViewModelBase

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 组件基于 Blazor Navlink 组件,并具有额外的 TViewModelRelativeUri 属性。在内部,它使用 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 则较为精简。为了保持此项目的精简,我包含了自己编写的 ListBoxTab 控件 - 尽情享用!待有时间,我将努力完成并发布一个 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 状态类来保存对 NavigationManagerMvvvmNavigationManager 类的引用。

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 类定义和管理命令导航。当命令执行时,会进行快速查找并执行相关的操作——不需要 switchif ... 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 的 TabControlListBox 组件(控件)。于是我自行开发了它们。这些组件可以在解决方案中的各自项目中找到,并可在您自己的项目中使用。有一个支持库用于通用代码。这两个组件都支持键盘导航。

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 的标题和图像列表(如果存在)。

参考文献

摘要

我们有一个易于使用的 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
Blazing.Mvvm - 使用 Mvvm Community Toolkit 的 Blazor Server、WebAssembly 和 Hybrid - CodeProject - 代码之家
© . All rights reserved.