一、Razor组件语法:.razor文件的结构
Blazor应用的基本构建单元是Razor组件 ,每个组件对应一个.razor文件。如果你曾经接触过React的单文件组件或Vue的.vue文件,你会对这种设计感到亲切------组件将UI模板、样式和逻辑封装到同一个文件中,形成自包含的代码单元。
一个典型的.razor文件由三个部分组成:指令区(Directives) 、HTML模板区 和代码块(@code)。我们来看一个完整的示例:
csharp
@* 文件:Components/Pages/ProductCard.razor *@
@* 1. 指令区:声明路由、使用命名空间、注入服务 *@
@page "/products/{Id:int}"
@using MyApp.Models
@inject IProductService ProductService
@* 2. HTML模板区:描述UI结构,可以嵌入C#表达式 *@
<div class="product-card">
@if (product is null)
{
<p>正在加载...</p>
}
else
{
<h2>@product.Name</h2>
<p class="price">¥@product.Price.ToString("F2")</p>
<button @onclick="AddToCart" disabled="@isAdding">
@(isAdding ? "添加中..." : "加入购物车")
</button>
}
</div>
@* 3. 代码块:组件的C#逻辑 *@
@code {
[Parameter]
public int Id { get; set; }
private Product? product;
private bool isAdding = false;
protected override async Task OnParametersSetAsync()
{
product = await ProductService.GetByIdAsync(Id);
}
private async Task AddToCart()
{
isAdding = true;
await ProductService.AddToCartAsync(product!.Id);
isAdding = false;
}
}
指令区通常位于文件顶部,以@符号开头。@page声明路由模板,组件只有带@page才能作为可访问的页面;@using引入命名空间,与C#文件中的using语句效果相同;@inject从依赖注入容器中获取服务实例,等同于在构造函数中接收注入。如果每个组件都要重复写相同的@using语句会很繁琐,解决方案是在_Imports.razor文件中集中声明------Components目录下的_Imports.razor中的所有指令会自动应用到该目录及其所有子目录下的组件,相当于全局隐式using。
HTML模板区是组件的视觉骨架,使用标准HTML语法,通过@前缀嵌入C#表达式。单个表达式直接写@变量名或@方法名();多行语句或控制结构使用@{ ... }代码块;条件渲染使用@if/@else;列表渲染使用@foreach。这些语法我们在后续数据绑定章节会详细展开。
@code { ... }代码块包含了整个组件的C#逻辑------字段、属性、方法以及生命周期钩子都写在这里。对于逻辑较复杂的组件,可以采用"代码后置(code-behind) "模式,创建一个同名的.razor.cs文件,在其中定义一个继承自ComponentBase的分部类(partial class),将代码逻辑从.razor模板中分离,使两个文件各司其职:
csharp
// ProductCard.razor.cs
public partial class ProductCard
{
[Parameter]
public int Id { get; set; }
private Product? product;
// ...其余逻辑
}
二、组件的生命周期
每个Blazor组件从被创建到销毁,都经历一个确定的生命周期,框架在各个阶段提供了可被重写的钩子方法。理解生命周期的顺序,是正确处理数据加载、副作用和资源清理的前提。
组件诞生时,框架首先调用构造函数 (通常是隐式的),紧接着是**SetParametersAsync**------这是参数注入的总入口,框架将父组件传入的参数值填充到标记了[Parameter]的属性上。绝大多数情况下不需要重写此方法,直接交给基类处理即可。参数设置完毕后,OnInitialized和OnInitializedAsync被调用,这个钩子在组件整个生命周期内只执行一次,非常适合做一次性初始化工作,比如在页面首次加载时拉取数据:
csharp
@code {
private List<Article> articles = [];
// 同步版本(适合不需要异步操作的初始化)
protected override void OnInitialized()
{
// 同步初始化逻辑,例如读取缓存
}
// 异步版本(适合需要等待I/O的场景,如调用API)
// 框架会先渲染一次(显示加载中状态),异步完成后再次渲染
protected override async Task OnInitializedAsync()
{
articles = await ArticleService.GetAllAsync();
}
}
每当父组件传入的参数发生变化,框架会重新调用OnParametersSet和OnParametersSetAsync。这与OnInitialized的区别在于------后者只在首次创建时运行,前者在每次参数变化时都会运行。上文ProductCard示例中就利用了OnParametersSetAsync,确保路由参数Id发生变化时(用户从一个产品页导航到另一个产品页),能够重新加载对应的产品数据。
组件完成渲染并将UI提交给DOM之后,OnAfterRender(bool firstRender)被调用。firstRender参数标记这是否是首次渲染,利用这个标志可以避免在每次重渲染时重复执行操作。这个钩子适合需要访问真实DOM元素或调用JavaScript互操作的场景------在组件渲染之前调用JS通常是无效的,因为被操作的DOM节点可能还不存在:
csharp
@code {
[Inject]
private IJSRuntime JS { get; set; } = default!;
protected override async Task OnAfterRenderAsync(bool firstRender)
{
if (firstRender)
{
// 仅在首次渲染后执行,初始化需要依赖真实DOM的JS插件
await JS.InvokeVoidAsync("initializeChart", "myChart");
}
}
}
当组件被从UI树中移除时,如果组件实现了IDisposable(或IAsyncDisposable)接口,Dispose方法会被调用。应该在这里释放订阅的事件、取消定时器或清理非托管资源,否则可能导致内存泄漏:
csharp
@implements IDisposable
@code {
private System.Threading.Timer? _timer;
protected override void OnInitialized()
{
// 创建一个每秒触发一次的定时器
_timer = new System.Threading.Timer(_ =>
{
// 在Timer回调中,需要通过InvokeAsync确保在Blazor同步上下文中更新UI
InvokeAsync(StateHasChanged);
}, null, TimeSpan.Zero, TimeSpan.FromSeconds(1));
}
public void Dispose()
{
// 组件销毁时停止定时器,避免泄漏
_timer?.Dispose();
}
}
三、参数传递与事件处理
组件之间的通信是组合型UI的核心机制。在Blazor中,父组件通过参数(Parameter)向子组件传递数据,子组件通过EventCallback向父组件回传事件通知,形成单向数据流。
在子组件中,将一个公开属性标记[Parameter]特性,它就成为一个可从父组件接收数据的参数:
csharp
@* 子组件:Components/Shared/AlertBox.razor *@
<div class="alert alert-@Type">
<strong>@Title</strong>
<p>@Message</p>
<button @onclick="OnCloseClicked">关闭</button>
</div>
@code {
// 基础参数:接收字符串和枚举类型
[Parameter]
public string Title { get; set; } = string.Empty;
[Parameter]
public string Message { get; set; } = string.Empty;
// 当参数是枚举等有限集合时,可提供更强的类型约束
[Parameter]
public string Type { get; set; } = "info"; // "info" | "warning" | "danger"
// EventCallback用于向父组件通知事件
// EventCallback<T>可以携带数据,EventCallback不携带数据
[Parameter]
public EventCallback OnClose { get; set; }
private async Task OnCloseClicked()
{
// 触发父组件传入的回调,如果父组件没有传入,InvokeAsync是安全的空操作
await OnClose.InvokeAsync();
}
}
父组件使用子组件时,以HTML属性的形式传入参数值,用@前缀传递C#表达式,用@on事件名绑定回调方法:
csharp
@* 父组件使用AlertBox *@
@if (showAlert)
{
<AlertBox
Title="操作成功"
Message="@alertMessage"
Type="info"
OnClose="HandleAlertClose" />
}
@code {
private bool showAlert = true;
private string alertMessage = "您的数据已保存。";
private void HandleAlertClose()
{
showAlert = false;
}
}
注意EventCallback与C#原生Action/Func委托的重要区别:EventCallback在被触发时会自动调用StateHasChanged(),通知组件渲染引擎当前组件需要重新渲染;而普通委托触发后若不手动调用StateHasChanged(),UI不会自动更新。这是初学者常遇到的陷阱之一。
[Parameter]还有一个特殊的变体------[Parameter] public RenderFragment ChildContent { get; set; },它允许父组件向子组件传递一段任意的Razor内容(相当于Vue的slot或React的children属性),使子组件变成通用的"容器":
csharp
@* 卡片容器组件 Card.razor *@
<div class="card">
<div class="card-body">
@ChildContent
</div>
</div>
@code {
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
@* 父组件中使用Card,将任意内容放入标签内 *@
<Card>
<h3>这是卡片标题</h3>
<p>这是卡片内容,可以放任意Razor内容。</p>
</Card>
四、UI渲染与数据绑定
Blazor提供了丰富的模板语法来控制UI的动态渲染,其中最常用的是条件渲染、列表渲染和双向数据绑定。
4.1 条件渲染
使用@if/@else if/@else,根据C#布尔表达式决定是否渲染某段HTML。对于多路分支,@switch更加清晰:
csharp
@* 根据订单状态渲染不同UI *@
@switch (order.Status)
{
case OrderStatus.Pending:
<span class="badge bg-warning">待处理</span>
break;
case OrderStatus.Shipped:
<span class="badge bg-info">已发货</span>
break;
case OrderStatus.Delivered:
<span class="badge bg-success">已送达</span>
break;
default:
<span class="badge bg-secondary">未知状态</span>
break;
}
4.2 列表渲染
使用@foreach遍历集合。一个值得注意的实践是:在渲染列表时为每个元素提供唯一的@key属性。@key是Blazor差异算法的提示,有了它,框架在集合发生变化时(如项目排序,或插入新项)能够精确识别哪些DOM节点需要复用、哪些需要创建或销毁,避免不必要的DOM重建,提升性能:
csharp
<ul>
@foreach (var item in todoItems)
{
@* @key 告诉Blazor用 item.Id 来识别这个DOM节点 *@
<li @key="item.Id">
<input type="checkbox" checked="@item.IsDone" @onchange="() => ToggleTodo(item.Id)" />
<span class="@(item.IsDone ? "done" : "")">@item.Text</span>
</li>
}
</ul>
4.3 双向数据绑定
是构建表单的核心机制。@bind指令将HTML表单元素的值与C#字段实现双向同步------用户在输入框中输入时,C#字段自动更新;C#字段值变化时,输入框的显示也会随之更新:
csharp
<input @bind="searchText" placeholder="搜索..." />
<p>当前搜索词:@searchText</p>
@* 数字输入框,@bind会自动处理字符串到int的转换 *@
<input type="number" @bind="quantity" min="1" max="99" />
@* 下拉选择框 *@
<select @bind="selectedCategory">
<option value="">全部分类</option>
@foreach (var cat in categories)
{
<option value="@cat.Id">@cat.Name</option>
}
</select>
@code {
private string searchText = string.Empty;
private int quantity = 1;
private string selectedCategory = string.Empty;
private List<Category> categories = [];
}
默认情况下,@bind在onchange事件(失去焦点时或按Enter键)触发时更新绑定值。如果需要在用户每次击键时实时更新,应使用@bind:event="oninput":
csharp
@* 实时搜索:每次键入字符都触发更新 *@
<input @bind="searchText" @bind:event="oninput" placeholder="实时搜索..." />
<p>结果数:@FilteredResults.Count()</p>
@code {
private string searchText = string.Empty;
private IEnumerable<string> FilteredResults =>
allItems.Where(x => x.Contains(searchText, StringComparison.OrdinalIgnoreCase));
private List<string> allItems = ["苹果", "香蕉", "橙子", "葡萄", "西瓜"];
}
这里FilteredResults是一个计算属性(C# property with getter),每次Blazor重新渲染组件时都会重新求值。当searchText通过oninput事件更新后,Blazor触发重渲染,FilteredResults得到重新计算,UI随即显示过滤结果------这种响应式的数据推导方式简洁而直观。
五、总结
本章我们系统学习了Blazor组件的基础体系:.razor文件的指令区、模板区和@code代码块三段式结构;从OnInitialized到OnParametersSet再到OnAfterRender的完整生命周期,以及IDisposable的资源清理实践;通过[Parameter]和EventCallback实现父子组件之间的数据传递与事件通知;最后掌握了@if、@foreach、@bind等核心模板指令。这些要素构成了Blazor开发的日常基础,几乎每一个真实的组件都会综合运用它们。
随着应用规模的增长,单纯的父子组件参数传递会面临"层层传递"的困境------当一个深层嵌套的组件需要一份来自顶层的数据時,中间每一层组件都不得不转发这个参数,代码变得冗余且难以维护。下一章,我们将探讨更高级的组件通信与共享状态模式:级联参数如何在组件树中广播数据,以及依赖注入服务如何成为全局状态的真正载体。