大家好,我是沙漠盡頭的狼。
.NET 是免費、跨平台、開源,用於建置所有應用程式的開發人員平台。
本文演示如何在 WPF 中使用 Blazor 開發漂亮的 UI,為客戶端開發注入新活力。
註 要使 WPF 支援 Blazor,.NET 版本必須是 6.0 或更高版本,本文所有範例使用的 .NET 7.0,版本要求見連結,截圖看如下文字:

1. WPF 預設程式
本文從建立 WPF Hello World 開發:
使用 WPF 範本建立一個預設程式,取名【WPFBlazorChat】,專案組織結構如下:

執行專案,一個空白視窗:

接著往下看,我們新增 Blazor 支援,本小節程式碼在這WPF預設程式原始碼。
2. 新增 Blazor 支援
依然使用上面的工程,新增 Blazor 支援,此部分參考微軟文件建立 Windows Presentation Foundation (WPF) Blazor 應用程式,本小節快速略過。
2.1 編輯專案檔案
雙擊專案檔案 WPFBlazorChat.csproj,修改處如下:

- 在專案檔案的頂部,將 SDK 更改為
Microsoft.NET.Sdk.Razor。 - 新增節點
<RootNameSpace>WPFBlazorChat</RootNameSpace>,將專案命名空間WPFBlazorChat設定為應用程式的根命名空間。 - 新增
NuGet套件Microsoft.AspNetCore.Components.WebView.Wpf,版本看你選擇的.NET版本而定。
2.2 新增 _Imports.razor 檔案
_Imports.razor 檔案類似一個 Global using 檔案,專門給 Razor 元件使用,放置一些用得比較多的全域命名空間,精簡程式碼。
內容如下,引入了一個命名空間 Microsoft.AspNetCore.Components.Web,這是 Razor 常用命名空間,包含用於向 Blazor 框架提供有關瀏覽器事件的資訊的類型。
@using Microsoft.AspNetCore.Components.Web
2.3 新增 wwwroot\index.html 檔案
和 Vue、React 一樣,需要一個 html 檔案承載 Razor 元件,頁面內容類似:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WPFBlazorChat</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="WpfBlazor.styles.css" rel="stylesheet" />
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>
app.css檔案在下面給出定義。- 看
<div id="app">Loading...</div>,這裡是承載Razor元件的地方,後面所有載入的Razor元件都是在這裡渲染出來的。 - 其他暫時不管。
2.4 新增 wwwroot\css\app.css 檔案
頁面的基本樣式,通用的樣式可放在這個檔案:
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
}
h1:focus {
outline: none;
}
a, .btn-link {
color: #0071c1;
}
.btn-primary {
color: #fff;
background-color: #1b6ec2;
border-color: #1861ac;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
2.5 新增一個 Razor 元件
加一個 Razor 的經典元件 Counter.razor,Blazor 的 Hello World 程式就有這麼一個元件,檔案路徑:/RazorViews/Counter.razor,之所以放 RazorViews 目錄,是為了和 WPF 常用的 Views 目錄區分,該元件內容如下:
<h1>Counter</h1>
<p>好開心,你點我了,現在是:<span style="color: red;">@currentCount</span></p>
<button class="btn btn-primary" @onclick="IncrementCount">快點我</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
}
一個按鈕【快點我】,點擊 @onclick="IncrementCount" 使變數 currentCount 自增,同時頁面顯示此變數值,相信你能看懂。
2.6 Blazor 與 WPF 表單關聯
這是兩者產生關係的關鍵一步,打開表單 MainWindow.xaml,修改如下:

如上程式碼,要點如下:
- 添加上面引入的
NuGet套件Microsoft.AspNetCore.Components.WebView.Wpf的命名空間,命名為blazor,主要是要使用BlazorWebView元件; BlazorWebView元件屬性HostPage指定承載的 html 檔案,Services指定 razor 元件的Ioc容器,看下面MainWindow()裡標紅的程式碼;RootComponent的Selector="#app"屬性指示Razor元件渲染的位置,看index.html中 id 為app的 html 元素,ComponentType指示需要在#app中渲染的Razor元件類型。
打開 MainWindow.xaml.cs,修改如下:

在 WPF 裡可以使用 Prism 等框架提供的 Unity、DryIoc 等 Ioc 容器實現檢視與服務的注入;Razor 元件這裡,預設使用 ASP.NET Core 的 IServiceCollection 容器;如果 WPF 表單與 Razor 元件需要共用資料,可以透過後面要說的 Messager 發送訊息,也可以透過 Ioc 容器注入的方式實現,比如從 WPF 表單中注入的資料(透過 MainWindow 建構函式注入),透過 IServiceCollection 容器再注入 Razor 元件使用,這裡後面也有提到。

上面步驟做完後,執行程式:

OK,WPF 與 Blazor 整合成功,打完收工?
等等,還沒完呢,本小節原始碼在這WPF中新增Blazor,接著往下看。
3. 自訂表單

看上圖,表單邊框是 WPF 預設的樣式,有時會感覺比較醜,或者不醜,設計師有其他的表單風格設計,往往我們要自訂表單,本節分享部分 WPF 與 Blazor 的自訂表單實現,更多客製化功能可能需要您自行研究。
3.1 WPF 自訂表單
一般實現是設定表單的三個屬性 WindowStyle="None" AllowsTransparency="True" Background="Transparent",即可隱藏預設表單的邊框,然後在內容區自己畫標題列、最小化、最大化、關閉按鈕、客戶區等。
MainWindow.xaml:隱藏WPF預設表單邊框
<Window
x:Class="WPFBlazorChat.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
Title="MainWindow"
Width="800"
Height="450"
AllowsTransparency="True"
Background="Transparent"
WindowStyle="None"
mc:Ignorable="d">
<Grid>
<blazor:BlazorWebView HostPage="wwwroot\index.html" Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent ComponentType="{x:Type razorViews:Counter}" Selector="#app" />
</blazor:BlazorWebView.RootComponents>
</Grid>
</Window>
上面的程式碼只是隱藏了 WPF 預設表單的邊框,執行程式如下:

看上圖,點擊表單中的按鈕(其實是 Razor 元件的按鈕),但未執行按鈕點擊事件,且表單消失了,這是怎麼回事?您可以嘗試研究下為什麼,我沒有研究個所以然來,暫時加個背景處理 BlazorWebView 穿透的問題。
簡單的WPF自訂表單樣式
我們加上自訂表單的基本樣式看看:

MainWindow.xaml 程式碼如下:
<Window
x:Class="WPFBlazorChat.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
Title="MainWindow"
Width="800"
Height="450"
AllowsTransparency="True" Background="Transparent" WindowStyle="None"
mc:Ignorable="d">
<Window.Resources>
<Style TargetType="{x:Type Button}">
<Setter Property="Width" Value="35" />
<Setter Property="Height" Value="25" />
<Setter Property="Margin" Value="2" />
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderThickness" Value="0" />
<Setter Property="Foreground" Value="White" />
</Style>
</Window.Resources>
<Border Background="#7160E8" CornerRadius="5">
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="35" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Border
Background="#7160E8" CornerRadius="5 5 0 0" MouseLeftButtonDown="MoveWindow_MouseLeftButtonDown">
<Grid>
<TextBlock
Margin="10,10,5,5"
Foreground="White"
Text="這裡是表單標題列,左側可放Logo、標題,右側放表單操作按鈕:最小化、最大化、關閉等" />
<StackPanel HorizontalAlignment="Right" Orientation="Horizontal">
<Button Click="MinimizeWindow_Click" Content="―" />
<Button Click="MaximizeWindow_Click" Content="口" />
<Button Click="CloseWindow_Click" Content="X" />
</StackPanel>
</Grid>
</Border>
<blazor:BlazorWebView Grid.Row="1" HostPage="wwwroot\index.html" Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent ComponentType="{x:Type razorViews:Counter}" Selector="#app" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</Grid>
</Border>
</Window>
我們給整個表單客戶端區域加了一個背景 Border(您可以去掉 Border 背景色,點擊介面按鈕試試),然後又套了一個 Grid,用於放置自訂的標題列(標題和表單控制按鈕)和 BlazorWebView(用於渲染 Razor 元件的瀏覽器元件),下面是表單控制按鈕的響應事件:
using Microsoft.Extensions.DependencyInjection;
using System.Windows;
namespace WPFBlazorChat;
public partial class MainWindow : Window
{
public MainWindow()
{
InitializeComponent();
var serviceCollection = new ServiceCollection();
serviceCollection.AddWpfBlazorWebView();
Resources.Add("services", serviceCollection.BuildServiceProvider());
}
private void MoveWindow_MouseLeftButtonDown(object sender, System.Windows.Input.MouseButtonEventArgs e)
{
if (e.ClickCount == 1)
{
this.DragMove();
}
else
{
MaximizeWindow_Click(null, null);
}
}
private void CloseWindow_Click(object sender, RoutedEventArgs e)
{
this.Close();
}
private void MinimizeWindow_Click(object sender, RoutedEventArgs e)
{
this.WindowState = WindowState.Minimized;
}
private void MaximizeWindow_Click(object sender, RoutedEventArgs e)
{
this.WindowState = this.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
}
}
程式碼簡單,處理了表單最小化、表單最大化(還原)、關閉、標題列雙擊表單最大化(還原),上面的實現不是一個完美的自訂表單實現,至少有這兩個問題:
- 當您嘗試最大化後,表單鋪滿了整個作業系統桌面(連工作列區域也佔用了);
- 表單工作列兩個圓角未生效(紅色矩形框選的部分),即表單下面的兩個圓角,站長未找到讓
BlazorWebView出現圓角的屬性或其他方法;標題列區域(綠色矩形框選的部分)是 WPF 控制項,所以圓角顯示正常。

在後面的 3.4 小節,站長使用一個第三庫實現了表單圓角問題,更多比較好的 WPF 自訂表單實現可看這篇文章:WPF三種自訂視窗的實現,本小節中範例原始碼在這WPF自訂表單。
3.2 WPF 異形表單
異形表單的需求,使用 WPF 實現是比較方便的,本來打算寫寫的,感覺偏離主題太遠了,給篇文章自行看看吧:WPF異形視窗演示,文中異形表單效果如下:

下面介紹將表單的標題列也放 Razor 元件中實現的方式。
3.3 Blazor 實現自訂表單效果
上面使用了 WPF 製作自訂表單,有沒有這種需求,把選單放置到標題列?這個簡單,WPF 能很好實現。
如果放 Tab 類控制項呢?Tab Header 是在標題列顯示,TabItem 是在客戶端區域,Tab Header 與 TabItem 風格統一,在一套程式碼裡面實現和維護也方便,那麼在 WPF+Blazor 混合開發的情況怎麼實現呢?相信透過本節 Razor 元件實現標題列的介紹,你能做出來。
MainWindow.xaml 恢復程式碼,只設定隱藏 WPF 預設表單邊框,並給 BlazorWebView 套一層背景:

後面的程式碼有參考 BlazorDesktopWPF-CustomTitleBar 開源專案實現。
我們把標題列做到 Counter.razor 元件,即標題列、客戶區放一個元件裡,當然你也可以分離,這裡我們方便演示:
Counter.razor
@using WPFBlazorChat.Services
<div class="titlebar" @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
<button class="titlebar-btn" onclick="alert('js alert: navigation pressed');">
<img src="svg/navigation.svg" />
</button>
<div class="window-title">
測試表單標題
</div>
<div style="flex-grow:1"></div>
<button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
<img src="svg/settings.svg" />
</button>
<button class="titlebar-btn" @onclick="WindowService.Minimize">
<img src="svg/minimize.svg" />
</button>
<button class="titlebar-btn" @onclick="WindowService.Maximize">
@if (WindowService.IsMaximized())
{
<img src="svg/restore.svg" />
}
else
{
<img src="svg/maximize.svg" />
}
</button>
<button class="titlebar-cbtn" @onclick="()=>WindowService.Close(false)">
<img src="svg/dismiss.svg" />
</button>
</div>
<p>好開心,你點我了,現在是:<span style="color: red;">@currentCount</span></p>
<button class="btn btn-primary" @onclick="IncrementCount">快點我</button>
@code {
private int currentCount = 0;
protected override void OnInitialized()
{
WindowService.Init();
base.OnInitialized();
}
private void IncrementCount()
{
currentCount++;
}
}
下面給出程式碼簡單說明:
- 第一個
div充做表單的標題列區域,註冊了雙擊事件呼叫表單最大化(還原)方法、滑鼠按下與釋放呼叫表單的移動開始與結束方法; - 在第一個
div裡,其中有 3 個按鈕,即表單的控制按鈕,呼叫表單最小化、最大化(還原)、關閉方法呼叫; - 另有兩個按鈕,演示單擊呼叫
JavaScript的alert方法彈出訊息。

執行效果如下:

實現這個效果,還有一些程式碼:
- 上面的程式碼呼叫了一些方法實現表單操作最小化、關閉等,程式碼如下;
- 因為是
Razor元件,即html實現的介面,介面的html元素也定義了一些css樣式,程式碼也一併給出。 - 標題列的按鈕使用了一些
svg圖片,在倉庫裡,可自行獲取。
表單拖動
首先新增 NuGet 套件 Simplify.Windows.Forms,用於獲取滑鼠游標的位置:
<PackageReference Include="Simplify.Windows.Forms" Version="1.1.2" />
新增表單幫助類:Services\WindowService.cs
using System;
using System.Linq;
using System.Windows;
using System.Windows.Forms;
using System.Windows.Threading;
using Application = System.Windows.Application;
namespace WPFBlazorChat.Services;
public class WindowService
{
private static bool _isMoving;
private static double _startMouseX;
private static double _startMouseY;
private static double _startWindLeft;
private static double _startWindTop;
public static void Init()
{
DispatcherTimer dispatcherTimer = new();
dispatcherTimer.Tick += UpdateWindowPos;
dispatcherTimer.Interval = TimeSpan.FromMilliseconds(17);
dispatcherTimer.Start();
}
public static void StartMove()
{
_isMoving = true;
_startMouseX = GetX();
_startMouseY = GetY();
var window = GetActiveWindow();
if (window == null)
{
return;
}
_startWindLeft = window.Left;
_startWindTop = window.Top;
}
public static void StopMove()
{
_isMoving = false;
}
public static void Minimize()
{
var window = GetActiveWindow();
if (window != null)
{
window.WindowState = WindowState.Minimized;
}
}
public static void Maximize()
{
var window = GetActiveWindow();
if (window != null)
{
window.WindowState =
window.WindowState == WindowState.Maximized ? WindowState.Normal : WindowState.Maximized;
}
}
public static bool IsMaximized()
{
var window = GetActiveWindow();
if (window != null)
{
return window.WindowState == WindowState.Maximized;
}
return false;
}
public static void Close(bool allWindow = false)
{
if (allWindow)
{
Application.Current?.Shutdown();
return;
}
var window = GetActiveWindow();
if (window != null)
{
window.Close();
}
}
private static void UpdateWindowPos(object? sender, EventArgs e)
{
if (!_isMoving)
{
return;
}
double moveX = GetX() - _startMouseX;
double moveY = GetY() - _startMouseY;
Window? window = GetActiveWindow();
if (window == null)
{
return;
}
window.Left = _startWindLeft + moveX;
window.Top = _startWindTop + moveY;
}
private static int GetX()
{
return Control.MousePosition.X;
}
private static int GetY()
{
return Control.MousePosition.Y;
}
private static Window? GetActiveWindow()
{
return Application.Current.Windows.Cast<Window>().FirstOrDefault(currentWindow => currentWindow.IsActive);
}
}
上面的程式碼用於表單的最小化、最大化(還原)、關閉等實現,需要在 Razor 元件裡正確的呼叫這些方法:
Counter.razor元件的OnInitialized初始化生命週期方法裡呼叫WindowService.Init();,如上程式碼,這個方法開啟定時器,定時呼叫UpdateWindowPos方法檢查滑鼠是否按下,如果按下,檢查間隔內表單的位置變化範圍,然後修改表單位置,從而實現表單位置移動(移動表單無法使用 WPF 的DragMove方法,您可以嘗試使用看看它報什麼錯),移動表單有更好的方法歡迎留言。Razor元件裡表單控制按鈕的使用看上面的程式碼不難理解,不過多解釋。
上面效果的樣式檔案修改如下,wwwroot\css\app.css:
/*
BlazorDesktopWPF-CustomTitleBar - © Copyright 2021 - Jam-Es.com
Licensed under the MIT License (MIT). See LICENSE in the repo root for license information.
*/
html, body {
font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif;
padding: 0;
margin: 0;
}
.valid.modified:not([type=checkbox]) {
outline: 1px solid #26b050;
}
.invalid {
outline: 1px solid red;
}
.validation-message {
color: red;
}
#blazor-error-ui {
background: lightyellow;
bottom: 0;
box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2);
display: none;
left: 0;
padding: 0.6rem 1.25rem 0.7rem 1.25rem;
position: fixed;
width: 100%;
z-index: 1000;
}
#blazor-error-ui .dismiss {
cursor: pointer;
position: absolute;
right: 0.75rem;
top: 0.5rem;
}
.page-container {
display: flex;
flex-direction: column;
height: 100vh;
}
.content-container {
padding: 0px 20px 20px 20px;
flex-grow: 1;
overflow-y: scroll;
}
.titlebar {
width: 100%;
height: 32px;
min-height: 32px;
background-color: #7160E8;
display: flex;
flex-direction: row;
}
.titlebar-btn, .titlebar-cbtn {
width: 46px;
background-color: #7160E8;
color: white;
border: none;
border-radius: 0;
}
.titlebar-btn:hover {
background-color: #5A5A5A;
}
.titlebar-btn:focus, .titlebar-cbtn:focus {
outline: 0;
}
.titlebar-cbtn:hover {
background-color: #E81123;
}
.window-title {
display: flex;
flex-direction: column;
justify-content: center;
margin-left: 5px;
color: white;
}
上面的一些程式碼即實現了由 Razor 元件實現表單的標題顯示、表單的最小化、最大化(還原)、關閉、移動等操作,然而還是會有 3.1 結尾出現的問題,即表單圓角和表單最大化鋪滿作業系統桌面工作列的問題,下面一小節我們嘗試解決他。
小節總結:透過上面的程式碼,如果放 Tab 控制項鋪滿整個表單,是不是有思路了?
本小節原始碼在這Razor元件實現表單標題列功能
3.4 Blazor 與 WPF 比較完美的實現效果
其實上面的程式碼可以當作學習,即使有不小瑕疵(哈哈),本小節我們還是使用第三套件解決表單圓角和最大化問題。
首先新增 NuGet 套件 ModernWpfUI,該 WPF 控制項庫本站介紹連結開源WPF控制項庫:ModernWpf:
<PackageReference Include="ModernWpfUI" Version="0.9.7-preview.2" />
然後打開 App.xaml,引用上面開源 WPF 控制項的樣式:
<Application x:Class="WPFBlazorChat.App"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="http://schemas.modernwpf.com/2019"
StartupUri="MainWindow.xaml">
<Application.Resources>
<ResourceDictionary>
<ResourceDictionary.MergedDictionaries>
<ui:ThemeResources />
<ui:XamlControlsResources />
</ResourceDictionary.MergedDictionaries>
</ResourceDictionary>
</Application.Resources>
</Application>
最後打開 MainWindow.xaml,修改如下(主要是引入的幾個屬性 ui:xxxxx):
<Window x:Class="WPFBlazorChat.MainWindow"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:ui="http://schemas.modernwpf.com/2019"
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Wpf;assembly=Microsoft.AspNetCore.Components.WebView.Wpf"
xmlns:razorViews="clr-namespace:WPFBlazorChat.RazorViews"
mc:Ignorable="d"
Title="MainWindow" Height="450" Width="800"
ui:TitleBar.ExtendViewIntoTitleBar="True"
ui:TitleBar.IsBackButtonVisible="False"
ui:TitleBar.Style="{DynamicResource AppTitleBarStyle}"
ui:WindowHelper.UseModernWindowStyle="True">
<Border Background="#7160E8" CornerRadius="5">
<blazor:BlazorWebView HostPage="wwwroot\index.html" Services="{DynamicResource services}">
<blazor:BlazorWebView.RootComponents>
<blazor:RootComponent Selector="#app" ComponentType="{x:Type razorViews:Counter}" />
</blazor:BlazorWebView.RootComponents>
</blazor:BlazorWebView>
</Border>
</Window>
就上面三處修改,我們執行看看:

是不是和 3.3 效果一樣?其實仔細看,表單下面的圓角也有了:

最終還是 WPF 解決了所有問題...

具體怎麼實現的表單最大化未佔操作系統的工作列,以及表單圓角問題的解決(竟然能讓 BlazorWebView 部分透明了)可以查看該元件相關程式碼,本文不過多深究。
另外,WPF 熟手可能比較清楚,前面的程式碼還不能正常的拖動改變表單大小(不知道你有沒有發現,我當你沒發現。),使用該庫後也解決了:

本小節原始碼在這解決圓角和最大化問題,下面開始本文的下半部分了,好累,終於到這了。

4. 新增第三方 Blazor 元件
工欲善其事,必先利其器!
鑑於大部分同學前端基礎可能不是太好,即使使用 Blazor 可以少用或者不用 JavaScript,但有那麼一款漂亮、便捷的 Blazor 元件庫,這不是如虎添翼嗎?本文使用 Masa Blazor 做範例展示,如今 Blazor 元件庫眾多,選擇自己喜歡的、順手的就成:

站長前些日子介紹過 MAUI 使用 Masa blazor 元件庫一文,本小節思路也是類似,且看我表演。

打開 Masa Blazor 文件站點:https://blazor.masastack.com/getting-started/installation,一起來往 WPF 中引入這款 Blazor 元件庫吧。
4.1 引入 Masa.Blazor 套件
打開專案檔案 WPFBlazorChat.csproj 直接複製下面的套件版本,或透過 NuGet 套件管理員搜尋 Masa.Blazor 安裝:
<PackageReference Include="Masa.Blazor" Version="0.6.0" />
4.2 新增 Masa.Blazor 帶來的資源
打開 wwwroot\index.html,在 <head></head> 節點新增如下資源:
<link href="_content/Masa.Blazor/css/masa-blazor.min.css" rel="stylesheet" />
<link href="https://cdn.masastack.com/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://cdn.masastack.com/npm/materialicons/materialicons.css" rel="stylesheet">
<link href="https://cdn.masastack.com/npm/fontawesome/v5.0.13/css/all.css" rel="stylesheet">
<script src="_content/BlazorComponent/js/blazor-component.js"></script>
完整程式碼如下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>WPFBlazorChat</title>
<base href="/" />
<link href="css/app.css" rel="stylesheet" />
<link href="WpfBlazor.styles.css" rel="stylesheet" />
<link href="_content/Masa.Blazor/css/masa-blazor.min.css" rel="stylesheet" />
<link href="https://cdn.masastack.com/npm/@mdi/font@5.x/css/materialdesignicons.min.css" rel="stylesheet">
<link href="https://cdn.masastack.com/npm/materialicons/materialicons.css" rel="stylesheet">
<link href="https://cdn.masastack.com/npm/fontawesome/v5.0.13/css/all.css" rel="stylesheet">
<script src="_content/BlazorComponent/js/blazor-component.js"></script>
</head>
<body>
<div id="app">Loading...</div>
<div id="blazor-error-ui">
An unhandled error has occurred.
<a href="" class="reload">Reload</a>
<a class="dismiss">🗙</a>
</div>
<script src="_framework/blazor.webview.js"></script>
</body>
</html>
4.3 引入 Masa.Blazor 命名空間
打開 _Imports.razor 檔案,修改如下:
@using Microsoft.AspNetCore.Components.Web
@using Masa.Blazor
@using BlazorComponent
4.4 Razor 元件新增 Masa.Blazor
打開 MainWindow.xaml.cs,新增一行程式碼 serviceCollection.AddMasaBlazor();

4.5 嘗試 Masa.Blazor 案例
上面4步的準備工作做好後,我們簡單來使用下 Masa.Blazor 元件。
打開 Tab 元件連結:https://blazor.masastack.com/components/tabs,嘗試這個 Demo:

Demo 的程式碼我幾乎不變的引入,打開 RazorViews\Counter.razor 檔案,保留 3.4 節的標題列,替換了客戶區域內容,程式碼如下:
@using WPFBlazorChat.Services
<MApp>
<!--上一小節的標題列開始-->
<div class="titlebar" @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
<button class="titlebar-btn" onclick="alert('js alert: navigation pressed');">
<img src="svg/navigation.svg"/>
</button>
<div class="window-title">
測試表單標題
</div>
<div style="flex-grow: 1"></div>
<button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
<img src="svg/settings.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Minimize">
<img src="svg/minimize.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Maximize">
@if (WindowService.IsMaximized())
{
<img src="svg/restore.svg"/>
}
else
{
<img src="svg/maximize.svg"/>
}
</button>
<button class="titlebar-cbtn" @onclick="() => WindowService.Close(false)">
<img src="svg/dismiss.svg"/>
</button>
</div>
<!--上一小節的標題列結束-->
<!--新增的Masa.Blazor Tab案例程式碼開始-->
<MCard>
<MToolbar Color="cyan" Dark Flat>
<ChildContent>
<MAppBarNavIcon></MAppBarNavIcon>
<MToolbarTitle>Your Dashboard</MToolbarTitle>
<MSpacer></MSpacer>
<MButton Icon>
<MIcon>mdi-magnify</MIcon>
</MButton>
<MButton Icon>
<MIcon>mdi-dots-vertical</MIcon>
</MButton>
</ChildContent>
<ExtensionContent>
<MTabs @bind-Value="tab"
AlignWithTitle
SliderColor="yellow">
@foreach (var item in items)
{
<MTab Value="item">
@item
</MTab>
}
</MTabs>
</ExtensionContent>
</MToolbar>
<MTabsItems @bind-Value="tab">
@foreach (var item in items)
{
<MTabItem Value="item">
<MCard Flat>
<MCardText>@text</MCardText>
</MCard>
</MTabItem>
}
</MTabsItems>
</MCard>
<!--新增的Masa.Blazor Tab案例程式碼結束-->
</MApp>
@code {
#region Masa.Blazor Tab案例C#程式碼
StringNumber tab;
List<string> items = new()
{
"web", "shopping", "videos", "images", "news",
};
string text = "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.";
#endregion
protected override void OnInitialized()
{
WindowService.Init();
base.OnInitialized();
}
}
執行效果如下:

是不是有那味兒了?再嘗試把 Tab 移到標題列,前面有提過的效果:

上面的效果,程式碼修改如下,刪除了原標題列程式碼,將表單操作按鈕放到了 MToolbar 裡面,並使用 MToolbar 新增了雙擊事件、滑鼠按下、釋放事件實現表單拖動:
<MApp>
<!--新增的Masa.Blazor Tab案例程式碼開始-->
<MCard>
<MToolbar Color="cyan" Dark Flat @ondblclick="WindowService.Maximize" @onmouseup="WindowService.StopMove" @onmousedown="WindowService.StartMove">
<MTabs @bind-Value="tab"
AlignWithTitle
SliderColor="yellow">
@foreach (var item in items)
{
<MTab Value="item">
@item
</MTab>
}
</MTabs>
<div style="flex-grow: 1"></div>
<button class="titlebar-btn" onclick="alert('js alert: settings pressed');">
<img src="svg/settings.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Minimize">
<img src="svg/minimize.svg"/>
</button>
<button class="titlebar-btn" @onclick="WindowService.Maximize">
@if (WindowService.IsMaximized())
{
<img src="svg/restore.svg"/>
}
else
{
<img src="svg/maximize.svg"/>
}
</button>
<button class="titlebar-cbtn" @onclick="() => WindowService.Close(false)">
<img src="svg/dismiss.svg"/>
</button>
</MToolbar>
<MTabsItems @bind-Value="tab">
@foreach (var item in items)
{
<MTabItem Value="item">
<MCard Flat>
<MCardText>@text</MCardText>
</MCard>
</MTabItem>
}
</MTabsItems>
</MCard>
<!--新增的Masa.Blazor Tab案例程式碼結束-->
</MApp>
表單操作按鈕的背景色也做部分修改:

其實上面的表單效果還是有點瑕疵,注意到表單右側的豎直捲動條了嗎?在沒引入 Masa.Blazor 之前,右側正常顯示,引入後多了一個豎直捲動條:

這個想去掉也簡單,在 wwwroot\css\app.css 追加樣式(當時也是折騰了好一會兒,最後在 Masa.Blazor 群裡群友給出了解決方案,十分感謝):

問題解決 css 程式碼:
::-webkit-scrollbar {
width: 0px;
}
因為 Razor 元件是在 BlazorWebView 裡渲染的,即 BlazorWebView 就是個小型的瀏覽器呀,上面的樣式即把瀏覽器的捲動條寬度設定為 0,它不就沒有了嗎?現在效果如下,是不是舒服了?

新增 Masa.Blazor 就介紹到這裡,本小節範例程式碼在這裡WPF中使用Masa.Blazor,下面講解 WPF 與 Blazor 混合開發後多表單訊息通知問題。
5. 多表單訊息通知
一般 C/S 表單之間通訊使用委託、事件,而在 WPF 開發中,可以使用一些框架提供的抽象事件 訂閱\發佈 元件,比如 Prism 的事件聚集器 IEventAggregator,或 MvvmLight 的 Messager。在 B/S 開發中,行程內事件通知可能就使用 MediatR 元件居多了,不論是在 C/S 還是 B/S 開發,這些元件在一定程度上,各大程式範本可以通用的,更不用說分散式的訊息佇列 RabbitMQ 和 Kafka 是萬能的行程間通訊標準選擇了。
上面是一些套話,站長根據 Prism 的事件聚集器和 MvvmLight 的 Messager 原始碼閱讀,簡單封裝了一個 Messager,可以適用於一般的業務需求。
5.1 Messager 封裝
本來不想貼程式碼直接給原始碼連結的,想想程式碼也不多,直接上吧。
Message
訊息抽象類別,用於定義訊息類型,具體的訊息需要繼承該類別,比如後面的打開子表單訊息 OpenSecondViewMessage。
using System;
namespace WPFBlazorChat.Messages;
public abstract class Message
{
protected Message(object sender)
{
this.Sender = sender ?? throw new ArgumentNullException(nameof(sender));
}
public object Sender { get; }
}
IMessenger
訊息介面,只定義了三個介面:
- Subscribe:訊息訂閱
- Unsubscribe:取消訊息訂閱
- Publish:訊息發送
using System;
namespace WPFBlazorChat.Messages;
public interface IMessenger
{
void Subscribe<TMessage>(object recipient, Action<TMessage> action,
ThreadOption threadOption = ThreadOption.PublisherThread) where TMessage : Message;
void Unsubscribe<TMessage>(object recipient, Action<TMessage>? action = null) where TMessage : Message;
void Publish<TMessage>(object sender, TMessage message) where TMessage : Message;
}
public enum ThreadOption
{
PublisherThread,
BackgroundThread,
UiThread
}
Messenger
訊息的管理,訊息中轉等實現:
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace WPFBlazorChat.Messages;
public class Messenger : IMessenger
{
public static readonly Messenger Default = new Messenger();
private readonly object registerLock = new object();
private Dictionary<Type, List<WeakActionAndToken>>? recipientsOfSubclassesAction;
public void Subscribe<TMessage>(object recipient, Action<TMessage> action, ThreadOption threadOption)
where TMessage : Message
{
lock (this.registerLock)
{
var messageType = typeof(TMessage);
this.recipientsOfSubclassesAction ??= new Dictionary<Type, List<WeakActionAndToken>>();
List<WeakActionAndToken> list;
if (!this.recipientsOfSubclassesAction.ContainsKey(messageType))
{
list = new List<WeakActionAndToken>();
this.recipientsOfSubclassesAction.Add(messageType, list);
}
else
{
list = this.recipientsOfSubclassesAction[messageType];
}
var item = new WeakActionAndToken
{ Recipient = recipient, ThreadOption = threadOption, Action = action };
list.Add(item);
}
}
public void Unsubscribe<TMessage>(object? recipient, Action<TMessage>? action) where TMessage : Message
{
var messageType = typeof(TMessage);
if (recipient == null || this.recipientsOfSubclassesAction == null ||
this.recipientsOfSubclassesAction.Count == 0 || !this.recipientsOfSubclassesAction.ContainsKey(messageType))
{
return;
}
var lstActions = this.recipientsOfSubclassesAction[messageType];
for (var i = lstActions.Count - 1; i >= 0; i--)
{
var item = lstActions[i];
var pastAction = item.Action;
if (pastAction != null
&& recipient == pastAction.Target
&& (action == null || action.Method.Name == pastAction.Method.Name))
{
lstActions.Remove(item);
}
}
}
public void Publish<TMessage>(object sender, TMessage message) where TMessage : Message
{
var messageType = typeof(TMessage);
if (this.recipientsOfSubclassesAction != null)
{
var listClone = this.recipientsOfSubclassesAction.Keys.Take(this.recipientsOfSubclassesAction.Count)
.ToList();
foreach (var type in listClone)
{
List<WeakActionAndToken>? list = null;
if (messageType == type || messageType.IsSubclassOf(type) || type.IsAssignableFrom(messageType))
{
list = this.recipientsOfSubclassesAction[type]
.Take(this.recipientsOfSubclassesAction[type].Count)
.ToList();
}
if (list is { Count: > 0 })
{
this.SendToList(message, list);
}
}
}
}
private void SendToList<TMessage>(TMessage message, IEnumerable<WeakActionAndToken> weakActionsAndTokens)
where TMessage : Message
{
var list = weakActionsAndTokens.ToList();
var listClone = list.Take(list.Count()).ToList();
foreach (var item in listClone)
{
if (item.Action is { Target: { } })
{
switch (item.ThreadOption)
{
case ThreadOption.BackgroundThread:
Task.Run(() => { item.ExecuteWithObject(message); });
break;
case ThreadOption.UiThread:
SynchronizationContext.Current!.Post(_ => { item.ExecuteWithObject(message); }, null);
break;
default:
item.ExecuteWithObject(message);
break;
}
}
}
}
}
public class WeakActionAndToken
{
public object? Recipient { get; set; }
public ThreadOption ThreadOption { get; set; }
public Delegate? Action { get; set; }
public string? Tag { get; set; }
public void ExecuteWithObject<TMessage>(TMessage message) where TMessage : Message
{
if (this.Action is Action<TMessage> factAction)
{
factAction.Invoke(message);
}
}
}
有興趣的看上面的程式碼,封裝程式碼上面簡單全部給上,後面的訊息通知都是基於上面的三個類別實現的,比較核心。
5.2 程式碼整理
第 5 節涉及到多表單及多 Razor 元件了,需要建立一些目錄存放這些檔案,方便分類管理。

A:放
Message,即一些訊息通知類;B:放
Razor元件,如果需要與Maui\Blazor Server(Wasm)等共用Razor元件,可以建立Razor 類別庫儲存;C:放通用服務,這裡只放了一個表單管理靜態類,實際情況可以放
Redis服務、RabbitMQ訊息服務等;D:放
WPF檢視,本範例 WPF 表單只是一個殼,承載BlazorWebView使用;
5.3 範例及程式碼說明
先看本範例效果,再給出相關程式碼說明:

圖中有三個操作:
- 點擊主表單A的 【+】 按鈕,發送了
OpenSecondViewMessage訊息,打開子表單 B; - 打開子表單 B 後,再點擊主表單 A 的 【桃心】 按鈕,發送了
SendRandomDataMessage訊息,子表單 B 的第二個TabItem Header顯示了訊息傳來的數字; - 點擊子表單 B 的 【安卓】 圖示按鈕,給主表單 A 響應了訊息
ReceivedResponseMessage,主表單收到後彈出一個對話框。
三個訊息類別定義如下:
public class OpenSecondViewMessage : Message
{
public OpenSecondViewMessage(object sender) : base(sender)
{
}
}
public class SendRandomDataMessage : Message
{
public SendRandomDataMessage(object sender, int number) : base(sender)
{
Number = number;
}
public int Number { get; set; }
}
public class ReceivedResponseMessage : Message
{
public ReceivedResponseMessage(object sender) : base(sender)
{
}
}
除了 SendRandomDataMessage 傳遞了一個業務 Number 屬性,另兩個訊息只是起到通知作用(所以沒有額外屬性定義),實際開發時可能需要傳遞業務資料。
5.3.1 打開多表單
即上面的第一個操作:點擊主表單 A 的 【+】 按鈕,發送了 OpenSecondViewMessage 訊息,打開子表單 B。
在 RazorViews\MainView.razor 中執行按鈕點擊,發送打開子表單訊息:
...
<MCol>
<MButton class="mx-2" Fab Dark Color="indigo" OnClick="OpenNewSecondView">
<MIcon>mdi-plus</MIcon>
</MButton>
</MCol>
...
@code{
...
void OpenNewSecondView()
{
Messenger.Default.Publish(this, new OpenSecondViewMessage(this));
}
...
}
在 App.xaml.cs 裡訂閱打開子表單訊息:
public partial class App : Application
{
public App()
{
// 訂閱打開子視窗訊息,在主視窗點擊【+】按鈕
Messenger.Default.Subscribe<OpenSecondViewMessage>(this, msg =>
{
var chatWin = new SecondWindowView();
chatWin.Show();
}, ThreadOption.UiThread);
}
}
實際開發可能情況更複雜,發送的訊息 OpenSecondViewMessage 裡帶 WPF 表單路由(定義的一套路徑規則尋找表單或 ViewModel),訂閱的地方也可能不在主程式,在子模組的 Module 類別裡。
5.3.2 發送業務資料
即第二個操作:打開子表單 B 後,再點擊主表單 A 的 【桃心】 按鈕,發送了 SendRandomDataMessage 訊息,子表單 B 的第二個 TabItem Header 顯示了訊息傳來的數字。
- 在
RazorViews\MainView.razor中執行按鈕點擊,發送業務訊息(就當前時間的Millisecond):
...
<MCol>
<MButton class="mx-2" Fab Small Dark Color="pink" OnClick="SendNumber">
<MIcon>mdi-heart</MIcon>
</MButton>
</MCol>
...
@code{
...
void SendNumber()
{
Messenger.Default.Publish(this, new SendRandomDataMessage(this, DateTime.Now.Millisecond));
}
...
}
- 在
RazorViews\SecondView.razor的OnInitialized()方法裡訂閱業務訊息通知:
@using WPFBlazorChat.Messages
<MApp>
<MToolbar>
<MTabs BackgroundColor="primary" Grow Dark>
<MTab>
<MBadge Color="pink" Dot>
Item One
</MBadge>
</MTab>
<MTab>
<MBadge Color="green" Content="tagCount">
Item Two
</MBadge>
</MTab>
<MTab>
<MBadge Color="deep-purple accent-4" Icon="mi-masa">
Item Three
</MBadge>
</MTab>
</MTabs>
</MToolbar>
<MRow>
<MButton class="mx-2" Fab Dark Large Color="purple" OnClick="ReponseMessage">
<MIcon>
mdi-android
</MIcon>
</MButton>
</MRow>
</MApp>
@code
{
private int tagCount = 6;
protected override void OnInitialized()
{
// 訂閱業務訊息,在主視窗點擊桃心按鈕時觸發
Messenger.Default.Subscribe<SendRandomDataMessage>(this, msg =>
{
this.InvokeAsync(() => { this.tagCount = msg.Number; });
this.StateHasChanged();
}, ThreadOption.UiThread);
}
void ReponseMessage()
{
// 通知主視窗,我已經收到訊息,請不要再發
Messenger.Default.Publish(this, new ReceivedResponseMessage(this));
}
}
注意看,上面收到訊息時有兩個方法要簡單說一下,看 OnInitialized() 裡的程式碼:
- InvokeAsync:將
Number賦值給變數tagCount的程式碼是在InvokeAsync方法裡執行的,這個和 WPF 裡的Dispatcher.Invoke是一個意思,相當於接收資料是在子執行緒,而賦值這個操作會即時的繫結到<MBadge Color="green" Content="tagCount">上,就需要 UI 執行緒同步。 - StateHasChanged:相當於 WPF MVVM 裡的
PropertyChanged事件通知,通知UI這裡有值變化了,請你重新整理一下,我要看看最新值。
上面的程式碼把子表單訊息回應也貼上了,即點擊安卓圖示按鈕時發送了 ReceivedResponseMessage 訊息,在主表單 RazorViews\MainView.razor 裡也訂閱了這個訊息,和上面的程式碼類似:
...
<!--確認對話框開始-->
<PConfirm Visible="_showComfirmDialog"
Title="子表單來回應了"
Type="AlertTypes.Warning"
OnCancel="() => _showComfirmDialog = false"
OnOk="() => _showComfirmDialog = false">
說你別沒事一直發,它們煩!
</PConfirm>
<!--確認對話框結束-->
</MApp>
@code{
...
// 是否顯示確認對話框
bool _showComfirmDialog;
protected override void OnInitialized()
{
WindowService.Init();
// 訂閱子表單回應的訊息,它已經收到訊息了,我可以休息下再發
Messenger.Default.Subscribe<ReceivedResponseMessage>(this, msg =>
{
this.InvokeAsync(() => { _showComfirmDialog = true; });
this.StateHasChanged();
}, ThreadOption.UiThread);
base.OnInitialized();
}
...
}
在 OnInitialized() 方法裡訂閱訊息 ReceivedResponseMessage,收到後將變數 _showComfirmDialog 置為 true,即上面對話框的屬性 Visible 繫結的值,同理需要在 InvokeAsync() 中處理資料接收,也需要呼叫 StateHasChanged 通知 UI 資料變化。
上面說了部分程式碼,可能講得不太清楚,可以看本節範例原始碼:多表單訊息通知。
6. 本文範例
本來想寫完整 Demo 說明的,發現上面把基本要點都拉了一遍,再貼一些重複程式碼有點沒完沒了了,有興趣的拉原始碼 WPF與Blazor混合開發Demo 查看、執行,下面是專案程式碼結構:

下面是最後的範例效果圖,前面部分文章已經發過,再發一次,哈哈:
使用者列表視窗

開啟子視窗

聊天視窗

示範發送訊息
7. Click Once 發佈嘗試
上一篇文章連結:快速建立軟體安裝包-ClickOnce,本文範例 Click Once 安裝頁面:https://dotnet9.com/WPFBlazorChat
8. Q&A
8.1 為啥要在 WPF 裡使用 Blazor?吃飽了撐的?
WPF 雖然相較 Winform 做出比較好看的 UI 相對容易一些,但比起 Blazor,或者直接說 html 開發介面,還是差了一點點,更何況 html 的資源更多一點,嘗試一下為何不可?
8.2 WPF + Blazor 支援哪些作業系統
最低支援 Windows 7 SP1 吧,有群友已經嘗試在 Windows 7 正常執行成功,這是本文範例 Click Once 安裝頁面:https://dotnet9.com/WPFBlazorChat
8.3 Blazor 混合開發還支援哪些既有框架?
Blazor 混合開發 的話,除了 WPF,還有 MAUI(跨平台框架,支援平台包括 Windows\Mac\Linux\Android\iOS 等)、Winform(同 WPF,只能在 Windows 平台 執行)等,建議閱讀 微軟文件 繼續學習,本文只是個引子:

8.4 Blazor 元件庫除了 Masa.Blazor 還有哪些?
開源的
Blazor元件有:Ant Design Blazor、Bootstrap Blazor、MudBlazor、Blazorise,以及微軟自家的 FAST Blazor 等,當然還有不少開源的Blazor元件。收費的
Blazor元件:DevExpress、Telerik、Syncfusion 等
8.5 本文範例程式碼?
文中各小節程式碼、最後的範例程式碼都給出了相應連結,您可返回查看。