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 | 参数被赋值或变化 | 依赖参数的重新加载 |
OnAfterRenderAsync | DOM 渲染完成后 | 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 方法 |
DotNetObjectReference | JS → .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 Server | ASP.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 与裁剪把应用编译成启动极快、体积极小的原生可执行文件。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。