Blazor 全栈开发

完整讲解 Blazor 的三种渲染模式取舍、组件模型与生命周期、参数与级联值传递、JavaScript 互操作、状态管理与持久化,以及预渲染与部署策略,帮助用 C# 构建现代交互式 Web 前端。

1. 三种渲染模式的取舍

一句话总结: Blazor Server 在服务端跑组件、靠 SignalR 同步 UI,Blazor WASM 把 .NET 运行时下载到浏览器,Blazor United 允许逐组件选择模式,取舍的核心是延迟、体积与离线能力。

Blazor 把组件逻辑用 C# 写,但「组件在哪里执行」有三种答案。

Blazor Server:组件跑在服务器,浏览器只渲染 DOM 差异。UI 事件通过 SignalR 发到服务端,服务端执行后把 DOM 变更发回。优点是启动快、体积小、能直接访问数据库;缺点是每个用户占一条长连接,延迟受网络影响。

Blazor WebAssembly:整个 .NET 运行时(裁剪后约几 MB)下载到浏览器,组件在客户端执行,服务端只提供静态文件与 API。优点是离线可用、无服务端连接开销、响应零延迟;缺点是首次加载体积大、受浏览器沙箱限制。

.NET 8 的 Blazor United(统一 Blazor)把两者合并进同一个项目:默认静态服务端渲染(SSR),需要交互的组件通过 @rendermode 声明为 InteractiveServer、InteractiveWebAssembly 或 InteractiveAuto。

@* 项目级默认:静态 SSR *@
@page "/orders"
<h1>订单列表</h1>
<OrderTable DataSource="_orders" />

@* 单个组件声明为交互式服务端 *@
<Counter @rendermode="InteractiveServer" />

@* 自动模式:首访用 Server,运行时下载完成后切 WASM *@
<Chart @rendermode="InteractiveAuto" />

@code {
    private List<Order> _orders = new();
}
模式执行位置首屏离线服务端连接
静态 SSR服务端(一次性)最快否无
InteractiveServer服务端快否每用户一条
InteractiveWebAssembly浏览器慢(需下载)是无
InteractiveAuto两者快部分首访有

避坑: InteractiveServer 的组件不能在浏览器里访问 DOM 之外的能力(如 localStorage 需走 JS 互操作),且服务端要保存每个用户的组件状态,用户数上万时内存与连接数会成为瓶颈。InteractiveWebAssembly 的组件在客户端执行,不能直接访问服务端数据库——所有数据访问必须走 HTTP API。选错模式最常见的症状是「在 WASM 组件里注入 DbContext 直接报错」。

2. 组件模型与生命周期

一句话总结: 组件由 @page、@code、参数与渲染片段组成,生命周期钩子按「设置参数 → 初始化 → 渲染 → 参数再设置 → 再渲染」的顺序被调用。

Blazor 组件是 .razor 文件,编译成 C# 类。它的生命周期比 React 更显式,几个关键钩子:

SetParametersAsync → OnInitialized/OnInitializedAsync → OnParametersSet/OnParametersSetAsync → OnAfterRender/OnAfterRenderAsync。参数变化时会再次触发 OnParametersSet 与渲染,但不会再次触发 OnInitialized。

@page "/orders/{Id:int}"
@implements IDisposable
@inject IOrderService OrderService

@if (_loading)
{
    <p>加载中...</p>
}
else
{
    <h2>@_order?.Title</h2>
    <p>金额:@_order?.Total.ToString("C")</p>
}

@code {
    [Parameter] public int Id { get; set; }
    [Parameter] public EventCallback<Order> OnLoaded { get; set; }
    private Order? _order;
    private bool _loading = true;

    protected override async Task OnInitializedAsync()
    {
        _order = await OrderService.GetAsync(Id);
        _loading = false;
    }

    protected override async Task OnParametersSetAsync()
    {
        _order = await OrderService.GetAsync(Id);   // Id 变化时重新加载
    }

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (firstRender) await OnLoaded.InvokeAsync(_order!);
    }

    public void Dispose() => _cts?.Cancel();
}
钩子触发时机典型用途
OnInitializedAsync组件首次创建首次数据加载
OnParametersSetAsync参数被赋值或变化依赖参数的重新加载
OnAfterRenderAsyncDOM 渲染完成后JS 互操作、焦点控制
Dispose组件移除取消订阅、释放资源
ShouldRender每次渲染前手动控制重渲染

避坑: OnInitializedAsync 与 OnParametersSetAsync 在预渲染阶段会被执行两次(一次在服务端预渲染,一次在交互式渲染),导致数据请求翻倍。解决办法是把一次性初始化放进 OnAfterRenderAsync(firstRender: true),或判断 RendererInfo.IsInteractive。另一个坑是异步钩子里要检查组件是否已释放,否则会抛 ObjectDisposedException。

3. 参数、级联值与事件

一句话总结: [Parameter] 是父传子的单向通道,EventCallback 是子传父的回调,CascadingValue 用于跨多层传递上下文,避免逐层透传。

组件通信的三条主线:父组件通过 [Parameter] 传数据给子组件;子组件通过 EventCallback<T> 通知父组件;祖先组件通过 CascadingValue 把值传给任意深度的后代,后代用 [CascadingParameter] 接收。

@* 父组件 *@
<OrderEditor Order="_order"
             OnSaved="HandleSaved"
             OnCancelled="@(() => _showEditor = false)" />

@code {
    private Order _order = new();
    private bool _showEditor = true;

    private async Task HandleSaved(Order saved)
    {
        await OrderService.UpdateAsync(saved);
        _showEditor = false;
    }
}
@* 子组件 OrderEditor.razor *@
<EditForm Model="_model" OnValidSubmit="Save">
    <InputText @bind-Value="_model.Title" />
    <button type="submit">保存</button>
    <button type="button" @onclick="() => OnCancelled.InvokeAsync()">取消</button>
</EditForm>

@code {
    [Parameter, EditorRequired] public Order Order { get; set; } = new();
    [Parameter] public EventCallback<Order> OnSaved { get; set; }
    [Parameter] public EventCallback OnCancelled { get; set; }

    private Order _model = new();

    protected override void OnParametersSet() => _model = Order with { };
    private Task Save() => OnSaved.InvokeAsync(_model);
}
@* 级联值:祖先注入主题,任意深度后代直接取用 *@
<CascadingValue Value="_theme" IsFixed="true">
    <Router AppAssembly="@typeof(App).Assembly" />
</CascadingValue>

@code { [CascadingParameter] public Theme Theme { get; set; } = Theme.Light; }
// 子组件无需逐层透传参数即可拿到 Theme
机制方向适用
[Parameter]父 → 子普通数据传递
EventCallback<T>子 → 父事件回调
CascadingValue祖先 → 后代主题、认证、本地化
@bind-Value双向表单输入
IsFixed="true"—级联值不变时跳过订阅

避坑: EventCallback 的异步版本要在父组件里 await 完成,否则子组件不知道父组件处理完了。[Parameter] 属性的对象不要在子组件里直接改——参数是父组件的引用,直接改会破坏单向数据流并让父组件的变更检测失效。需要编辑时用 Order with { } 拷贝或映射到本地模型。另外 CascadingValue 若值会变,不要设 IsFixed="true",否则后代收不到更新。

4. JavaScript 互操作

一句话总结: Blazor 通过 IJSRuntime 调用 JS 函数,通过 [JSInvokable] 让 JS 回调 .NET,是接入浏览器 API 与既有 JS 库的唯一通道。

IJSRuntime.InvokeAsync<T> 调用全局 JS 函数,InvokeVoidAsync 用于无返回值。要在 Blazor Server 上调用需要 DOM 引用的 JS,用 IJSObjectReference 持有 JS 模块。反向调用则用 DotNetObjectReference 包装 .NET 对象,JS 侧通过 invokeMethodAsync 调用。

@inject IJSRuntime JS
@implements IAsyncDisposable

@code {
    private IJSObjectReference? _module;

    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        // 导入 ES 模块(作用域隔离),再调用其导出函数
        _module = await JS.InvokeAsync<IJSObjectReference>(
            "import", "./js/charts.js");

        var dotnetRef = DotNetObjectReference.Create(this);
        await _module.InvokeVoidAsync("initChart", "canvas-1", dotnetRef);
    }

    // JS 侧可调用:dotnetRef.invokeMethodAsync('OnPointClicked', x, y)
    [JSInvokable]
    public Task OnPointClicked(int x, int y) => Task.CompletedTask;

    public async ValueTask DisposeAsync()
    {
        if (_module is not null) await _module.DisposeAsync();
    }
}
// wwwroot/js/charts.js
export function initChart(canvasId, dotnetRef) {
  const canvas = document.getElementById(canvasId);
  canvas.addEventListener('click', e =>
    dotnetRef.invokeMethodAsync('OnPointClicked', e.offsetX, e.offsetY));
}
API方向说明
IJSRuntime.InvokeAsync<T>.NET → JS调用全局函数
IJSObjectReference.NET → JS持有模块/对象引用
[JSInvokable]JS → .NET暴露 .NET 方法
DotNetObjectReferenceJS → .NET传 .NET 对象给 JS
DisposeAsync—释放 JS 引用

避坑: JS 互操作只能在 OnAfterRenderAsync 之后调用,因为此时 DOM 才存在。在 OnInitializedAsync 里调用会抛异常(预渲染阶段没有 JS 环境)。DotNetObjectReference 与 IJSObjectReference 都是非托管资源,必须 Dispose,否则 Blazor Server 上会内存泄漏。另外 Blazor Server 的 JS 互操作是网络往返,高频调用(如 mousemove)要节流。

5. 状态管理与持久化

一句话总结: 组件内状态用字段,跨组件状态用服务或级联值,需要跨刷新持久化用 ProtectedLocalStorage/ProtectedSessionStorage 或服务端存储。

Blazor 没有像 Redux 那样的全局状态库,状态管理靠三招:组件内字段(局部)、注入的单例/作用域服务(跨组件)、级联值(跨层级)。

跨页面刷新或跨标签页的持久化,用 ProtectedBrowserStorage——它把数据加密后写进 localStorage 或 sessionStorage,只能在交互式渲染后使用。

// 作用域服务:一个用户会话内的共享状态
public class CartState
{
    private readonly List<CartItem> _items = new();
    public IReadOnlyList<CartItem> Items => _items;
    public event Action? OnChange;

    public void Add(CartItem item)
    {
        _items.Add(item);
        OnChange?.Invoke();
    }

    public decimal Total => _items.Sum(i => i.Price * i.Quantity);
}

builder.Services.AddScoped<CartState>();   // 注册为作用域服务
@inject CartState Cart
@inject ProtectedLocalStorage Storage
@implements IDisposable
<div>共 @Cart.Items.Count 件,合计 @Cart.Total.ToString("C")</div>

@code {
    protected override async Task OnAfterRenderAsync(bool firstRender)
    {
        if (!firstRender) return;

        var saved = await Storage.GetAsync<List<CartItem>>("cart");
        if (saved.Success && saved.Value is not null)
            foreach (var i in saved.Value) Cart.Add(i);

        Cart.OnChange += StateHasChanged;   // 订阅变更
    }

    public void Dispose() => Cart.OnChange -= StateHasChanged;
}
存储作用域生命周期安全性
组件字段单组件组件存活期进程内
Scoped 服务用户会话会话期进程内
ProtectedLocalStorage浏览器跨会话加密
ProtectedSessionStorage浏览器标签标签关闭即失效加密
服务端数据库全局永久取决于实现

避坑: ProtectedLocalStorage 不能在预渲染阶段使用(没有 JS 环境),必须在 OnAfterRenderAsync(firstRender: true) 之后。它写入的数据是加密但存在浏览器的,不要存敏感凭据。Blazor Server 的 Scoped 服务生命周期绑定到电路(circuit),用户刷新页面会创建新电路、状态丢失——需要持久的状态必须落到浏览器存储或服务端。

6. 表单、验证与路由

一句话总结: EditForm 配合数据注解或 FluentValidation 提供客户端与服务端一致的验证,路由用 @page 模板与 NavLink 组合。

EditForm 是 Blazor 的表单容器,Model 绑定数据对象,OnValidSubmit 只在验证通过时触发。验证器通过 DataAnnotationsValidator 或第三方库接入。

@page "/orders/new"
@page "/orders/page/{Page:int}"
@inject IOrderService Service
@inject NavigationManager Nav

<EditForm Model="_model" OnValidSubmit="Submit" FormName="createOrder">
    <DataAnnotationsValidator />
    <ValidationSummary />
    <label>标题</label>
    <InputText @bind-Value="_model.Title" />
    <ValidationMessage For="() => _model.Title" />
    <InputNumber @bind-Value="_model.Total" />
    <button type="submit" disabled="@_saving">创建</button>
</EditForm>

<NavLink href="/orders" Match="NavLinkMatch.All">全部订单</NavLink>

@code {
    [Parameter] public int Page { get; set; } = 1;
    private OrderInput _model = new();
    private bool _saving;

    private async Task Submit()
    {
        _saving = true;
        try
        {
            var id = await Service.CreateAsync(_model);
            Nav.NavigateTo($"/orders/{id}");
        }
        finally { _saving = false; }
    }

    public class OrderInput
    {
        [Required(ErrorMessage = "标题必填")]
        [StringLength(100, MinimumLength = 3)]
        public string Title { get; set; } = "";
        [Range(0.01, 1_000_000)]
        public decimal Total { get; set; }
    }
}
组件用途
EditForm表单容器与提交控制
DataAnnotationsValidator接入数据注解验证
ValidationMessage单字段错误提示
ValidationSummary汇总所有错误
NavLink带激活样式的路由链接
NavigationManager编程式跳转与拦截

避坑: InputText 的绑定是在失焦时才更新模型(onchange),要在每次按键时验证需设 @bind-Value:event="oninput"——但这会让验证变得嘈杂。EditForm 需要显式 FormName(.NET 8+)才能在静态 SSR 下正确工作。另外客户端验证不能替代服务端验证:WASM 组件的验证在浏览器里执行,攻击者可以绕过,API 端必须重新校验。

7. 预渲染与部署

一句话总结: 预渲染让首屏由服务端直接输出 HTML(利于 SEO 与首屏速度),但会执行两次生命周期;部署形态取决于渲染模式。

预渲染(Prerendering) 让服务端先把组件渲染成 HTML 发给浏览器,浏览器先看到内容,之后交互式运行时接管。好处是 SEO 友好、首屏快;代价是组件初始化逻辑执行两次。

Blazor WASM 的部署产物是静态文件,可直接放 CDN 或静态托管。Blazor Server 需要 ASP.NET Core 进程与 WebSocket 支持。Blazor United 则是混合:静态 SSR 部分由服务端输出,交互部分按 @rendermode 决定。

// Program.cs:配置渲染模式与预渲染
builder.Services.AddRazorComponents()
    .AddInteractiveServerComponents()
    .AddInteractiveWebAssemblyComponents();
app.MapRazorComponents<App>()
    .AddInteractiveServerRenderMode()
    .AddInteractiveWebAssemblyRenderMode();
@* 关闭单个组件的预渲染 *@
@rendermode @(new InteractiveWebAssemblyRenderMode(prerender: false))

@* 从查询串取参数,预渲染与交互式渲染都能拿到 *@
@code { [SupplyParameterFromQuery] public int? Id { get; set; } }
部署形态产物服务器要求
Blazor WASM静态文件任意静态托管 + API
Blazor ServerASP.NET Core 应用WebSocket、粘性会话
Blazor United混合ASP.NET Core + 静态资源
混合 MAUI原生壳 + Blazor无

避坑: 预渲染期间不能访问浏览器 API,也不能依赖 HttpContext 之外的用户特定状态(多用户共享预渲染进程时可能串数据)。Blazor Server 部署在负载均衡后需要粘性会话(sticky session),因为 WebSocket 连接不能随意切换实例。WASM 部署要注意压缩与缓存:.br/.gz 预压缩文件能显著减小首屏体积,dotnet.wasm 与框架 DLL 要设置长期缓存加指纹。

8. 总结

环节要点
渲染模式Server 快但占连接,WASM 离线但体积大,Auto 两者兼得
组件模型生命周期钩子顺序固定,预渲染会执行两次
组件通信参数下行、回调上行、级联值跨层
JS 互操作只能在渲染后调用,引用必须释放
状态管理局部字段、Scoped 服务、浏览器加密存储三选一
表单验证客户端验证只是体验,服务端必须重新校验
部署Server 要粘性会话,WASM 要压缩与缓存策略

Blazor 的价值在于用一套语言与组件模型覆盖服务端与客户端:同一个 OrderEditor 组件既能在服务端渲染,也能在浏览器里交互。代价是必须理解渲染模式的边界——哪些代码在服务端跑、哪些在浏览器跑、什么时候会执行两次。掌握这些之后,前端不再是黑盒,全栈 C# 开发成为现实。下一篇文章转向运行时的另一端:如何用 Native AOT 与裁剪把应用编译成启动极快、体积极小的原生可执行文件。

延伸阅读

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「csharp」更多文章

  1. .NET 机器学习实战
  2. 内存剖析与 dump 分析
  3. 分布式事务与 Saga 编排