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库来管理复杂状态,以及如何精细控制组件的重渲染时机。

相关推荐
桌面运维家2 小时前
云桌面vDisk解决方案:Windows/Linux高效部署与优化
linux·运维·服务器
江南西肥肥2 小时前
养虾日记[特殊字符]:OpenClaw 多 Agent 与飞书对接实战
网络·飞书·openclaw
AI-小柒2 小时前
开发者一站式数据解决方案:通过 DataEyes API 一键配置智能数据采集与分析工具
大数据·人工智能·windows·http·macos
wsoz2 小时前
GCC编译
linux·c语言·嵌入式·gcc
CHQIUU2 小时前
外置硬盘格式选择指南:Windows与Mac双系统通用方案
windows·macos
xlq223223 小时前
26(下).库的理解与加载
linux·运维·服务器
德迅云安全-小潘3 小时前
恶意爬虫对数字资产的系统性威胁
网络·人工智能·安全·web安全
wbs_scy3 小时前
Linux 动静态库完全指南:制作、使用、原理与实战
linux·运维·服务器
孙同学_3 小时前
【Linux篇】Socket编程TCP
linux·网络·tcp/ip