一、父子组件通信:参数与EventCallback
上一章我们已经接触了[Parameter]和EventCallback的基本用法,本章将在更真实的场景中深化这两种机制。
父子通信的本质是单向数据流:数据从父组件流向 子组件(通过参数),事件从子组件流向父组件(通过EventCallback)。这种设计使数据流动方向清晰可预测,便于定位问题。
考虑一个常见的表格+分页场景,父组件ProductList.razor负责管理产品列表的状态和数据加载,子组件Pagination.razor只负责渲染分页条并通知父组件用户选择了哪一页:
csharp
@* 子组件:Components/Shared/Pagination.razor *@
<nav>
<ul class="pagination">
<li class="page-item @(CurrentPage == 1 ? "disabled" : "")">
<button class="page-link" @onclick="() => OnPageChanged.InvokeAsync(CurrentPage - 1)">
上一页
</button>
</li>
@for (int i = 1; i <= TotalPages; i++)
{
var pageNum = i;
<li class="page-item @(CurrentPage == pageNum ? "active" : "")">
<button class="page-link" @onclick="() => OnPageChanged.InvokeAsync(pageNum)">
@pageNum
</button>
</li>
}
<li class="page-item @(CurrentPage == TotalPages ? "disabled" : "")">
<button class="page-link" @onclick="() => OnPageChanged.InvokeAsync(CurrentPage + 1)">
下一页
</button>
</li>
</ul>
</nav>
@code {
// 子组件接收当前页和总页数作为参数(只读)
[Parameter]
public int CurrentPage { get; set; }
[Parameter]
public int TotalPages { get; set; }
// 通过 EventCallback<int> 将用户选中的页码传回父组件
[Parameter]
public EventCallback<int> OnPageChanged { get; set; }
}
csharp
@* 父组件:Components/Pages/ProductList.razor *@
@page "/products"
@inject IProductService ProductService
<h1>产品列表</h1>
@if (isLoading)
{
<p>加载中...</p>
}
else
{
<table class="table">
<tbody>
@foreach (var product in currentProducts)
{
<tr>
<td>@product.Name</td>
<td>¥@product.Price</td>
</tr>
}
</tbody>
</table>
@* 将父组件状态传入子组件,并订阅子组件的页码变化事件 *@
<Pagination
CurrentPage="currentPage"
TotalPages="totalPages"
OnPageChanged="HandlePageChanged" />
}
@code {
private List<Product> currentProducts = [];
private int currentPage = 1;
private int totalPages = 1;
private bool isLoading = true;
private const int PageSize = 10;
protected override async Task OnInitializedAsync()
{
await LoadProducts();
}
// 子组件触发 OnPageChanged 时,此方法被调用,参数 page 就是用户选中的页码
private async Task HandlePageChanged(int page)
{
currentPage = page;
await LoadProducts();
}
private async Task LoadProducts()
{
isLoading = true;
var result = await ProductService.GetPagedAsync(currentPage, PageSize);
currentProducts = result.Items;
totalPages = result.TotalPages;
isLoading = false;
}
}
这段代码清晰地展示了职责分离:Pagination组件只关心如何渲染分页按钮和捕获用户点击,它不知道也不关心产品数据是什么;ProductList组件掌管所有状态和业务逻辑,分页组件只是它的一个可替换的UI模块。这种设计使Pagination可以被任何需要分页的列表页复用。
注意循环中var pageNum = i的写法------这是一个经典的"lambda闭包捕获循环变量"陷阱。如果直接在lambda中使用i,所有按钮的点击事件都会捕获到循环结束后i的最终值。创建局部变量pageNum并让lambda捕获它,可以确保每个按钮捕获到各自对应的页码。
二、级联参数:在组件树中广播数据
父子参数传递仅限于直接父子关系。当一份数据需要被多层组件树共享时,逐层显式传递参数会造成"prop drilling"问题------中间层组件即使不使用该数据,也不得不充当数据管道。Blazor提供了**级联参数(Cascading Parameters)**来解决这个问题。
CascadingValue组件将一个值"广播"到它所有的后代组件,无论嵌套多深。后代组件使用[CascadingParameter]特性标记属性来接收这个值。最典型的用例是主题、语言、用户身份等全局上下文。
考虑一个主题切换的场景,我们希望根用户的主题偏好对整个应用生效:
csharp
@* 根布局:Components/Layout/MainLayout.razor *@
@inherits LayoutComponentBase
@* 用 CascadingValue 将 currentTheme 对象广播给所有后代 *@
<CascadingValue Value="currentTheme">
<div class="app-container theme-@currentTheme.Name">
<NavMenu />
<main>
@Body
</main>
</div>
</CascadingValue>
<button @onclick="ToggleTheme">切换主题</button>
@code {
private AppTheme currentTheme = new AppTheme { Name = "light", PrimaryColor = "#0078d4" };
private void ToggleTheme()
{
currentTheme = currentTheme.Name == "light"
? new AppTheme { Name = "dark", PrimaryColor = "#1a9fff" }
: new AppTheme { Name = "light", PrimaryColor = "#0078d4" };
}
}
csharp
// 主题数据模型
public class AppTheme
{
public string Name { get; set; } = "light";
public string PrimaryColor { get; set; } = "#0078d4";
}
csharp
@* 深层嵌套的某个按钮组件,无需任何中间组件传递参数 *@
@* Components/Shared/ThemedButton.razor *@
<button style="background-color: @Theme?.PrimaryColor" class="btn">
@ChildContent
</button>
@code {
// [CascadingParameter] 自动从上层最近的 CascadingValue<AppTheme> 接收值
[CascadingParameter]
public AppTheme? Theme { get; set; }
[Parameter]
public RenderFragment? ChildContent { get; set; }
}
当MainLayout中的currentTheme对象引用发生变化(注意这里创建了新对象而非修改字段),Blazor会自动重新渲染所有依赖该级联值的后代组件------ThemedButton的主题色就会随之更新,即使NavMenu等中间组件完全不知道AppTheme的存在。
如果应用中存在多种类型的级联值,也可以通过Name属性区分:
csharp
<CascadingValue Value="currentTheme" Name="Theme">
<CascadingValue Value="currentUser" Name="User">
@Body
</CascadingValue>
</CascadingValue>
接收时同样需要指定名称:
csharp
[CascadingParameter(Name = "User")]
public UserInfo? CurrentUser { get; set; }
三、依赖注入服务作为共享状态容器
级联参数非常适合处理UI上下文(主题、语言),但对于跨越多个页面的复杂应用状态(如购物车、用户会话、通知列表),将状态放在组件层面仍然不够灵活。更强大的做法是将共享状态封装到服务类中,通过依赖注入(DI)供所有需要的组件使用。
以购物车为例,我们创建一个CartService,它持有购物车的商品列表,并暴露一个OnChange事件让关心购物车状态的组件订阅变化:
csharp
// Services/CartService.cs
public class CartService
{
private readonly List<CartItem> _items = [];
public IReadOnlyList<CartItem> Items => _items.AsReadOnly();
public int TotalCount => _items.Sum(x => x.Quantity);
// 当购物车内容变化时,通知所有订阅者重新渲染
// 使用 Action 而非 event 是为了方便从异步上下文触发
public event Action? OnChange;
public void AddItem(Product product, int quantity = 1)
{
var existing = _items.FirstOrDefault(x => x.ProductId == product.Id);
if (existing is not null)
{
existing.Quantity += quantity;
}
else
{
_items.Add(new CartItem
{
ProductId = product.Id,
ProductName = product.Name,
Price = product.Price,
Quantity = quantity
});
}
// 状态变化后触发通知
NotifyStateChanged();
}
public void RemoveItem(int productId)
{
var item = _items.FirstOrDefault(x => x.ProductId == productId);
if (item is not null)
{
_items.Remove(item);
NotifyStateChanged();
}
}
private void NotifyStateChanged() => OnChange?.Invoke();
}
在Program.cs中将CartService注册为Scoped服务------Blazor Server中Scope的生命周期等于一次用户会话(SignalR连接),Blazor WebAssembly中Scope等于整个应用生命周期,两种模式下Scoped服务都能为单个用户提供独立的购物车状态:
csharp
// Program.cs
builder.Services.AddScoped<CartService>();
在购物车图标组件中订阅OnChange事件,当有商品加入购物车时自动更新徽章数字:
csharp
@* Components/Shared/CartIcon.razor *@
@inject CartService CartService
@implements IDisposable
<a href="/cart" class="cart-link">
<span class="cart-icon">🛒</span>
@if (CartService.TotalCount > 0)
{
<span class="badge">@CartService.TotalCount</span>
}
</a>
@code {
protected override void OnInitialized()
{
// 订阅购物车变化事件
CartService.OnChange += HandleCartChanged;
}
private void HandleCartChanged()
{
// OnChange 可能在非Blazor线程触发,InvokeAsync 确保安全地切回同步上下文
InvokeAsync(StateHasChanged);
}
public void Dispose()
{
// 解除订阅,防止组件销毁后的悬挂引用造成内存泄漏
CartService.OnChange -= HandleCartChanged;
}
}
在产品列表组件中注入同一个CartService,直接调用其方法修改状态:
csharp
@* 产品卡片中的"加入购物车"按钮 *@
@inject CartService CartService
<button @onclick="() => CartService.AddItem(Product)">加入购物车</button>
@code {
[Parameter]
public required Product Product { get; set; }
}
以Scoped服务为核心的共享状态模式,让状态管理从组件层面提升到服务层面,多个组件无论嵌套关系如何,都可以通过注入访问和操作同一份状态。这种模式轻量、直观,非常适合中小规模应用。
四、总结
本章从三个维度扩展了组件协作的能力:通过[Parameter]和EventCallback<T>构建清晰的父子单向数据流;通过CascadingValue/[CascadingParameter]在组件树中广播上下文数据,避免层层传递;通过DI服务作为共享状态容器,打破组件层次的限制,让任意组件都能响应同一份状态的变化。
然而,随着应用状态变得越来越复杂------多个操作相互影响,异步副作用(如API请求)穿插其中,状态历史需要追踪时------仅靠服务类的OnChange事件会显得力不从心。下一章我们将引入专门的状态管理模式,探讨如何用Flux/Redux架构的Fluxor库来管理复杂状态,以及如何精细控制组件的重渲染时机。