6. 【Blazor全栈开发实战指南】--组件通信与共享状态

一、父子组件通信:参数与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库来管理复杂状态,以及如何精细控制组件的重渲染时机。

相关推荐
love530love2 小时前
Windows Podman Machine 虚拟硬盘迁移完整指南:从 C 盘到非系统盘
c语言·人工智能·windows·podman
love530love2 小时前
Podman Machine 虚拟硬盘迁移实战二:用 Junction 把 vhdx 从 C 盘搬到其他盘
c语言·开发语言·人工智能·windows·wsl·podman·podman machine
小龙在慢慢变强..2 小时前
目录结构(FHS 标准)
linux·运维·服务器
2035去旅行2 小时前
嵌入式开发,如何选择C标准库
linux·arm开发
刘延林.2 小时前
win11系统下通过 WSL2 安装Ubuntu 24.04 使用RTX 5080 GPU
linux·运维·ubuntu
Diros1g2 小时前
如何通过普通网线给另一个设备供网
网络·网络协议
beyond阿亮3 小时前
IEC104 Client Simulator - IEC104 主站/客户端模拟器 仿真器免费使用教程
运维·服务器·网络
(Charon)3 小时前
【C++/Qt】Qt 封装 TCP 客户端底层 Network 类:连接、收发、自动测试与错误处理
服务器·网络·qt·tcp/ip
KKKlucifer3 小时前
日志审计与行为分析在安全服务中的应用实践
网络·人工智能·安全
CodeOfCC3 小时前
Linux 嵌入式arm64安装openclaw
linux·运维·服务器