我是大标题
我学习Blazor的顺序是基于Blazor University,然后实际内容不完全基于它,因为它的例子还是基于.NET Core 3.1做的,距离现在很遥远了。
截至本文撰写的时间,2025年,最新的.NET是.NET9了都,可能10也快了。我发现有些它上面说的例子其实现在都不一定能运行了,我结合Deep Seek和其他几家人工智能辅助的情况下,进行Blazor的学习,基于原来的教程,并补充了一些我好奇的部分,结合人工智能给我的教学,掌握blazor的核心知识。
我是冗长你不爱看的目录
组件与布局
首先什么是布局,就是页面的模板
创建自己的模板需要
- 使用razor语法,@inherits LayoutComponentBase
然后一般的会基于一个已有的布局来做,如果这样的话就需要再写一句
@layout [其他布局]
例如这样

基于MainLayout创建一个叫Admin的布局。
然后如何使用自定义的布局到具体的页面呢
- 大批量的,通过_Imports.razor,在指定文件夹下的所有布局都会按照_Imports.razor来处理,就是这个razor文件放在哪个文件夹下,哪个文件夹下的razor都会按照这个_Imports.razor来渲染
- 小量的,直接就写@layout [你的布局]
如图所示,这样写Admin下面的razor都会按_Import的布局来渲染了

组件如果需要交互的话,需要在最前面加@rendermode InteractiveServer
这里我默认都是用服务器模式的Blazor
单向绑定和双向绑定
单向绑定
单向绑定就是C#->前端的意思
只需要把需要绑定的对象写一个[Parameter]属性,然后修改为public属性,在前端区域就可以使用@符号进行访问了
比如这样

访问HTML元素的DOM事件
对于像是button这类存在DOM的HTML元素,可以通过@onxxxx来访问其DOM事件,之后就可以和C#进行联动了,或者直接通过lambda表达式。某些事件还具备属性,只需要通过@onxxxx:yyyy进行更细致的控制。
联动C#区域代码

原地直接lambda表达式

访问某些事件更细化的属性功能【譬如这里的stopPropagation】

这个的作用就是让事件停止冒泡传递了,就停在button这一级了。
单向绑定的总结[来自AI]
单向绑定,这样写外部初始化这个Razor组件的时候就必须要传入一个参数,然后传入的参数就会投射到前端区域
在Blazor中,组件如何解释我们传递的值取决于我们正在设置的[Parameter]的类型,以及我们是否通过使用@符号明确表达了我们的意图。
关于[Parameter]属性:
在Blazor组件中,当你想要从父组件接收数据时,你会在子组件的代码中定义一个带有[Parameter]特性的属性。这个特性告诉Blazor这是一个可以从外部传递进来的值。
关于值的传递和解释:
当你向组件传递一个简单类型的值(如整数、字符串、布尔值)时,Blazor会根据属性的类型自动进行值的转换和绑定。例如,如果你传递了一个字符串给一个类型为string的[Parameter]属性,Blazor会直接将这个字符串值赋给该属性。
对于复杂类型的值(如对象、委托Func、事件处理程序等),Blazor同样会根据属性的类型进行绑定,但这里可能需要更多的考虑,比如对象的生命周期管理、委托的调用上下文等。
关于@符号的使用:
在Blazor的Razor语法中,@符号通常用于标记代码表达式。当你在HTML标记中嵌入C#代码时,你需要使用@符号来告诉Razor引擎这是一个代码块或表达式。
但是,在传递参数给组件时,@符号的使用并不是直接相关的。更常见的是,你会在父组件的Razor标记中直接使用属性名来传递值,而不需要额外的@符号(除非你是在传递一个代码表达式的结果作为值)。例如:。
有一点需要注意:如果你在传递参数时使用了绑定表达式(例如,绑定到一个组件的状态或父组件的状态),那么你可能需要使用@bind指令或@bind-Value(取决于属性的命名)来实现双向绑定。但即使在这种情况下,@符号也是用于标记绑定表达式本身,而不是用于标记参数属性。总结来说,Blazor如何解释传递给组件的值主要取决于[Parameter]属性的类型以及你传递值的方式(直接值、绑定表达式等)。@符号在Razor语法中用于标记代码表达式,但在传递参数时通常不是直接相关的,除非你在值中使用了代码表达式。
Blazor 在传递参数时,会根据目标参数的类型推断传递的值。
对于布尔、数字等非字符串类型,Blazor 会将字符串字面量推断为相应的表达式。
对于字符串类型,Blazor 会直接传递字符串值,而不会进行推断。
这种推断机制使得代码更简洁,同时确保类型安全。
双向绑定
双向绑定就是实现数据在前端区域和C#区域双向传递
通过@bind和@bind-value来实现
| 特性 | @bind | @bind-value |
|---|---|---|
| 语法简洁性 | 更简洁,适合大多数场景 | 更显式,适合复杂场景 |
| 默认绑定属性 | 自动绑定到 value 属性 | 显式绑定到 value 属性 |
| 默认事件 | 默认绑定到 onchange 事件 | 需要显式指定事件(如 oninput) |
| 适用场景 | 普通 HTML 元素或简单组件 | 自定义组件或需要显式控制的场景 |
| 灵活性 | 较低,适合简单绑定 | 较高,适合需要精细控制的场景 |
这里我们看下面这个例子,涵盖了双向绑定的各种情况
简单说就是懒的话,直接@bind,想精细点就@bind-value,毕竟牛逼

组件之间的参数传递
组件之间传递参数,可以通过将需要传递的组件通过[Parameter]暴露出去给父级组件来访问或者通过CascadingParameter来级联传递。
这里重点说CascadingParameter
它有两种模式
- 通过“名字”来让子组件索引自己要的信息
- 通过类型自动检索自己要的信息
先说“名字”索引
父组件想传递两个信息到子组件

传递的时候就这样套娃写,CascadingValue,然后指定传递的变量,然后给要传递的变量取一个“名字”,然后最后子组件就写在最内层就完了【我这里子组件叫CascadingChild】
子组件是这样的

在子组件里头需要用一个变量来接传递进来的参数
写一个属性CascadingParameter,然后指定好取的名字就可以接收到上层传递进来的参数了
基于类型的参数传递
父组件
我这里整了个复杂的类对象来传递,让子组件自动推断,类似于上面用“名字”来索引

子组件

最后再说下按“名字”传递的特殊情况-覆写
框架在按照名字进行索引的时候,会出现级联传递的名字一样的情况,这种情况下就会发生在传递过程中的覆写问题,框架并不禁止这种行为,可以在过程中组件将上一级传递的值修改,然后再往下传递。
父组件

子组件
这里就出现了子组件往更下一级的孙组件传递同样名字的参数,这个时候框架不禁止这种行为,可以自行修改变量的值,再往下传递。

孙组件

Blazor所支持的指令
| 类别 | 关键字/用法 |
|---|---|
| 控制流 | @if、@else、@switch、@for、@foreach、@while |
| 代码块 | @{ … } |
| 表达式 | @变量、@(表达式) |
| HTML 辅助方法 | @Html.Raw、@Html.ActionLink、@Html.Partial |
| 注释 | @* … *@ |
| 模型和视图数据 | @model、@using、@inherits |
| 布局和部分视图 | @section、@RenderBody、@RenderSection |
| 函数和属性 | @functions |
| Razor Pages | @page |
| 异步编程 | @await |
| 依赖注入 | @inject |
| 标签助手 | @addTagHelper、 |
| URL 和路径 | @Url.Action、@Url.Content |
| 表单和验证 | @Html.BeginForm、@Html.ValidationSummary、@Html.ValidationMessageFor |
| 组件渲染 | @(await Html.RenderComponentAsync) |
| 动态属性 | <div class=“@(isActive ? “active” : “inactive”)”> |
| 全局指令 | @namespace、@attribute |
| 转义字符 | @@ |
| 自定义指令 | 通过自定义 Razor 引擎或标签助手实现 |
控制流语句
@if、@else、@else if:条件判断。
@switch、@case、@default:多条件分支。
@for、@foreach、@while:循环语句。
代码块
@{ … }:定义多行C#代码块。
表达式
@变量:直接输出变量。
@(表达式):输出表达式的结果。
HTML辅助方法
@Html.Raw:输出未编码的HTML。
@Html.ActionLink:生成超链接。
@Html.Partial、@Html.RenderPartial:渲染部分视图。
注释
@* … *@:Razor注释。
模型和视图数据
@model:定义视图的强类型模型。
@using:引入命名空间。
@inherits:指定视图继承的基类。
布局和部分视图
@section:定义布局中的占位符内容。
@RenderBody():在布局页面中渲染主体内容。
@RenderSection:在布局页面中渲染特定部分。
函数和属性
@functions:定义视图中的函数或属性。
Razor Pages
@page:定义Razor页面的路由。
三元运算符
@(条件 ? “True” : “False”):条件化输出。
Lambda表达式
@{ Func<int, string> 函数名 = (参数) => “返回值”; }:定义和使用Lambda表达式。
其他
@await:用于异步操作。
Blazor的属性与属性展开Attribute Splatting
属性这个和前端部分联系比较紧密。
对于HTML组件来说,他们一般会有一些“键-值对”构成了属性描述
譬如举一个例子,可能某一个按钮有这些属性
| 属性名 | 值 |
|---|---|
| class | btn btn-primary |
| style | color:red; |
| disable | true |
| data-custom | 123 |
按照既有的理解,开发人员就直接在前端部分写HTML标签写这些玩意进去了。但是在Blazor框架下,这个可以通过C#的字典来定义,然后传递给前端HTML部分,这个就叫做属性了。就很方便可以动态在C#代码区修改属性
譬如我定义一个键值对属性字典

我把它给一个按钮附上

这样渲染的时候,框架就会把我希望的属性渲染给这个按钮
就是通过这个@attributes 来实现
这里就会引申出另一个问题,如果我HTML对象就本身存在了一些既有的键值对属性了,怎么处理呢,就要引出CaptureUnmatchedValues了。
来自AI的解读




如何捕获了未在组件中显式定义的属性CaptureUnmatchedValues
譬如我这里有一个组件CaptureUnmatchedValues

通过CaptureUnmatchedValues属性,配合一个键值对字典变量来接收外部传递进来的“属性”,接收没有显式定义的属性
外部我是这样写的

我在外部既有已经写了一个字体大小的属性,和内部显式定义的属性是重复的,这种时候就会按内部显式定义的属性来渲染,虽然这个组件也接收了来自外部的属性。

如果我把显式定义删掉

这样写,运行的效果这个按钮的字体大小就是10px了

来自AI的总结


Blazor的变量生命期
这里我让AI总结了下微软文档中关于【ASP.NET Core Razor组件生命周期】这篇文档的内容
就是每个组件自带的这些方法的执行顺序要小心,有很多坑

这部分就是理论性比较强,就没有去具体做些例子来试了,只能说是日后遇到用到的情况具体问题具体分析了。
Blazor的多线程与InvokeAsync
我先问了问AI,Blazor里的多线程和普通C#的多线程的区别
Blazor的多线程
- UI线程的限制:
在Blazor中,特别是Blazor WebAssembly应用程序中,所有的UI更新和事件处理都是在浏览器的UI线程上执行的。这意味着,如果开发者在Blazor中启动了一个异步操作(如使用Task.Run或await Task.Delay等),并且这个操作需要在UI线程上继续执行(如更新UI元素),那么就需要确保这个操作在完成后能够正确地返回到UI线程上。
为了实现这一点,Blazor通常会使用ConfigureAwait(true)来确保异步操作在完成后能够返回到捕获的同步上下文(即UI线程)上。
然而,这也可能导致性能问题,因为频繁的上下文切换会增加额外的开销。 - 线程安全性的考虑:
在Blazor中,由于UI线程的限制,开发者需要更加谨慎地处理线程安全问题。例如,当多个线程尝试同时访问或修改共享资源时,就需要使用适当的同步机制(如锁、信号量等)来避免竞态条件和死锁等问题。此外,由于Blazor运行在浏览器中,因此还需要考虑跨线程通信的问题。例如,如果需要在非UI线程上执行某些操作,并且这些操作的结果需要更新到UI上,那么就需要使用适当的方法(如通过消息传递或事件机制)来将结果传递回UI线程。 - Blazor Server的特殊性:
值得注意的是,Blazor Server的运行环境与Blazor WebAssembly有所不同。
在Blazor Server中,应用程序实际上是在服务器上运行的,而UI更新则是通过SignalR连接实时回传给客户端的。这意味着在Blazor Server中,多线程的使用可能更加复杂。
因为开发者需要处理服务器和客户端之间的通信、状态同步等问题。同时,由于服务器上的代码可以访问完整的.NET Core运行时支持,因此也可以使用更高级的多线程和并行编程技术来提高性能。
普通C#程序
C#提供了多种实现多线程的方式,如使用Thread类、ThreadPool类、Task类以及Parallel类等。这些类和方法允许开发者创建和控制线程,以实现并行执行多个任务。在普通C#程序中,多线程的访问和操作通常不会受到特定的框架或运行环境的限制。开发者可以自由地在线程之间共享数据、进行同步和通信等操作。然而,这也需要开发者自行处理线程安全问题,如避免竞态条件、死锁等问题。
这里先通过两种不同的初始化-同步初始化vs异步初始化,来体验下初探下多线程,然后再说一说InvokeAsync。
同步初始化

这里重写了OnInitialized方法,里面获取当前线程的管理线程ID。
然后外层父组件是通过一个for循环调用了5次这个组件

运行的效果就是这5个组件均会输出同一个管理线程的ID

然后我再问了AI关于这个的理解。
输出结果取决于以下因素:
情况 1:纯同步调用
如果父组件自身是完全同步的(例如没有 async 方法或 await):
所有子组件的 OnInitialized 会由同一个线程连续执行
输出的 IdThread 相同
这是因为同步代码会阻塞当前线程,直到所有子组件初始化完成。
情况 2:异步父组件
如果父组件包含异步逻辑(如 OnInitializedAsync):
可能在初始化过程中发生线程切换
子组件的 IdThread 可能不同(但未必一定会不同)
异步初始化
做一个异步初始化的代码,试一下,外层也是类似的for循环调用5次


出现了一些不同的线程ID了
输出的线程 ID 大概率会不同,因为异步操作可能导致线程切换。
ConfigureAwait(continueOnCapturedContext: bool)方法
这里再引申出另一个有关系的知识点-ConfigureAwait
实际上在异步初始化这里,我们的初始化是可以设置一个参数来控制线程控制权后面的归属的。
就是这个ConfigureAwait(continueOnCapturedContext: bool)方法。
用于指定在等待异步操作完成后,是否应该尝试将控制权返回给捕获的同步上下文(如果存在的话)。在这个上下文中,“捕获的同步上下文”通常指的是最初启动异步操作的上下文,比如ASP.NET Core的请求上下文或Blazor的UI线程
| 设置为true | 设置为false |
|---|---|
| 框架默认的行为是true,await 操作完成后,控制权将尝试返回给捕获的同步上下文。在Blazor中,这意味着如果异步操作是在UI线程上启动的,那么后续的操作也会尝试在UI线程上执行,以确保对UI元素的访问是线程安全的 | await 操作完成后,控制权不会返回给捕获的同步上下文,而是继续在当前可用的线程池线程上执行。这可以提高性能,因为它避免了不必要的上下文切换,但你必须小心确保不在错误的线程上访问UI元素 |
| 推荐的做法,保证UI访问的线程安全 | 不涉及UI的后台操作,使用false可以提高性能和响应 |
| 1. 需要操作 UI 组件(如更新 @currentCount)2. 访问 HttpContext(在 ASP.NET Core 中)3.使用 Blazor 的 JS 互操作(IJSRuntime.InvokeAsync) | 1. 通用类库代码(不依赖具体上下文)2. 纯后台任务(如日志记录、数据处理)3. 长时间运行的 CPU 密集型操作(避免阻塞 UI 线程) |
这里我们做一个测试
打印异步之前的线程ID,和不同设置下异步之后的线程ID


当使用await关键字时,默认情况下,它会捕获当前的同步上下文(在Blazor Server中,这通常是ASP.NET Core的同步上下文),并在异步操作完成后尝试回到这个上下文。这是为了确保像UI更新这样的操作能在正确的上下文中执行。在异步操作完成后,应该尝试回到原来的同步上下文。在Blazor Server中,这意味着回到处理该SignalR消息的线程或与之相关的线程。由于线程池的工作方式,这个“原来的线程”可能并不是实际开始执行异步操作的那个线程。因此,你看到的线程ID不同,是因为在await之后,代码可能是在线程池中的另一个线程上执行的,但这个线程被调度回来执行后续的代码,以确保它运行在正确的同步上下文中。所以线程ID会不一样。
如果是false的情况下

await之后它去到另一个线程ID了,没有回到之前的线程ID

InvokeAsync
先听听AI怎么说:
由于Blazor使用单线程的渲染模型,这意味着UI更新通常在主线程上执行。
这种设计简化了开发,但也可能导致性能问题,特别是在处理复杂或耗时的操作时。
某些场景我们会希望再后台线程处理一些操作,以避免阻塞UI线程,这时候就需要上多线程了。
这个InvokeAsync方法就是Blazor提供的一个用于在UI线程上执行代码,当你在后台线程操作时,如果要更新UI,就必须用InvokeAsync将更新操作调度到UI线程上执行,以避免线程冲突,直白点就是把涉及UI的更新部分更新给UI。
通过几个例子来看看
第1个例子

这个例子大概就是做了一个按钮,当单击按钮之前,任务状态显示时未开始,之后单击按钮,显示任务进行中,然后开始异步延时了。延时完了之后就通过InvokeAsync执行更新UI的显示,然后调用框架通知重写渲染UI。

单击之后,就组件自己更新,其他的东西不受影响

时间到了,完成异步操作,然后UI更新

第2个例子

这个例子就复杂一些,更新的是一个复杂的列表数据。原理其实和第一个差不多。就是这个复杂点。
单击之前

单击之后,数据就刷到列表里渲染出来了。

第3个例子

这个例子相比前面两个又要复杂些了。这个是一个定时刷新UI,显示实时时间的例子。
通过重写OnInitialized方法,初始化了一个定时器,并且实现Dispose接口,处理定时器在组件释放的时候的释放问题,一定要做释放,不然内存会有泄露的风险。定时器回调那里写法比较独特,我们来听听AI的说法:
_ = 是一种常见的写法,用于明确表示我们忽略这个返回值。_ 是一个合法的变量名,通常用于表示“我不关心这个值”。所以,_ = InvokeAsync(…) 的意思是:调用 InvokeAsync,但不关心它的返回值。将返回值赋值给 _,表示我们忽略它。
在 C# 中,如果一个方法返回 Task,而你直接调用它而不使用 await 或赋值给某个变量,编译器会发出警告,提示你“这个异步操作没有被等待”。
例如,如果你直接写:
InvokeAsync(() =>
{
currentTime = newTime;
StateHasChanged();
});
编译器会警告:
由于此调用未被等待,因此在调用完成之前将继续执行当前方法。请考虑将 await 运算符应用于调用结果。
为了避免这个警告,我们可以使用 _ = 来明确表示我们忽略返回值。
第4个例子

最后一个例子是一个并行任务的例子。每一个任务是均是一个单独异步任务,点击运行之后会等待所有任务都完成之后才算执行完。点击运行之后,最终的效果是任务123都显示完成。

AI替大家总结下:
Blazor的单线程渲染模型简化了开发,但在处理复杂操作时可能需要使用多线程。
InvokeAsync方法允许开发者在后台线程中执行操作,并将UI更新安全地调度到UI线程上执行。
通过合理使用InvokeAsync,可以提升Blazor应用的性能和响应性。
Blazor的虚拟DOM功能
AI先给大家说一说概念性的东西
虚拟 DOM 是一种在内存中表示 HTML 页面结构的技术。它是一个轻量级的 JavaScript 对象(或其他语言的对象),用来描述页面上应该渲染的 HTML 元素及其结构
它的作用是当页面需要更新的时候,框架不会直接操作DOM,而是操作内存里面的虚拟DOM,然后框架比较新旧DOM的差异,然后更新差异部分,而不是整个页面全部重新渲染一次.
这样做的好处就是可以减少直接操作真实DOM的次数,提高性能,提高UI的响应
在Blazor里面,它是通过BuildRenderTree来实现的,但是在目前的NET9版本里,实际上是不推荐用户自己去重写BuildRenderTree这个方法的,框架推荐用户通过Razor语言来实现动态渲染组件或者是通过RenderFragment来做
RenderFragment
这个按我的理解就是通过C#的代码来写DOM操作的语句
这里我通过几个例子来说明下
例子1-基础语法

基础写法就是这样写的,譬如我想写一个div,然后它的内容是hello world,我就要先写div,然后"hello world",然后结束。

在前端区域,如果要使用它,只需要@这个变量就完了,非常方便
例子2-内联前端写法
简单说就是C#内联直接写对应的前端代码

效果和上面一样
例子3-支持泛型参数的写法

这个区别于前面的写法,就是强制指定了参数的类型,然后调用的时候要这样写

例子4-支持属性的写法

有时候我们需要属性,譬如style,这个时候我们就可以通过这个AddAttribute加上对应的属性
这里引申出一个特殊的组件写法
例子5-动态代码定义样式的组件写法
这里我们以列表为例,一个泛型的动态列表,列表项的样式是动态的,可以通过外部代码来定义列表中元素的样式,内容。

这种要泛型的组件,需要写一句@typeparam XXXX,表示参数存在泛型,待会在父级需要告诉组件,对应的泛型是啥。
然后由于我们这个例子是一个列表,所以传入的对象需要是一个实现了IEnumerable的东西.然后在列表遍历的时候,需要传入对应的泛型参数给到ItemTemplate,由它来指定渲染的效果。
然后在父级要这样调用

告诉组件,泛型的类型,Items对应的对象是啥,然后呈现的样式模板是啥


然后实际运行起来的效果就是:

例子6-动态代码定义样式的组件写法-进阶版本
反正都可以指定传入参数的类型,就不局限于普通类型,也可以是复杂类型

这里我给了一个类变量
然后对应的也给了数据和模板


完了之后,原理其实和上面例子5是类似的,效果如下:

例子7-Tab页面的例子
这个例子是Blazor University的例子,比较绕,就是基于这个RenderFragment的复杂例子了。

就是一个这样的控件,当你点击某个Tab按钮之后,它会切换到另一个“页面”上,但是其他页面上的组件不会受到影响.
这个组件是由两个部分组成的:
- TabControl—[父组件]
- TabPage—[子组件]
调用者
<TabControl>
<TabTextTemplate>
<img src="/images/tab.png" />
</TabTextTemplate>
<ChildContent>
@* 当 TabControl 渲染每个 Tab 按钮时,会调用 @TabTextTemplate(tab),将当前 tab 对象传递给模板,并渲染 <img src="/images/tab.png" /> *@
<TabPage Text="Tab 1">
<h1>The first tab</h1>
</TabPage>
<TabPage Text="Tab 2">
<h1>The second tab</h1>
</TabPage>
<TabPage Text="Tab 3">
<h1>The third tab</h1>
</TabPage>
</ChildContent>
</TabControl>
TabControl
@rendermode InteractiveServer
@* 使用 CascadingValue 组件将 TabControl 自身(或 Tabs 集合)传递给子组件 *@
@* this 应该就是TabControl的引用*@
<CascadingValue Value="this">
<div class="btn-group" role="group">
@foreach (TabPage tab in Pages)
{
<button type="button" class="btn @GetButtonClass(tab)"
@onclick=@( ()=>ActivatePage(tab) )>
@tab.Text
@if (TabTextTemplate != null)
{
@* 如果提供了TabTextTemplate,则使用模板来渲染按钮内容,否则直接使用TabPage的Text属性 *@
@* 调用模板并传入当前 TabPage 对象作为参数,然后渲染模板定义的内容 *@
@*tab 是 TabControl 组件中 @foreach 循环的当前 TabPage 对象*@
@* TabTextTemplate(tab) 会将 tab 作为参数传递给模板,并返回一个 RenderFragment,最终渲染出模板定义的内容。 *@
@TabTextTemplate(tab)
}
else
{
@tab.Text
}
</button>
}
</div>
@ChildContent
</CascadingValue>
@code {
/// <summary>
/// 自动捕获所有嵌套的子组件
/// </summary>
[Parameter]
public RenderFragment ChildContent{ get; set; }
//它是一个模板,允许用户自定义每个 Tab 按钮的显示内容
//表示一个可以接收参数(这里是 TabPage 对象)并返回渲染内容的模板
//在 index.razor 中,TabTextTemplate 被定义为一个 <img> 标签
[Parameter]
public RenderFragment<TabPage> TabTextTemplate { get; set; }
public TabPage ActivePage{ get; set; }
List<TabPage> Pages = new List<TabPage>();
internal void AddPage(TabPage page)
{
Pages.Add(page);
if(Pages.Count == 1)
{
ActivePage = page;
}
StateHasChanged();
}
string GetButtonClass(TabPage page)
{
return page == ActivePage ? "btn-primary" : "btn-secondary";
}
void ActivatePage(TabPage page)
{
ActivePage = page;
}
}
在父组件这层,对外需要传入的参数有:
- RenderFragment ChildContent
- RenderFragment TabTextTemplate
这个TabTextTemplate是用来自定义Tab按钮显示内容的,譬如我们在外层调用者那里传入了img标签对应的图片
这个ChildContent是用于捕获嵌套在TabControl中的所有子组件,譬如我们在外层调用者里面写的那三个TabPage
其他变量的作用:
Pages用于TabControl所有包含的TabPage
ActivatePage用来得到当前活动的TabPage
AddPage,用于将TabPage添加到Pages列表里,然后添加第一个TabPage时将其设置为活动页面。
前端部分:
: 使用CascadingValue将TabControl实例传递给所有子组件(即TabPage组件),以便子组件可以访问父组件的属性和方法。这里的this应该就是指代this指针的意思.
@foreach (TabPage tab in Pages): 遍历所有的TabPage,并为每个TabPage生成一个按钮。
: 每个按钮对应一个TabPage,点击按钮时会调用ActivatePage方法激活对应的TabPage。GetButtonClass方法用来判定当前Page是不是激活的哪个,然后决定是不是要修改button所属的class.
@if (TabTextTemplate != null): 如果提供了TabTextTemplate,则使用模板来渲染按钮内容,否则直接使用TabPage的Text属性。
@ChildContent: 渲染TabControl的内容部分,即所有的TabPage。
我在补充下我不太理解的
TabPage
@rendermode InteractiveServer
@* 如果当前对应的父组件是当前子组件,就呈现出定制Tab页面的内容 *@
@if(Parent.ActivePage == this)
{
@ChildContent
}
@code {
/// <summary>
/// 接收自父组件传递的引用,传入进来的值是对应父组件的引用
/// </summary>
[CascadingParameter]
public TabControl Parent{ get; set; }
/// <summary>
/// 用于定制Tab页的内容,对应在顶层调用里填的那段HTML
/// </summary>
[Parameter]
public RenderFragment ChildContent{ get; set; }
[Parameter]
public string Text { get; set; }
/// <summary>
/// 初始化组件的时候
/// </summary>
/// <exception cref="ArgumentNullException"></exception>
protected override void OnInitialized()
{
if(Parent == null)
{
throw new ArgumentNullException(nameof(Parent), "TabPage must exist within a TabControl");
}
base.OnInitialized();
//父组件添加当前Page作为子页面
Parent.AddPage(this);
}
}
Parent: 通过级联参数传递得到父组件变量的引用
@if(Parent.ActivePage == this): 只有在当前TabPage是活动页面时,才会渲染ChildContent。
@ChildContent: 渲染TabPage的内容部分。
Text: 用于设置Tab按钮的文本。
OnInitialized: 在组件初始化时,将当前TabPage添加到父组件TabControl的Pages列表中。
顶层调用者,定义了TabControl和多个TabPage的结构
TabControl.razor 是管理多个TabPage的组件,负责渲染Tab按钮并控制哪个TabPage是活动的。
TabPage.razor 是单个选项卡页面的组件,只有在它处于活动状态时才会显示其内容。
这里我还想再补充下,关于RenderFragment的理解
在 TabControl 组件的代码中,有一个 TabTextTemplate 参数,类型为 RenderFragment
public RenderFragment TabTextTemplate { get; set; }
这表示 TabTextTemplate 是一个可以接受 TabPage 对象作为输入,并返回 UI 内容的模板。

Blazor 会自动将 标签内的内容转换为一个 RenderFragment 的委托
怎么理解这个委托呢
RenderFragment 的定义
它是一个 泛型委托,可以接受一个类型为 T 的参数,并返回要渲染的 UI 内容。用代码表示就是:
// 伪代码简化表示
public delegate RenderFragment RenderFragment(T input);
输入:一个 T 类型的参数(比如 TabPage 对象)
输出:RenderFragment(一段 UI 内容)
实际Blazor框架会将对应到例子上的写法转换上下面这样的写法
TabTextTemplate = (TabPage context) => __builder =>
{
<img src="/images/tab.png" />
};
传入了TabPage这个变量,到HTML标签中,但是实际上没有用到
RenderFragment 就像「一个能生成 UI 的模板工厂」
想象你有一个 制作汉堡的模具(模板),这个模具需要你 提供食材(参数 T),然后它会 自动压出特定形状的汉堡(生成 UI)。RenderFragment 就是这个模具,而 T 就是你给它的食材类型。
为什么需要这种设计?
关注点分离:业务逻辑(数据)和 UI 表现分离
复用性:同一套数据可以用不同的模板渲染
动态性:根据运行时数据决定如何渲染
类型安全:编译时检查模板中使用的属性是否存在
这种模式在需要高度定制 UI 的组件(如表格、列表、导航菜单)中非常常见,它完美结合了 C# 的强类型特性和 HTML 的声明式语法。
Blazor的@key关键字
Key关键字是用来让列表类的数据出现"唯一性"的一个关键字,因为某些场景下,我们是希望列表中的数据存在唯一性的,处理的时候也是。
这里我们看两个例子
例子1-泛型模板列表
调用者
<DataList TItem="Person" Data=@People>
<ItemTemplate>
<li @key=context>
@context.Name @context.Age @context.Sex
</li>
</ItemTemplate>
</DataList>
DataList是一个泛型列表的组件,然后它会渲染出Person类对象列表中的信息.
注意这里出现了@key关键字,这里的context就代指People类对象列表中的对象.然后列表项展示Name,Age和Sex
DataList
@typeparam TItem
@if (ListTemplate == null)
{
<ul>
@* ?? Array.Empty<TItem>() 避免data是null的时候造成影响*@
@foreach (TItem item in Data ?? Array.Empty<TItem>())
{
@ItemTemplate(item)
}
</ul>
}
else
{
@* @: 看作是一个“转义符号”,它告诉 Blazor:“接下来的内容不要解析为 Razor 代码 *@
@ListTemplate(
// 在你的代码中,@: 的作用是确保 @{ ... } 中的内容被正确传递给 ListTemplate,而不会被误解析为 Razor 代码
@:@{
foreach (TItem item in Data ?? Array.Empty<TItem>())
{
@ItemTemplate(item)
}
}
)
}
@code {
[Parameter]
public IEnumerable<TItem> Data{ get; set; }
[Parameter]
public RenderFragment<TItem> ItemTemplate{ get; set; }
[Parameter]
//这表示 ListTemplate 是一个接受 RenderFragment 类型参数的模板
public RenderFragment<RenderFragment> ListTemplate{ get; set; }
}
和前面所说的泛型组件类似,也是有一个@typeparam TItem来代指泛型。
[Parameter] public IEnumerable Data { get; set; }:这是一个参数属性,用于接收要展示的数据集合。它必须是IEnumerable类型,即任何实现了IEnumerable接口的集合类型,其中T是数据项的类型。
[Parameter] public RenderFragment ItemTemplate { get; set; }:这是一个渲染片段参数,用于定义如何渲染每个数据项。RenderFragment是一个委托类型,它接受一个TItem类型的参数并返回一个RenderFragment,后者定义了如何渲染UI。
[Parameter] public RenderFragment ListTemplate { get; set; }:这是另一个渲染片段参数,但它更加灵活。它接受一个RenderFragment类型的参数(这里实际上是接受一个代码块),这个RenderFragment本身又可以包含更多的渲染逻辑。这允许开发者完全自定义整个列表的渲染方式,包括列表的HTML结构。
比较精妙的逻辑在其渲染的那部分
组件内部首先检查ListTemplate是否为null:
如果ListTemplate为null,则使用默认的<ul>标签来渲染数据项列表。数据项通过foreach循环遍历Data集合(如果Data为null,则使用Array.Empty<TItem>()来避免空引用异常),并对每个数据项应用ItemTemplate渲染片段。
如果ListTemplate不为null,则使用ListTemplate来渲染列表。这里使用了@:指令来“转义”接下来的代码块,确保它不会被解析为Razor代码,而是作为一个完整的RenderFragment传递给ListTemplate。在ListTemplate内部,再次遍历Data集合并对每个数据项应用ItemTemplate渲染片段。
这里再说说转义:
在Blazor中,@: 符号被用作一个“转义”符号,它的作用是告诉Blazor编译器接下来的内容不应该被当作Razor代码来解析。这在一些特定的场景下非常有用,尤其是当你想要将一个代码块或者一段文本直接传递给一个组件或者渲染片段(RenderFragment)时。
ListTemplate是一个接受RenderFragment类型参数的模板。这里的嵌套RenderFragment结构可能有点令人困惑,但基本思想是ListTemplate可以定义一个自定义的列表渲染逻辑,这个逻辑内部又可以包含另一个渲染片段,用于渲染列表项
直接在ListTemplate内部写Razor代码会遇到一个问题:Blazor编译器会尝试解析这些代码作为Razor标记,而不是将它们作为一个整体传递给ListTemplate。这就是@:符号发挥作用的地方
当你使用@:时,你实际上是在说:“接下来的这部分内容应该被视为一个普通的文本块或者代码块,不要尝试解析其中的Razor语法。
其实这样写就比较难读懂其实,最好还是用一个局部函数来写,比较容易看得懂,原地写lambda表达式总觉得怪怪的
@code {
private RenderFragment GetListItemsRenderFragment()
{
return builder =>
{
foreach (var item in Data ?? Array.Empty<TItem>())
{
builder.AddContent(0, ItemTemplate(item));
}
};
}
}
<!-- 在ListTemplate中使用这个函数 -->
@ListTemplate(GetListItemsRenderFragment())
然后回到key关键字,它的作用最主要的还是当列表有变化的时候,框架可以正确的管理好每一个列表中的列表项.
例子2
<DataList Data=@People TItem="Person">
<ListTemplate Context="listofPeople" >
<table boarder=1 cellpadding=4>
<thead>
<tr>
<th>
Name
</th>
<th>
Age
</th>
<th>
Sex
</th>
</tr>
</thead>
<tbody>@listofPeople</tbody>
</table>
</ListTemplate>
@* Context这里是来指定传入的参数的名称的不是变量的名称 *@
@* ItemTemplate 定义了每个 Person 对象的渲染方式 *@
<ItemTemplate Context="person">
@* 在 ItemTemplate 内部,可以通过 @person 访问当前的 Person 对象。 *@
<tr @key=@person>
@* key 是 Blazor 中的一个优化机制,用于标识列表项的唯一性 *@
@* 这里,@person 作为 key,确保每个 Person 对象的渲染是独立的。 *@
<td>@person.Name</td>
<td>@person.Age</td>
<td>@person.Sex</td>
</tr>
</ItemTemplate>
</DataList>
其实和上面那个例子差不多,但是有点不一样的是这里出现了Context属性,这个是用来指定传入参数的名称而不是对应数据变量的名称,就当是一个别名,渲染出来的效果是下面这样

Blazor的路由功能
路由的意思就是允许用户通过URL访问不同的页面或者组件
路由是通过@page指令来实现的
路由是通过URL实现的,URL就可以向页面传递参数。
这个参数可以设定为可选传入,或者必须传入,还可以限制传入参数类型,也可以让一个页面支持多种不同的参数类型传入。
具体写法
@page "/counter"
普通的路由
@page "/user/{UserId}"
包含一个必须要填的参数 UserId
@page "/user/{UserId:int?}
包含一个可选要填的整型数参数 UserId
@page "/user/{UserId?}/{Action?}"
包含多个可选参数的路由
@page "/user/{UserId:int}
包含一个必选要填的整型数参数 UserId
包含多个路由
@page "/user"
@page "/user/{UserId}"
SupplyParameterFromQuery功能,通过设置一个别名来做查询
@page "/search"
<h3>QueryPage</h3>
<p>Query:@Query</p>
@code {
[Parameter]
[SupplyParameterFromQuery(Name ="q")]
public string Query{ get; set; }
}
实际运行效果就是这样

Blazor 支持多种内置的路由约束,例如:
int:参数必须是整数。
bool:参数必须是布尔值(true 或 false)。
datetime:参数必须是日期时间格式。
guid:参数必须是 GUID 格式。
long:参数必须是长整型。
decimal:参数必须是十进制数。
float:参数必须是浮点数。
double:参数必须是双精度浮点数。
抛开内置的约束,自己还可以自定义约束,就是实现RouteConstraint接口
譬如,自定义一个限定偶数的约束
namespace LearnBlazor.Components.LearnRouteConstraint
{
/// <summary>
/// 自定义路由约束-限定偶数[注意,虽然是偶数,但是传入的还是字符串]
/// </summary>
public class EvenNumberRouteConstraint : IRouteConstraint
{
public bool Match(HttpContext? httpContext, IRouter? route, string routeKey, RouteValueDictionary values, RouteDirection routeDirection)
{
if(values.TryGetValue(routeKey,out var value) &&
value is string stringValue)
{
if (int.TryParse(stringValue, out int intValue))
{
return intValue % 2 == 0;
}
}
return false;
}
}
}
写完之后,需要在Program.cs注册下自定义的约束到框架里
//添加自定义路由约束
builder.Services.AddRouting(options =>
{
options.ConstraintMap.Add("even", typeof(EvenNumberRouteConstraint));
});
注册完就可以用了,就直接打注册时录入的名字,譬如这里是even
用的时候
@page “/user/{Number:even}”
如何把界面添加到导航里
路由弄好了之后,如何让框架知道有这个页面,通过NavLink组件来实现。
NavLink 是 Blazor 中用于创建导航链接的组件,类似于 HTML 的 标签。
它的特殊之处在于可以根据当前 URL 自动为匹配的链接添加一个 CSS 类(默认是 active),以便用户知道当前所在的页面。
这里就要引出一个匹配的问题,就是URL有可能有相似的地方,是完全匹配还是怎么样
NavLinkMatch 属性的作用:
NavLinkMatch 是一个枚举类型,用于控制 NavLink 如何匹配当前 URL。
它有两个可选值:
- NavLinkMatch.All:要求 URL 完全匹配 href 属性值。
- NavLinkMatch.Prefix(默认值):只要当前 URL 以 href 属性值开头,就会匹配。
NavLinkMatch.All 的使用场景:
当需要精确匹配 URL 时使用。例如,如果 href 是 /counter,则只有当 URL 正好是 /counter 时,链接才会被激活
<NavLink href="/counter" Match="NavLinkMatch.All">Counter</NavLink>
NavLinkMatch.Prefix 的使用场景
这是默认行为,适用于大多数情况。例如,如果 href 是 /,则任何以 / 开头的 URL(如 /、/home、/about)都会激活该链接
<NavLink href="/" Match="NavLinkMatch.Prefix">Home</NavLink>
这里需要往深引申出~
嵌套路由
NavLinkMatch.Prefix 的行为看起来可能会导致多个链接同时被激活,但实际上它的设计是为了解决一些特定的导航场景,尤其是嵌套路由或分层路由的情况.
一个父页面可能包含多个子页面,使用 NavLinkMatch.Prefix 可以确保父页面的导航链接在子页面中仍然保持激活状态。
假设你有一个应用,结构如下:
/:主页
/dashboard:仪表盘
/dashboard/profile:用户资料
/dashboard/settings:设置
你希望当用户访问 /dashboard/profile 或 /dashboard/settings 时,/dashboard 的导航链接仍然保持激活状态,以表明用户当前位于“仪表盘”部分。
<NavLink href="/dashboard" Match="NavLinkMatch.Prefix">Dashboard</NavLink>
当用户访问 /dashboard 时,链接激活。
当用户访问 /dashboard/profile 或 /dashboard/settings 时,链接仍然激活
就是前一级是激活的,后面的就也按激活处理了
默认路由
假设你有一个默认路由 /,你希望它在任何子路由中都保持激活状态
<NavLink href="/" Match="NavLinkMatch.Prefix">Home</NavLink>
当用户访问 /、/about 或 /contact 时,Home 链接都会激活。
总的来说,路由通常都是分层涉及的
在合理的路由设计中,父路由和子路由是分层的,不会冲突。
确保路由之间没有重叠的前缀。
例如,避免设计类似 /dashboard 和 /dashboard-settings 的路由
/dashboard 和 /dashboard/profile 是父子关系,不会与其他路由(如 /about)冲突。
NavLinkMatch.Prefix 通常用于父级导航:
父级导航链接使用 NavLinkMatch.Prefix,而子级导航链接使用 NavLinkMatch.All,这样可以避免冲突。
对于子页面或独立页面,使用 NavLinkMatch.All 来确保精确匹配
CSS 样式的控制:
即使多个链接激活,也可以通过 CSS 样式来控制视觉效果,确保用户只关注当前的主要导航。
NavLinkMatch.Prefix 的主要意义在于支持嵌套路由和分层导航。它的设计并不是为了让多个链接同时激活,而是为了让父级导航在子页面中仍然保持激活状态,从而提供更好的用户体验。
如果你不希望多个链接同时激活,可以通过合理设计路由、使用 NavLinkMatch.All 或自定义逻辑来实现。
激活是指当前导航链接与浏览器 URL 匹配时,链接被标记为“激活状态”。 激活状态通过 CSS 类(如 active)体现,为用户提供视觉反馈。 在 Blazor 中,NavLink组件会自动处理激活状态,支持精确匹配(NavLinkMatch.All)和前缀匹配(NavLinkMatch.Prefix)。 你可以通过ActiveClass 属性自定义激活状态的 CSS 类名。
激活的作用 视觉反馈: 当用户点击一个导航链接或通过其他方式导航到某个页面时,激活状态会为当前链接添加一个 CSS 类(如active),从而改变链接的样式(例如高亮显示)。 这为用户提供了清晰的视觉反馈,帮助他们知道当前所在的页面。 提升用户体验:激活状态可以帮助用户快速识别当前页面的位置,尤其是在复杂的导航菜单中。
Blazor的表单功能
表单功能是Blazor框架里比较重要的一个内容。这里我们分成几个小的部分来说。
- 数据如何绑定到表单
- 表单的数据如何进行验证
- 表单的数据如何进行提交
数据如何绑定到表单
在框架中,数据希望绑定到表单是使用@bind-Value的方式将数据绑定到表单组件上的。
内置的表单组件为EditFrom,使用的时候需要填写相应的元素模型,然后将需要呈现的输入类的组件内嵌在EditForm中
譬如下面这样
<EditForm Model="@person">
<InputText @bind-Value="person.Name" />
<InputNumber @bind-Value="person.Age" />
</EditForm>
@code {
private Person person = new Person { Name = "John", Age = 30 };
}
Model的值被赋值给person,说明这个表单呈现的数据模型为person
在这个EditForm里面,嵌入了两个输入组件,1个输入字符串的组件和1个输入数字的组件,然后他们的值绑定在了对应的类对象上。
这种绑定是基于数据已经在框架内了的,如果数据需要从别处提取出来呢,别处可能是数据库,API,文件等等
<EditForm Model="@person">
<InputText @bind-Value="person.Name" />
<InputNumber @bind-Value="person.Age" />
</EditForm>
@code {
private Person person;
protected override async Task OnInitializedAsync()
{
person = await HttpClient.GetFromJsonAsync<Person>("api/person/1");
}
}
这个例子和上面的例子有点类似,但是不同的是数据的来源。
例子重写了OnInitializedAsync,里面包含了一个异步请求Person类型的JSON数据到框架里的语句。请求到了之后便将数据绑定到表单里。
表单的数据如何进行提交
使用 OnValidSubmit 或 OnInvalidSubmit 来处理表单验证成功或失败的情况
举个例子
<EditForm Model="@person" OnValidSubmit="SavePerson">
<InputText @bind-Value="person.Name" />
<InputNumber @bind-Value="person.Age" />
<button type="submit">Save</button>
</EditForm>
@code {
private Person person;
protected override async Task OnInitializedAsync()
{
person = await HttpClient.GetFromJsonAsync<Person>("api/person/1");
}
private async Task SavePerson()
{
await HttpClient.PutAsJsonAsync("api/person/1", person);
}
}
OnValidSubmit就是对应验证通过以后的提交函数接口录入的地方
OnInvalidSubmit就是对应验证不通过以后的函数接口录入的地方
表单的数据如何进行验证
这个是表单这个章节最重要的内容了。
在Blazor这种框架下,可能对我来说比较有挑战性的,就是理解这种开发概念
数据模型-验证-UI组件
这种方式和我平常写顺手了的Winform那种基于事件驱动的开发就很不一样了。
主要是Winform确实开发小工具,特别是嵌入式用的测试工具,数据调试,配置工具等等工具特别方便
以前那种写法,就是事件和UI搅合在一起,强耦合,就很难做到分的很开
现在这种基于“ 数据模型-验证-UI组件”的方式确实是很先进了
也是一样,通过几个由浅入深的例子来说一说这个数据验证[ 数据模型-验证-UI组件]的概念
例子1

第一个例子是一个很简单的表单提交界面,就是Person类
它比较厉害的地方在于,如果你填写的不符合要求或者不填,都会触发它给出提示,譬如

这种是如何实现的呢.下面给出例子的代码:
@rendermode InteractiveServer
@page "/LearnForm"
@inject HttpClient HttpClient
@inject IJSRuntime JSRuntime
@* 依赖注入 *@
<h3>LearnForm</h3>
<h1>Status: @Status</h1>
<EditForm Model=@person OnSubmit="FormSubmitted">
@* 这是一个内置的验证组件,用于启用基于数据注解(Data Annotations)的验证 *@
<DataAnnotationsValidator/>
@* 内置的验证摘要组件,用于显示表单中所有验证错误信息的汇总 *@
<ValidationSummary/>
<div>
<label>ID:</label>
<InputNumber @bind-Value="person.ID" />
@* For 属性的作用是指定 <ValidationMessage> 组件应该显示哪个字段的错误信息。 *@
<ValidationMessage For="@(() => person.ID)" />
</div>
<div>
<label>Name:</label>
<InputText @bind-Value="person.Name" />
<ValidationMessage For="@(()=>person.Name)" />
</div>
<div>
<label>Age:</label>
<InputText @bind-Value="person.Age" />
<ValidationMessage For="@(()=>person.Age)" />
</div>
<div>
<label>Sex:</label>
<InputText @bind-Value="person.Sex" />
<ValidationMessage For="@(()=>person.Sex)" />
</div>
<button type="submit">Save</button>
</EditForm>
@code {
string Status = "Not submitted";
Person person = new Person { Name="aaa" ,Sex="男" ,Age="11"};
int ID = 12345;
async void FormSubmitted()
{
Status = "Form submitted";
// await HttpClient.PutAsJsonAsync<Person>("api/person/1",person);
var json = System.Text.Json.JsonSerializer.Serialize(person);
// 打印 JSON 到浏览器控制台
Console.WriteLine("Saved JSON:");
Console.WriteLine(json);
{
// 调用 JavaScript 函数,将 JSON 打印到浏览器控制台
var jsson = System.Text.Json.JsonSerializer.Serialize(person);
await JSRuntime.InvokeVoidAsync("logToConsole", "Saved Json");
await JSRuntime.InvokeVoidAsync("logToConsole", jsson);
// 提示用户数据已保存
await JSRuntime.InvokeVoidAsync("logToConsole", "Data saved successfully!");
}
// 提示用户数据已保存
Console.WriteLine("Data saved successfully!");
}
protected override async Task OnInitializedAsync()
{
//从API处获取ID=1的Person数据
//person=await HttpClient.GetFromJsonAsync<Person>("api/person/1");
}
}
它对应的数据模型Person
using System.ComponentModel.DataAnnotations;
namespace LearnBlazor.Components
{
public class Person
{
//加了个别属性的表单验证
[Required(ErrorMessage ="Name is required")]
public string Name { get; set; }
public string Age { get; set; }
[Required(ErrorMessage ="ID is required")]
[Range(1,1222,ErrorMessage ="1~1222之间")]
public int ID { get; set; }
public string Sex { get; set; }
public override string ToString()
{
return string.Format("{3}: {0}-{1}-{2}", ID,Name, Age, Sex);
}
}
}
这两节代码要联合一起看
我们先从数据模型Person开始看:
首先这种表单验证的UI组件下,数据模型和以往的一样,它多了很多属性,那些属性就是用来做出它验证功能的东西.
Required表示这个字段是必须要填的,后面的ErrorMessage就是对应如果不填后的报错信息
这个报错信息对应到UI那边就是ValidationMessage那个内置组件对应做出显示的
此外,显示这种报错信息,需要一个总控,就是DataAnnotationsValidator.这个要写才行.
对于那种有范围的值,譬如数这一类的,可以给出范围(就是数据模型类里的Range标签),超过范围也会给报警信息
回到UI层,报警信息可以单独每一个输入控件显示,也可以总列表显示
总列表显示是ValidationSummary控制的
这个确实是比Winform那种模式方便多了,不用额外再写很多代码和提示来做数据验证。
这里为了测试效果,我是试了试NET和JS互操作还有依赖注入的功能,这个要后面才讲,这里可以不理他先,反正就是输出个结果用的。
它可以使用的验证功能的属性应该不止这些,应该还有别的,这里我就没有试了。
例子2
第二个例子是一个自定义验证类的例子

效果大概是这样的
它的模型类UserModel
using System.ComponentModel.DataAnnotations;
namespace LearnBlazor.Components.FormValided
{
public class UserModel
{
[Required(ErrorMessage = "用户名不能为空")]
[StringLength(20, MinimumLength = 3, ErrorMessage = "用户名长度需在3-20字符之间")]
public string UserName { get; set; }
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email { get; set; }
[Required(ErrorMessage = "密码不能为空")]
[CustomPasswordValidation] // 自定义密码验证
public string Password { get; set; }
[Required(ErrorMessage = "请确认密码")]
[Compare(nameof(Password), ErrorMessage = "两次密码不一致")]
public string ConfirmPassword { get; set; }
}
}
这里的CustomPasswordValidation是自定义的密码验证类
然后这里出现了验证两次输入是否一致的Compare属性,验证EMail格式是不是正确的EmailAddress属性,字符串长度验证属性StringLength。
我们接下来看CustomPasswordValidation,自定义密码验证类
using System.ComponentModel.DataAnnotations;
using System.Text.RegularExpressions;
namespace LearnBlazor.Components.FormValided
{
/// <summary>
/// 自定义密码验证器
/// </summary>
public class CustomPasswordValidationAttribute:ValidationAttribute
{
protected override ValidationResult? IsValid(object? value, ValidationContext validationContext)
{
var password = value?.ToString();
if (string.IsNullOrEmpty(password))
return new ValidationResult("密码不能为空");
// 密码强度规则:至少8位,包含大小写字母和数字
var regex = new Regex(@"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).{8,}$");
if (!regex.IsMatch(password))
return new ValidationResult("密码需至少8位,包含大小写字母和数字");
return ValidationResult.Success;
}
}
}
自定义的验证类应该是名字要遵循XXXAttribute的写法,然后继承ValidationAttribute基类,然后重写IsValid函数,返回验证是不是通过。
界面部分:
@rendermode InteractiveServer
@page "/Regis"
@using System.ComponentModel.DataAnnotations
@inject HttpClient Http
<h3>用户注册</h3>
<EditForm Model="@user" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group">
<label>用户名:</label>
<InputText @bind-Value="user.UserName"
@oninput="async () => await CheckUsernameAvailability()"
class="form-control" />
<ValidationMessage For="@(() => user.UserName)" />
@if (IsCheckingUsername)
{
<div class="text-muted small">检查中...</div>
}
@if (!IsUsernameAvailable)
{
<div class="invalid-feedback">用户名已被占用</div>
}
</div>
<div class="form-group">
<label>邮箱:</label>
<InputText @bind-Value="user.Email" class="form-control" />
<ValidationMessage For="@(() => user.Email)" />
</div>
<div class="form-group">
<label>密码:</label>
<InputText type="password" @bind-Value="user.Password" class="form-control" />
<ValidationMessage For="@(() => user.Password)" />
</div>
<div class="form-group">
<label>确认密码:</label>
<InputText type="password" @bind-Value="user.ConfirmPassword" class="form-control" />
<ValidationMessage For="@(() => user.ConfirmPassword)" />
</div>
<button type="submit" class="btn btn-primary" disabled="@IsSubmitting">
@if (IsSubmitting)
{
<span class="spinner-border spinner-border-sm" role="status"></span>
}
注册
</button>
@if (!string.IsNullOrEmpty(ServerErrorMessage))
{
<div class="alert alert-danger mt-3">@ServerErrorMessage</div>
}
</EditForm>
@code {
private bool IsSubmitting = false;
private bool IsCheckingUsername = false;
private bool IsUsernameAvailable = true;
// 检查用户名是否可用(带防抖)
private async Task CheckUsernameAvailability()
{
if (string.IsNullOrWhiteSpace(user.UserName) || user.UserName.Length < 3)
{
return;
}
IsCheckingUsername = true;
await Task.Delay(500);
try
{
var response = await Http.GetAsync($"api/users/check?username={user.UserName}");
IsUsernameAvailable = response.IsSuccessStatusCode;
}
catch (Exception)
{
IsUsernameAvailable = false;
}
finally
{
IsCheckingUsername = false;
}
}
private string ServerErrorMessage = "";
private UserModel user = new UserModel();
private async Task HandleSubmit()
{
// 实际提交逻辑(如调用API)
Console.WriteLine("注册成功!");
Console.WriteLine($"用户名: {user.UserName}");
Console.WriteLine($"邮箱: {user.Email}");
IsSubmitting = true;
ServerErrorMessage = "";
try
{
var response = await Http.PostAsJsonAsync("api/users/register", user);
if (response.IsSuccessStatusCode)
{
// 注册成功,跳转到登录页
Console.WriteLine("注册成功");
}
else
{
ServerErrorMessage = await response.Content.ReadAsStringAsync();
}
}
catch (Exception ex)
{
ServerErrorMessage = $"注册失败: {ex.Message}";
}
finally
{
IsSubmitting = false;
}
}
}
有一部分内容和前面的例子重复,就是表单那部分,着重说不一样的。
这里输入名字的时候多了一个CheckUsernameAvailability的功能,就是检测名字是不是可以用的功能
// 检查用户名是否可用(带防抖)
private async Task CheckUsernameAvailability()
{
if (string.IsNullOrWhiteSpace(user.UserName) || user.UserName.Length < 3)
{
return;
}
IsCheckingUsername = true;
await Task.Delay(500);
try
{
var response = await Http.GetAsync($"api/users/check?username={user.UserName}");
IsUsernameAvailable = response.IsSuccessStatusCode;
}
catch (Exception)
{
IsUsernameAvailable = false;
}
finally
{
IsCheckingUsername = false;
}
}
这里它不单纯只是基本的字符串格式检测,还发起了一个HTTP请求到后端问这个名字是不是可用,这里牵涉后面才会说到的依赖注入的知识,暂时不深究,反正知道这里是引入了一个HTTP请求查询就可以了,如果查询的到就返回OK,不行就是失败,并且给出对应的显示。IsCheckingUsername 这里的作用是为了防止用户重复去点击提交。
提交完毕之后,是有效提交之后,会触发HandleSubmit
private async Task HandleSubmit()
{
// 实际提交逻辑(如调用API)
Console.WriteLine("注册成功!");
Console.WriteLine($"用户名: {user.UserName}");
Console.WriteLine($"邮箱: {user.Email}");
IsSubmitting = true;
ServerErrorMessage = "";
try
{
var response = await Http.PostAsJsonAsync("api/users/register", user);
if (response.IsSuccessStatusCode)
{
// 注册成功,跳转到登录页
Console.WriteLine("注册成功");
}
else
{
ServerErrorMessage = await response.Content.ReadAsStringAsync();
}
}
catch (Exception ex)
{
ServerErrorMessage = $"注册失败: {ex.Message}";
}
finally
{
IsSubmitting = false;
}
}
这里提交也是有个发送HTTP POST的功能,这里知道就可以了,因为我们还没有说依赖注入的章节。
例子3
这个例子是一个自定义输入组件+自定义验证逻辑容器的例子,比较复杂
用户模型 EmailModel
using LearnBlazor.Components.FormValided;
using System.ComponentModel.DataAnnotations;
namespace LearnBlazor.Components.EmailValided
{
public class RegisterModel
{
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email { get; set; }
[Required(ErrorMessage = "密码不能为空")]
[StringLength(8, MinimumLength = 3, ErrorMessage = "密码长度需在3-8字符之间")]
public string Password { get; set; }
[Required(ErrorMessage = "请确认密码")]
[Compare(nameof(Password), ErrorMessage = "两次密码不一致")]
public string ConfirmPassword { get; set; }
}
}
这个就没啥好说的了,和前面的例子差不多,数据验证属性搞起来。
自定义输入组件CustomInput
@using System.ComponentModel.DataAnnotations
@inherits InputText
<input @attributes="AdditionalAttributes"
class="CssClass"
@bind-value="CurrentValue"
@bind-value:event="oninput"/>
@code {
[CascadingParameter]
private EditContext EditContext { set; get; }
protected override void OnParametersSet()
{
base.OnParametersSet();
EditContext?.NotifyFieldChanged(FieldIdentifier);
}
}
自己定义的输入组件,需要继承InputXXXX,这里我是文字类的,就继承InputText
@inherits InputText
因为继承,所以可以引用一些他提供的对象
下面的这句
<input @attributes=“AdditionalAttributes” class=“CssClass” @bind-value=“CurrentValue” @bind-value:event=“oninput”/>
这里@attributes="AdditionalAttributes“ 中的AdditionalAttributes是内置组件Input定义的一个字典参数,用来自动捕获父组件传递的未被明确定义为组件参数的所有额外属性 ,我们把这个玩意赋值给自定义的输入组件,就实现了自定义输入组件对父组件传递的未被明确定义为组件参数的所有额外属性的捕捉。当父组件使用CustomInput时,任何未在CustomInput中通过[Parameter]显式声明的属性(如type、placeholder等)会被自动收集到AdditionalAttributes字典中
然后input类组件肯定是要绑定一个值,CurrentValue,然后绑定到oninput事件,输入框内容每次变化(如用户输入或删除字符),模型值会立即更新而不是普通的输入完焦点离开控件才触发生效
到代码区,
[CascadingParameter]
private EditContext EditContext { set; get; }
protected override void OnParametersSet()
{
base.OnParametersSet();
EditContext?.NotifyFieldChanged(FieldIdentifier);
}
EditContext ,这个东西很重要,它会 EditContext跟踪表单中所有字段的值、修改状态(如是否被编辑过)以及验证结果;通过[CascadingParameter] 从父组件(如EditForm)自动传递给子组件,确保整个表单共享同一上下文;当字段值变化或表单提交时,EditContext触发验证逻辑(如数据注解验证),并更新验证消息;
组件重写了OnParametersSet方法,如果触发参数设置了之后,会触发UI更新,然后FieldIdentifier通知上层的验证器,验证其某个字段的值变化了,需要触发验证。
自定义验证逻辑容器CustomValidation
@using Microsoft.AspNetCore.Components.Forms
<CascadingValue Value="this">
@ChildContent
</CascadingValue>
@code {
[Parameter]
public RenderFragment ChildContent { get; set; }
[CascadingParameter]
private EditContext EditContext { get; set; }
[Parameter] // 新增:接收外部传入的模型实例
public object Model { get; set; }
private ValidationMessageStore _messageStore;
protected override void OnInitialized()
{
if (EditContext == null)
throw new InvalidOperationException("CustomValidation 必须嵌套在 EditForm 中");
// 意味着_messageStore与当前表单的EditContext绑定,直接操作其验证状态
_messageStore = new ValidationMessageStore(EditContext);
EditContext.OnValidationRequested += OnValidationRequested;
}
private void OnValidationRequested(object sender, ValidationRequestedEventArgs e)
{
// 确保在验证前调用_messageStore.Clear(),避免旧错误残留
_messageStore.Clear();
}
// 修改:使用传入的 Model 创建 FieldIdentifier
public void DisplayError(string fieldName, string message)
{
//这里对应的就是数据模型那里的数据验证字段的字段名字和验证内容
var fieldIdentifier = new FieldIdentifier(Model, fieldName);
_messageStore.Add(fieldIdentifier, message);
EditContext.NotifyValidationStateChanged();
}
public void Dispose()
{
// 在组件销毁时(Dispose方法中)取消事件订阅,防止内存泄漏
EditContext.OnValidationRequested -= OnValidationRequested;
}
}
自定义验证逻辑容器,就是主界面中用来管理所有数据字段的验证逻辑的一个容器。
<CascadingValue Value="this">
@ChildContent
</CascadingValue>
首先,代码将当前组件实例(CustomValidation自身)作为级联值传递给所有子组件,this指针就是指代容器本身,ChildContent就是接收这个容器内所有子组件用的。子组件(如输入控件或验证消息组件)可以通过[CascadingParameter]获取到CustomValidation实例,直接调用其公共方法(如DisplayError)或访问属性。用级联参数标签就可以让所有子组件都可以获得到当前的验证逻辑容器。
[CascadingParameter]
private EditContext EditContext { get; set; }
[Parameter]
public object Model { get; set; }
private ValidationMessageStore _messageStore
EditContext 用来接收上一级传递进来的表单中所有字段的值、修改状态(如是否被编辑过)以及验证结果;Model用来接收验证的数据模型。
_messageStore的作用是集中管理自定义验证错误消息,并与Blazor的表单验证系统集成;保存通过代码手动添加的验证错误信息(如业务逻辑错误、异步验证结果等),与数据注解(如[Required])生成的错误分开管理;他的内部是按FieldIdentifier(字段标识,由模型对象和属性名组成)分类存储错误消息列表,支持多字段、多错误的管理
由于本身框架自己也有内部的验证;_messageStore仅做自定义的误,数据注解验证的错误由Blazor自动处理,二者互不冲突,不影响数据注解(如[Required])自动生成的错误;同一字段可以同时存在数据注解错误和自定义错误
所有的错误(无论是数据注解还是自定义错误)最终通过ValidationMessage或ValidationSummary组件显示
OnInitialized方法中,_messageStore与当前表单的EditContext绑定,直接操作其验证状态。然后绑定OnValidationRequested,每次验证之前就可以调用下清理上一次验证的函数接口,避免错误残留。然后每次组件销毁的时候还需要调用取消事件订阅的函数Dispose,防止内存泄漏。
DisplayError方法是用于当上层表单提交数据的时候,发现错误了,用于显示的接口,这里需要传入字段的名称,显示的信息,然后添加到ValidationMessageStore 中,触发更新。
主界面EmailRegister
@rendermode InteractiveServer
@page "/EmailRegister"
<EditForm Model="_model" OnValidSubmit="HandleSubmit">
<CustomValidation @ref="_customValidation" Model="_model" />
@* 自定义验证逻辑容器,扩展内置验证功能 *@
<div class="form-group">
<label>邮箱</label>
<CustomInput @bind-Value="_model.Email" />
<ValidationMessage For="@(() => _model.Email)" />
</div>
<div class="form-group">
<label>密码</label>
<CustomInput @bind-Value="_model.Password" type="password" />
@* 在EmailRegister.razor中,通过<CustomValidation @ref="_customValidation">引用父组件,并在提交时调用其方法 *@
<ValidationMessage For="@(() => _model.Password)" />
</div>
<div class="form-group">
<label>确认密码</label>
<CustomInput @bind-Value="_model.ConfirmPassword" type="password" />
<ValidationMessage For="@(() => _model.ConfirmPassword)" />
</div>
<button type="submit" disabled="@_isSubmitting">
@(_isSubmitting ? "提交中..." : "立即注册")
</button>
<ValidationSummary />
</EditForm>
@code {
private RegisterModel _model = new();
private CustomValidation _customValidation;
private bool _isSubmitting;
private async Task HandleSubmit()
{
_isSubmitting = true;
// 执行自定义验证
var validationResult = await ValidateAsync();
if (validationResult.IsValid)
{
// 模拟提交成功,打印到控制台
Console.WriteLine("提交成功!");
Console.WriteLine($"邮箱: {_model.Email}");
Console.WriteLine($"密码: {_model.Password}");
// 可选:清空表单
_model = new RegisterModel();
}
_isSubmitting = false;
}
private async Task<ValidationResult> ValidateAsync()
{
var validationResult = new ValidationResult();
// 密码一致性验证
if (_model.Password != _model.ConfirmPassword)
{
validationResult.Errors.Add("两次输入的密码不一致");
// _messageStore会记录此错误,并通过ValidationMessage显示在ConfirmPassword输入框旁
_customValidation?.DisplayError(nameof(_model.ConfirmPassword), "密码不一致");
}
// 模拟异步邮箱验证(硬编码返回 false 表示邮箱可用)
await Task.Delay(200); // 模拟网络延迟
bool isEmailRegistered = false;
if (isEmailRegistered)
{
validationResult.Errors.Add("该邮箱已被注册");
_customValidation?.DisplayError(nameof(_model.Email), "邮箱已存在");
}
return validationResult;
}
public class ValidationResult
{
public bool IsValid => Errors.Count == 0;
public List<string> Errors { get; } = new();
}
}
主界面这边就是很常规的把涉及到的自定义输入组件,自定义验证逻辑容器放置好。
自定义验证逻辑容器需要传入自定义验证容易类和对应这个数据的模型。
ValidateAsync是一个异步函数,就是自定义验证的一段代码,密码不一样就会触发错误显示,这里它还做了一个ValidationResult类来管理需要显示的错误和对应的显示。
例子4
最后一个例子,我们看一个颜色调色盘的例子

输入的颜色代码自动转换到颜色窗,输入错了,有提示

颜色调色盘也可以输入,并且联动文本输入框

数据模型ColorModel
public class ColorModel
{
[Required(ErrorMessage ="必须选择颜色")]
public string SelectedColor { get; set; } = "#ff0000";
}
输入组件ColorInput
@using System.Diagnostics.CodeAnalysis
@* 继承自这个泛型输入组件 *@
@inherits InputBase<string>
@* 颜色选择器部分 *@
<input type="color"
value="@CurrentValue"
@oninput="HandleColorInput"
class="@CssClass"
id="@Id" />
<!-- 允许用户手动输入颜色代码 -->
<input type="text"
value="@CurrentValue"
@oninput="HandleTextInput"
class="@CssClass" />
@* 颜色代码显示部分 *@
<span class="color-value">@CurrentValue</span>
@* 验证消息部分 *@
<ValidationMessage For="@(()=>CurrentValue)"/>
@code {
[Parameter]
public string Id{ set; get; }
//处理颜色输入的函数
private void HandleColorInput(ChangeEventArgs e)
{
CurrentValueAsString = e.Value?.ToString();
}
//处理颜色文本输入的部分
private void HandleTextInput(ChangeEventArgs e)
{
CurrentValueAsString = e.Value?.ToString();
}
/// <summary>
/// 重写输入值校验的部分
/// </summary>
/// <param name="value"></param>
/// <param name="result"></param>
/// <param name="validationErrorMessage"></param>
/// <returns></returns>
protected override bool TryParseValueFromString(string value, [MaybeNullWhen(false)] out string result, [NotNullWhen(false)] out string validationErrorMessage)
{
if (string.IsNullOrEmpty(value) || !IsValidHexColor(value))
{
result = "#000000";
validationErrorMessage = "颜色格式无效,请使用 #RRGGBB 格式。";
return false;
}
result = value;
validationErrorMessage = null;
return true;
}
/// <summary>
/// 校验十六进制值
/// </summary>
/// <param name="color"></param>
/// <returns></returns>
private bool IsValidHexColor(string color)
{
if(string.IsNullOrWhiteSpace(color))
{
return false;
}
return System.Text.RegularExpressions.Regex.IsMatch(
color,
"^#([A-Fa-f0-9]{3}|[A-Fa-f0-9]{6})$"
);
}
}
由于这个也是一个自定义的输入组件,所以继承了InputBase,然后限定了输入参数是字符串
然后构建颜色调色盘,颜色代码输入窗,颜色代码显示的HTML部分
最后带一个验证消息的提示功能
它这里把文本输入颜色代码的验证HandleTextInput 和颜色调色盘的输入验证HandleColorInput分开做
然后重写了继承的InputBase类中的TryParseValueFromString,这个是输入颜色代码值的校验,里面就是必要的文本校验了
主界面ColorPage
@rendermode InteractiveServer
@page "/color-picker"
@using System.ComponentModel.DataAnnotations
@* 表达结构 *@
<EditForm Model="@Model" OnValidSubmit="HandleValidSubmit">
<DataAnnotationsValidator/>
@* 启用基于数据注解(Data Annotations)的验证。 *@
<ValidationSummary/>
@* ValidationMessage:显示单个字段的错误信息 *@
@* ValidationSummary:汇总所有验证错误 *@
<ColorInput @bind-Value="Model.SelectedColor" Id="colorPicker" />
<button type="submit">提交</button>
</EditForm>
@code {
private ColorModel Model { get; set; } = new ColorModel();
/// <summary>
/// 提交的时候的处理函数
/// </summary>
private void HandleValidSubmit()
{
Console.WriteLine($"选中的颜色: {Model.SelectedColor}");
}
}
主界面比较简单就不过多说了,就是把数据和组件做了双向绑定,然后把表单那套东西整上。
Blazor的组件库

Blazor下的.NET和JS互操作
这部分分为两个部分
- JS调用.NET的方法
- .NET调用JS的方法
JS调用.NET的方法
在Blazor框架下,如果要让一个方法可以被JS调用,有两种方式
- 静态方法方式
- 常规方法方式
先说静态方法方式
就是在static的C#类和static的方法上标记[JSInvokable]
譬如
using Microsoft.JSInterop;
namespace LearnBlazor.Components.JS_Invokable
{
public static class MyJsInteropHelper
{
//必须标记在静态方法上,否则 JavaScript 无法发现该方法
[JSInvokable]
public static string ToUpperCase(string input)
{
return input.ToUpper();
}
[JSInvokable]
public static string ToLowerCase(string input) {
return input.ToLower();
}
[JSInvokable]
public static Task<int> AddAsync(int a,int b)
{
return Task.FromResult(a + b);
}
[JSInvokable]
public static int Add(object num1Obj, object num2Obj)
{
//.NET 后端校验:
//防止绕过前端校验的非法调用。
//确保核心业务逻辑的输入可靠性。
// 防御性编程:检查参数是否为 int
if (!(num1Obj is int num1) || !(num2Obj is int num2))
{
throw new ArgumentException("参数必须为整数类型!");
}
return num1 + num2;
}
}
}
这样标记之后,框架就可以识别出来了
在调用端:
function callDotNetStaticMethod() {
// 调用 .NET 静态方法 ToUpperCase
// 注意这里第一个参数要写你的项目名!!不是类名
//然后是方法名字,然后是参数名字
//DotNet.invokeMethodAsync:
//第一个参数是程序集名称(项目名 JsInteropDemo)。
//第二个参数是方法名(ToUpperCase 或 AddAsync)。
//后续参数是传递给方法的参数(支持基本类型、对象、数组)。
//直接调用DotNet.invokeMethodAsync
DotNet.invokeMethodAsync('LearnBlazor', 'ToUpperCase', 'hello from javascript')
.then(result => {
alert('ToUpperCase 结果: ' + result);
})
.catch(error => {
console.error(error);
});
// 调用 .NET 静态异步方法 AddAsync
DotNet.invokeMethodAsync('LearnBlazor', 'AddAsync', 111, 5)
.then(result => {
alert('AddAsync 结果: ' + result);
});
}
DotNet是框架下的一个对象,直接用就完了,然后按照invokeMethodAsync方法的提示填写参数,然后它也能支持.NET异常的捕捉,捕捉就像上面这样写。
然后还有一个步骤,需要在工程的App.razor下引入所需要使用的JS
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<base href="/" />
<link rel="stylesheet" href="bootstrap/bootstrap.min.css" />
<link rel="stylesheet" href="app.css" />
<link rel="stylesheet" href="LearnBlazor.styles.css" />
<link rel="icon" type="image/png" href="favicon.png" />
<script src="js/interop.js"></script> <!-- 引入自定义的 JS 文件 -->
<script src="js/callDotNetStaticMethod.js"></script> <!-- 引入自定义的 JS 文件 -->
<script src="js/callDotNetInstanceMethod.js"></script> <!-- 引入自定义的 JS 文件 -->
<script src="js/ComplexObjProcess.js"></script> <!-- 引入自定义的 JS 文件 -->
<script src="js/GetWindowSize.js"></script> <!-- 引入自定义的 JS 文件 -->
<script src="js/setDocumentTitle.js"></script> <!-- 引入自定义的 JS 文件 -->
<script src="js/canvas-interop.js"></script> <!-- 引入自定义的 JS 文件 -->
<HeadOutlet />
</head>
<body>
<Routes />
<script src="_framework/blazor.web.js"></script>
</body>
</html>
然后再说下常规方法
让C#能够被JS发现的那个步骤一样,加[JSInvokable],然后要注意的是,记得实现 IDisposable 释放资源
public class MyDotNetHelper
{
private int _counter = 0;
[JSInvokable]
public Task<string> IncrementCounterAsync(string prefix)
{
_counter++;
return Task.FromResult($"{prefix}:{_counter}");
}
[JSInvokable]
public Task ThrowErrorAsync()
{
throw new InvalidOperationException("模拟net异常");
}
// 实现 IDisposable 释放资源
public void Dispose()
{
GC.SuppressFinalize(this);
}
[JSInvokable]
public static Task<MyPerson> ProcessPersonAsync(MyPerson person)
{
// 修改并返回对象
person.Age += 1;
person.FullName = $"{person.FirstName} {person.LastName}";
return Task.FromResult(person);
}
}
到JS这边,由于是普通的实例方法,这边的写法略有不同
function callDotNetInstanceMethod(dotNetObjRef) {
// 调用实例方法
dotNetObjRef.invokeMethodAsync('IncrementCounterAsync', '计数器')
.then(result => {
alert(result); // 输出 "计数器: 1", "计数器: 2"...
return result; // 传递回 Blazor 的 result 变量
})
.catch(error => {
console.error('实例方法调用失败:', error);
});
}
function callDotNetSimException(dotNetObjRef) {
///需要将 .NET 对象实例包装为 DotNetObjectReference,传递给 JavaScript
dotNetObjRef.invokeMethodAsync('ThrowErrorAsync')
.then(() => { /* 成功逻辑 */ })
.catch(error => {
console.error('捕获到 .NET 异常:', error.message); // 输出 "这是一个 .NET 异常"
});
}
要传入对应的.NET对象进去来调用对应的方法出来,当然也是支持捕捉.NET的异常
.NET调用JS的方法
调用JS相关的东西需要引入IJSRuntime对象,这个是下一讲要涉及到的依赖注入,目前就知道它可以实现JS与.NET的互操作就可以了。
例子1
直接调用一个js的函数
譬如这里我们调用前面写的JS里出现的callDotNetStaticMethod函数
在C#代码区直接写就完了,然后通过一个button来触发这个函数,这里JSRuntime就是通过依赖注入引入的IJSRuntime对象
private async Task CallJavaScript()
{
// 调用 JavaScript 函数,这里写的是函数名
await JSRuntime.InvokeVoidAsync("callDotNetStaticMethod");
}
例子2
调用一个需要传入实例的方法
private MyDotNetHelper DotNetHelper;
private string ressss;
private async Task CallInstanceMethod()
{
// 将实例包装为 DotNetObjectReference
var obj = DotNetObjectReference.Create(DotNetHelper);
//调用JS函数
ressss = await JSRuntime.InvokeAsync<string>("callDotNetInstanceMethod", obj);
}
这里传入了前面我们定义的一个类对象DotNetHelper,通过DotNetObjectReference进行转换,可以让JS认得,然后传到JS那边,然后这里还涉及到有返回值的限定,这边返回一个string
function callDotNetInstanceMethod(dotNetObjRef) {
// 调用实例方法
dotNetObjRef.invokeMethodAsync('IncrementCounterAsync', '计数器')
.then(result => {
alert(result); // 输出 "计数器: 1", "计数器: 2"...
return result; // 传递回 Blazor 的 result 变量
})
.catch(error => {
console.error('实例方法调用失败:', error);
});
}
例子3
涉及到有异常
private MyDotNetHelper DotNetHelper;
private string ressss;
private async Task CallExceptionMethod()
{
// 将实例包装为 DotNetObjectReference
var obj = DotNetObjectReference.Create(DotNetHelper);
//调用JS函数
ressss = await JSRuntime.InvokeAsync<string>("callDotNetSimException", obj);
}
function callDotNetSimException(dotNetObjRef) {
///需要将 .NET 对象实例包装为 DotNetObjectReference,传递给 JavaScript
dotNetObjRef.invokeMethodAsync('ThrowErrorAsync')
.then(() => { /* 成功逻辑 */ })
.catch(error => {
console.error('捕获到 .NET 异常:', error.message); // 输出 "这是一个 .NET 异常"
});
}
例子4
调用JS方法的时候传入一个类对象作为参数
public class MyDotNetHelper
{
// 实现 IDisposable 释放资源
public void Dispose()
{
GC.SuppressFinalize(this);
}
[JSInvokable]
public static Task<MyPerson> ProcessPersonAsync(MyPerson person)
{
// 修改并返回对象
person.Age += 1;
person.FullName = $"{person.FirstName} {person.LastName}";
return Task.FromResult(person);
}
}
public class MyPerson
{
public string FirstName { get; set; }
public string LastName { get; set; }
public int Age { get; set; }
public string FullName { get; set; }
}
调用者
private async Task CallComplexMethod()
{
// 将实例包装为 DotNetObjectReference
var obj = DotNetObjectReference.Create(DotNetHelper);
//调用JS函数
await JSRuntime.InvokeAsync<string>("ComplexObjProcess", obj);
}
JS函数
// 调用 ProcessPersonAsync
const person = {
firstName: "John",
lastName: "Doe",
age: 30
};
function ComplexObjProcess() {
DotNet.invokeMethodAsync('LearnBlazor', 'ProcessPersonAsync', person)
.then(processedPerson => {
console.log('处理后的对象:', processedPerson);
// 输出: { fullName: "John Doe", age: 31, ... }
});
}
这里JS侧创建了一个对象然后,传递给了.NET方法,方法给处理完,然后返回给JS
复杂的JS和.NET互操作的例子
例子1-Canvas

运行之后是这样的效果,这个是JS 画的
先看.NET这边
@page "/canvas-demo"
@rendermode InteractiveServer
@inject IJSRuntime JSRuntime;
<h3>CanvasComponent</h3>
@* 在 Blazor 组件中,通过 @ref 指令捕获 HTML 元素的引用,类型为 ElementReference *@
<canvas @ref="MyCanvas" width="200" height="200" ></canvas>
@code {
private ElementReference MyCanvas;//获取Canvas元素引用
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if(firstRender)
{
//确保在元素渲染完成后调用 JavaScript,避免操作未加载的元素
await JSRuntime.InvokeVoidAsync("initCanvas", MyCanvas);
}
}
}
这里引入了一个叫做ElementReference 的对象,这个是用来获取HTML元素引用的东西,类似于指针,你看那里是勇敢一个ref标记得到的.
这里例子重写了OnAfterRenderAsync方法
确保确保在元素渲染完成后调用 JavaScript,避免操作未加载的元素
然后调用JS开始画图
function initCanvas(canvasElement) {
const ctx = canvasElement.getContext('2d');
// 绘制红色矩形
ctx.fillStyle = 'red';
ctx.fillRect(20, 20, 150, 100);
// 绘制绿色圆形
ctx.beginPath();
ctx.arc(100, 100, 30, 0, 2 * Math.PI);
ctx.fillStyle = 'green';
ctx.fill();
}
JS这边传入了一个对象,这个对象就是C#那边获取的引用的那个Canvas
例子2-获取DOM元素的尺寸
@page "/ElementSizeDemo"
@inject IJSRuntime JSRuntime
@rendermode InteractiveServer
<h3>获取 DOM 元素尺寸示例</h3>
<div id="myElement" class="demo-box">
这是一个示例元素,点击按钮获取我的尺寸。
</div>
<button @onclick="GetElementSize">获取尺寸</button>
@if (_dimensions != null)
{
<p>宽度: @_dimensions.Width px, 高度: @_dimensions.Height px</p>
}
<style>
.demo-box {
width: 300px;
height: 150px;
border: 2px solid #007bff;
padding: 20px;
margin: 20px 0;
}
</style>
@code {
private Dimensions _dimensions;
private async Task GetElementSize()
{
try
{
_dimensions = await JSRuntime.InvokeAsync<Dimensions>("getElementSize", "myElement");
StateHasChanged(); // 更新 UI 显示结果
}
catch (Exception)
{
throw;
}
}
public class Dimensions
{
public int Width { get; set; }
public int Height { get; set; }
}
}
这里是通过.NET调用JS的方法,根据元素的ID来查询元素的尺寸,然后返回到C#侧显示
window.getElementSize = (elementId) => {
const element = document.getElementById(elementId);
if (!element) {
throw new Error(`Element with ID '${elementId}' not found.`);
}
return {
width: element.offsetWidth,
height: element.offsetHeight
};
};
例子3-控制标签值的显示和停止
<h3>TitleUpdater</h3>
@page "/TitleUpdater"
@inject IJSRuntime JSRuntime
@rendermode InteractiveServer
<button @onclick="SetStaticTitle">设置为静态标题</button>
<button @onclick="StartDynamicTitle">启动动态标题</button>
<button @onclick="StopDynamicTitle">停止动态标题</button>
<button @onclick="SetTitleWithSubtitle">带副标题</button>
@code {
private Timer _timer;
private bool _isDynamicTitleActive = false;
private async Task SetStaticTitle()
{
await JSRuntime.InvokeVoidAsync("setDocumentTitle","欢迎使用Blazor");
}
private async Task StartDynamicTitle()
{
_isDynamicTitleActive = true;
_timer?.Dispose();
_timer = new Timer(async _ => {
if(_isDynamicTitleActive)
{
var time = DateTime.Now.ToString("HH:mm:ss");
await InvokeAsync( async()=> {
await JSRuntime.InvokeVoidAsync("setDocumentTitle", $"动态时间: {time}");
});
}
},null,0,1000);
}
private async Task StopDynamicTitle()
{
_isDynamicTitleActive = false;
await JSRuntime.InvokeVoidAsync("setDocumentTitle", "已停止");
}
//必须要清理定时器
public void Dispose()
{
_isDynamicTitleActive = false;
_timer?.Dispose();
GC.SuppressFinalize(this);
}
private async Task SetTitleWithSubtitle()
{
await JSRuntime.InvokeVoidAsync("setDocumentTitle", "主页 - 我的应用");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
await JSRuntime.InvokeVoidAsync("setDocumentTitle", "初始标题");
}
}
}
这个例子就是花式调用JS了,代码都比较好懂
这里比较牛逼的是,这里还整了个定时器,这个要比较小心,定时器是引入一定要注意资源释放,写Dispose
总的说就是写这种互操作的代码要非常消息,让我们看看AI怎么说


Blazor下的DI依赖注入
最后一章了
依赖注入DI是一种设计模式,允许将服务的创建和生命周期管理交给框架,而非手动在代码中实例化。
目标:解耦组件与具体实现,提高代码可维护性和可测试性。
框架内置了 DI 容器,支持自动解析和注入依赖项。
依赖注入后存在一个生命周期的概念,就是他的存在范围,总共3种
- Singleton:全局单例(整个应用共享一个实例)。
- Scoped:作用域单例(在 Blazor WebAssembly 中等同于 Singleton;在 Blazor Server 中,每个用户会话一个实例)。
- Transient:瞬态(每次请求创建一个新实例)。
如何注册依赖
在program.cs种通过builder.Services.AddXxx() 注册服务,譬如我这里注册了一些服务

依赖要使用之前一定要注册,不然依赖注入会报异常
如何将服务注入到希望的地方呢
- 通过构造函数注入,直接在组件的构造函数中声明依赖
- 通过 [Inject] 特性标记属性
- 通过@inject注入相关依赖到组件
通过构造函数注入,直接在组件的构造函数中声明依赖
public class MyComponent : ComponentBase
{
private readonly IMyService _myService;
public MyComponent(IMyService myService)
{
_myService = myService;
}
}
通过 [Inject] 特性标记属性
[Inject]
private IMyService MyService { get; set; }
通过@inject注入相关依赖到组件
@page "/user-profile"
@inject IUserService UserService
框架本身是已经带了一些基本的服务,直接注入就可以
- NavigationManager:管理页面导航(如 NavigateTo())。
- IJSRuntime:JavaScript 互操作。
- HttpClient:发送 HTTP 请求(仅在 WebAssembly 中自动注入)
依赖注入综合性的一个例子
这个例子主要是讲解了下如何自定义服务,如何将服务注册,如何注入为依赖,如何同时管理多个依赖.
namespace LearnBlazor.Components.OwingComponentBaseEx
{
/// <summary>
/// 服务接口
/// </summary>
public interface ICounterService
{
int CurrentCount { get; }
void Increment();
}
//第二个服务接口
public interface ILoggerService
{
int LogCount { get; }
void Log(string message);
}
/// <summary>
/// 第二个服务的实现
/// </summary>
public class LoggerService: ILoggerService, IDisposable
{
public int LogCount { get; private set; }
public void Log(string message)
{
LogCount++;
Console.WriteLine(message);
}
public void Dispose() => Console.WriteLine("日志服务已释放");
}
/// <summary>
/// 服务实现
/// </summary>
public class CounterService:ICounterService,IDisposable
{
public int CurrentCount { get; private set; }
public void Increment()
{
CurrentCount++;
Console.WriteLine($"计数器: {CurrentCount}");
}
public void Dispose()
{
Console.WriteLine("计数器服务释放");
}
}
}
这里我们定义了两个服务,分别是计数器和日志
先定义接口,然后实现对应接口的服务,注意实现的时候需要把Dispose也实现了,这样资源释放就有保障了,不会后期有内存泄漏的风险。。
然后我们使用计数器这个服务,为了演示其独立性,我们定义两个服务A和B
这里我们Razor 组件继承了OwningComponentBase,这样这个组件就具备管理这个服务的依赖注入的能力了
当你的组件继承自 OwningComponentBase,基类会自动完成以下操作:
创建独立的 DI 作用域:为组件生成一个子依赖注入容器(作用域)。
解析服务实例:在该作用域内解析泛型参数 T(如 ICounterService),并将实例赋值给 Service 属性。
管理生命周期:组件销毁时,自动释放该作用域及其内的服务(如果服务实现了 IDisposable)。
因此,Service 是基类提供的直接访问服务实例的属性,无需手动注入。
然后因为继承了它,就可以直接调用对应的服务了,会多了一个Service对象,其实就是注入的依赖,然后就可以调用你自己写的对应的服务了。
并且还需要在Program.cs下注册我们自己写的服务

A
<h3>IndependentCounterA</h3>
@rendermode InteractiveServer
@inherits OwningComponentBase<ICounterService>
<h3>计数器</h3>
<p>当前值: @Service.CurrentCount</p>
<button @onclick="Increment">增加</button>
@code {
private void Increment()
{
Service.Increment();//直接使用Service属性
StateHasChanged();
}
}
B
<h3>IndependentCounterB</h3>
@rendermode InteractiveServer
@inherits OwningComponentBase<ICounterService>
<h3>计数器 B</h3>
<p>当前值: @Service.CurrentCount</p>
<button @onclick="Increment">增加</button>
@code {
private void Increment()
{
Service.Increment(); // 独立于 A 的服务实例
StateHasChanged();
}
}
实际运行的效果就是这两个计数器是独立的。
然后接下来是在同一个Razor组件里面管理两个依赖服务
@inherits OwningComponentBase
@rendermode InteractiveServer
<h3>多个服务注入</h3>
<p>计数器: @CounterService.CurrentCount</p>
<button @onclick="IncrementAndLog">点我增加</button>
@code {
// 定义需要使用的服务
private ICounterService CounterService;
private ILoggerService LoggerService;
protected override void OnInitialized()
{
CounterService = ScopedServices.GetRequiredService<ICounterService>();
LoggerService = ScopedServices.GetRequiredService<ILoggerService>();
}
public void IncrementAndLog()
{
CounterService.Increment();
LoggerService.Log($"计数器增加到 {CounterService.CurrentCount}");
StateHasChanged();
}
}
这里用的方式就是定义了对应服务的对象然后通过ScopedServices.GetRequiredService返回出对应的服务出来进行指定服务的调用的.
效果就是两个服务是互相独立运行的。
如果不想这样写,还有另外一种方式,定义一个具备多个泛型参数的OwningComponentBaseMulT,它继承OwningComponentBase。
using Microsoft.AspNetCore.Components;
namespace LearnBlazor.Components.OwingComponentBaseEx
{
/// <summary>
/// 定义多个泛型OwningComponentBase,添加多个泛型参数
/// </summary>
/// <typeparam name="T1"></typeparam>
/// <typeparam name="T2"></typeparam>
public class OwningComponentBaseMulT<T1,T2>:OwningComponentBase
{
private T1 _s1;
private T2 _s2;
protected T1 S1=>_s1??=ScopedServices.GetRequiredService<T1>();
protected T2 S2 => _s2 ??= ScopedServices.GetRequiredService<T2>();
}
}
这个就比较巧妙了,相当于把前面那种写法做了一个封装
用的时候就很简洁了
@inherits OwningComponentBaseMulT<ICounterService,ILoggerService>
@rendermode InteractiveServer
<h3>多个服务注入</h3>
<p>计数器: @S1.CurrentCount</p>
<button @onclick="IncrementAndLog">点我增加</button>
@code {
public void IncrementAndLog()
{
S1.Increment();
S2.Log($"计数器增加到 {S1.CurrentCount}");
StateHasChanged();
}
}
大概依赖注入的内容就是这些了。
再放点AI说的一些内容结尾把




结语
花了大概两个月这样把Blazor的基础内容算是全部过了一遍,没AI我觉得学这个估计会花更长的时间.
希望2025年可以把winform开始逐渐替代成Blazor这种开发模式


3283

被折叠的 条评论
为什么被折叠?



