第十六章我们学习了 ASP.NET Core Web API,构建了后端服务。但用户如何与这些服务交互?传统的做法是写 JavaScript 前端。但如果你不想学 JavaScript 呢?这一章要学的 Blazor 就是答案------它让你直接用 C# 写前端,在浏览器中运行!
17.1 什么是 Blazor?
17.1.1 Blazor 的诞生
Blazor = Browser + Razor(微软的前端模板语法)
传统的 Web 开发:前端用 JavaScript,后端用 C#,需要掌握两套语言。
Blazor 的方案:前端也用 C#,通过 WebAssembly 在浏览器中运行。
17.1.2 Blazor 的两种模式
text
┌─────────────────────────────────────────────────────────────────┐
│ Blazor │
├─────────────────────────────────────────────────────────────────┤
│ │
│ ┌─────────────────────────┐ ┌─────────────────────────┐ │
│ │ Blazor Server │ │ Blazor WebAssembly │ │
│ ├─────────────────────────┤ ├─────────────────────────┤ │
│ │ • UI 渲染在服务器 │ │ • UI 渲染在浏览器 │ │
│ │ • 通过 SignalR 传输 │ │ • 下载 .NET 运行时 │ │
│ │ • 首次加载快 │ │ • 首次加载慢 │ │
│ │ • 需要稳定网络连接 │ │ • 可离线运行 │ │
│ │ • 服务器压力大 │ │ • 客户端压力大 │ │
│ └─────────────────────────┘ └─────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────────────┘
选择指南:
| 场景 | 推荐模式 |
|---|---|
| 企业内部管理系统 | Blazor Server |
| 面向公众的网站 | Blazor WebAssembly |
| 需要 SEO | Blazor Server |
| 需要离线支持 | Blazor WebAssembly |
| 首次加载速度重要 | Blazor Server |
| 服务器资源有限 | Blazor WebAssembly |
17.2 创建第一个 Blazor 项目
17.2.1 创建 Blazor Server 项目
bash
# 使用 CLI 创建 Blazor Server 应用
dotnet new blazorserver -n MyBlazorApp
cd MyBlazorApp
dotnet run
访问:https://localhost:5001
17.2.2 项目结构
text
MyBlazorApp/
├── Program.cs # 程序入口
├── appsettings.json # 配置文件
├── Pages/ # 页面组件
│ ├── Index.razor # 首页
│ ├── Counter.razor # 计数器示例
│ ├── FetchData.razor # 数据获取示例
│ └── _Layout.cshtml # 布局
├── Shared/ # 共享组件
│ ├── MainLayout.razor # 主布局
│ ├── NavMenu.razor # 导航菜单
│ └── SurveyPrompt.razor # 问卷调查组件
├── wwwroot/ # 静态文件(CSS、JS、图片)
├── Data/ # 数据服务
└── Properties/ # 项目属性
17.2.3 第一个组件:计数器
razor
@* Counter.razor *@
@page "/counter"
<h3>计数器</h3>
<p>当前计数:@currentCount</p>
<button class="btn btn-primary" @οnclick="IncrementCount">点我 +1</button>
<button class="btn btn-success" @οnclick="ResetCount">重置</button>
@code {
private int currentCount = 0;
private void IncrementCount()
{
currentCount++;
}
private void ResetCount()
{
currentCount = 0;
}
}
17.2.4 数据绑定
razor
@page "/binding"
<h3>数据绑定演示</h3>
<div class="row">
<div class="col-md-6">
<h4>单向绑定</h4>
<input @bind="name" @bind:event="oninput" />
<p>你好,@name!</p>
</div>
<div class="col-md-6">
<h4>双向绑定</h4>
<input @bind-value="age" @bind-value:event="oninput" />
<p>年龄:@age</p>
</div>
</div>
<h4>格式化绑定</h4>
<input @bind="birthDate" @bind:format="yyyy-MM-dd" type="date" />
<p>生日:@birthDate.ToString("yyyy年MM月dd日")</p>
<h4>复选框绑定</h4>
<label>
<input type="checkbox" @bind="isAgreed" />
同意条款
</label>
<p>是否同意:@(isAgreed ? "已同意" : "未同意")</p>
@code {
private string name = "";
private int age = 18;
private DateTime birthDate = DateTime.Now;
private bool isAgreed = false;
}
17.3 组件基础
17.3.1 创建可复用组件
razor
@* Components/ProductCard.razor *@
<div class="card" style="width: 18rem;">
<img src="@ImageUrl" class="card-img-top" alt="@Name">
<div class="card-body">
<h5 class="card-title">@Name</h5>
<p class="card-text">@Description</p>
<p class="card-text text-primary">价格:@Price.ToString("C")</p>
<button class="btn btn-primary" @οnclick="OnBuyClick">购买</button>
</div>
</div>
@code {
[Parameter]
public string Name { get; set; }
[Parameter]
public string Description { get; set; }
[Parameter]
public decimal Price { get; set; }
[Parameter]
public string ImageUrl { get; set; }
[Parameter]
public EventCallback<ProductInfo> OnBuy { get; set; }
private async Task OnBuyClick()
{
var productInfo = new ProductInfo
{
Name = Name,
Price = Price
};
await OnBuy.InvokeAsync(productInfo);
}
}
public class ProductInfo
{
public string Name { get; set; }
public decimal Price { get; set; }
}
17.3.2 使用组件
razor
@page "/products"
@using MyBlazorApp.Components
<h3>商品列表</h3>
<div class="row">
@foreach (var product in products)
{
<div class="col-md-4 mb-3">
<ProductCard Name="@product.Name"
Description="@product.Description"
Price="@product.Price"
ImageUrl="@product.ImageUrl"
OnBuy="HandleBuy" />
</div>
}
</div>
@if (lastPurchased != null)
{
<div class="alert alert-success mt-3">
您购买了 @lastPurchased.Name,金额 @lastPurchased.Price
</div>
}
@code {
private List<Product> products = new();
private ProductInfo lastPurchased;
protected override void OnInitialized()
{
products = new List<Product>
{
new Product { Name = "iPhone 15", Description = "最新款 iPhone", Price = 5999, ImageUrl = "/images/iphone.jpg" },
new Product { Name = "MacBook Pro", Description = "高性能笔记本", Price = 14999, ImageUrl = "/images/mac.jpg" },
new Product { Name = "AirPods Pro", Description = "降噪耳机", Price = 1899, ImageUrl = "/images/airpods.jpg" }
};
}
private void HandleBuy(ProductInfo product)
{
lastPurchased = product;
Console.WriteLine($"购买了:{product.Name}");
}
class Product
{
public string Name { get; set; }
public string Description { get; set; }
public decimal Price { get; set; }
public string ImageUrl { get; set; }
}
}
17.3.3 组件生命周期
razor
@page "/lifecycle"
<h3>组件生命周期</h3>
<p>日志:</p>
<div class="border p-2" style="height: 200px; overflow-y: scroll;">
@foreach (var log in logs)
{
<div>@log</div>
}
</div>
<button class="btn btn-primary" @οnclick="TriggerRender">触发重新渲染</button>
<button class="btn btn-danger" @οnclick="Dispose">销毁组件</button>
@code {
private List<string> logs = new();
private bool isVisible = true;
private int counter = 0;
protected override void OnInitialized()
{
AddLog("OnInitialized - 组件初始化");
}
protected override async Task OnInitializedAsync()
{
AddLog("OnInitializedAsync - 异步初始化开始");
await Task.Delay(1000);
AddLog("OnInitializedAsync - 异步初始化完成");
}
protected override void OnParametersSet()
{
AddLog("OnParametersSet - 参数已设置");
}
protected override async Task OnParametersSetAsync()
{
AddLog("OnParametersSetAsync - 异步参数设置");
await Task.CompletedTask;
}
protected override void OnAfterRender(bool firstRender)
{
AddLog($"OnAfterRender - firstRender: {firstRender}");
}
protected override async Task OnAfterRenderAsync(bool firstRender)
{
AddLog($"OnAfterRenderAsync - firstRender: {firstRender}");
await Task.CompletedTask;
}
private void TriggerRender()
{
counter++;
AddLog($"触发重新渲染 - 计数: {counter}");
StateHasChanged(); // 手动触发重新渲染
}
private void Dispose()
{
AddLog("Dispose - 组件即将销毁");
isVisible = false;
StateHasChanged();
}
private void AddLog(string message)
{
logs.Add($"{DateTime.Now:HH:mm:ss.fff} - {message}");
StateHasChanged();
}
// 实现 IDisposable
public void Dispose()
{
AddLog("IDisposable.Dispose - 释放资源");
}
}
17.3.4 组件参数和级联参数
razor
@* Components/ThemedButton.razor *@
<button class="btn @ButtonClass" @οnclick="OnClick">
@ChildContent
</button>
@code {
[Parameter]
public RenderFragment ChildContent { get; set; }
[CascadingParameter]
public string Theme { get; set; }
[Parameter]
public EventCallback<MouseEventArgs> OnClick { get; set; }
private string ButtonClass => Theme switch
{
"dark" => "btn-dark",
"primary" => "btn-primary",
"danger" => "btn-danger",
_ => "btn-secondary"
};
}
@* 使用级联参数 *@
@page "/theme-demo"
<CascadingValue Value="selectedTheme">
<div class="container">
<h3>主题演示</h3>
<div class="mb-3">
<label>选择主题:</label>
<select @bind="selectedTheme">
<option value="primary">蓝色</option>
<option value="dark">深色</option>
<option value="danger">红色</option>
</select>
</div>
<ThemedButton OnClick="() => Console.WriteLine("点击了按钮")">
主题按钮
</ThemedButton>
<ThemedButton OnClick="() => Console.WriteLine("另一个按钮")">
另一个主题按钮
</ThemedButton>
</div>
</CascadingValue>
@code {
private string selectedTheme = "primary";
}
17.4 表单和验证
17.4.1 使用 EditForm
razor
@page "/register"
@using System.ComponentModel.DataAnnotations
<h3>用户注册</h3>
<EditForm Model="@user" OnValidSubmit="HandleValidSubmit" OnInvalidSubmit="HandleInvalidSubmit">
<DataAnnotationsValidator />
<ValidationSummary />
<div class="form-group mb-3">
<label>用户名</label>
<InputText class="form-control" @bind-Value="user.Username" />
<ValidationMessage For="@(() => user.Username)" />
</div>
<div class="form-group mb-3">
<label>邮箱</label>
<InputText class="form-control" @bind-Value="user.Email" type="email" />
<ValidationMessage For="@(() => user.Email)" />
</div>
<div class="form-group mb-3">
<label>密码</label>
<InputText class="form-control" @bind-Value="user.Password" type="password" />
<ValidationMessage For="@(() => user.Password)" />
</div>
<div class="form-group mb-3">
<label>确认密码</label>
<InputText class="form-control" @bind-Value="user.ConfirmPassword" type="password" />
<ValidationMessage For="@(() => user.ConfirmPassword)" />
</div>
<div class="form-group mb-3">
<label>年龄</label>
<InputNumber class="form-control" @bind-Value="user.Age" />
<ValidationMessage For="@(() => user.Age)" />
</div>
<div class="form-group mb-3">
<label>性别</label>
<InputSelect class="form-control" @bind-Value="user.Gender">
<option value="">请选择</option>
<option value="男">男</option>
<option value="女">女</option>
</InputSelect>
<ValidationMessage For="@(() => user.Gender)" />
</div>
<div class="form-group mb-3">
<InputCheckbox @bind-Value="user.AgreeTerms" />
<label> 同意条款</label>
<ValidationMessage For="@(() => user.AgreeTerms)" />
</div>
<button type="submit" class="btn btn-primary">注册</button>
</EditForm>
@if (isSubmitted)
{
<div class="alert alert-success mt-3">
注册成功!欢迎 @user.Username
</div>
}
@code {
private RegisterModel user = new();
private bool isSubmitted = false;
private void HandleValidSubmit()
{
Console.WriteLine("验证通过,提交数据");
isSubmitted = true;
}
private void HandleInvalidSubmit()
{
Console.WriteLine("验证失败,请检查输入");
}
public class RegisterModel
{
[Required(ErrorMessage = "用户名不能为空")]
[MinLength(3, ErrorMessage = "用户名至少3个字符")]
[MaxLength(20, ErrorMessage = "用户名最多20个字符")]
public string Username { get; set; }
[Required(ErrorMessage = "邮箱不能为空")]
[EmailAddress(ErrorMessage = "邮箱格式不正确")]
public string Email { get; set; }
[Required(ErrorMessage = "密码不能为空")]
[MinLength(6, ErrorMessage = "密码至少6个字符")]
public string Password { get; set; }
[Required(ErrorMessage = "请确认密码")]
[Compare("Password", ErrorMessage = "两次密码不一致")]
public string ConfirmPassword { get; set; }
[Range(18, 100, ErrorMessage = "年龄必须在18-100之间")]
public int Age { get; set; } = 18;
public string Gender { get; set; }
[Required(ErrorMessage = "必须同意条款")]
public bool AgreeTerms { get; set; }
}
}
17.4.2 自定义验证
razor
@page "/custom-validation"
<EditForm Model="@model" OnValidSubmit="HandleSubmit">
<DataAnnotationsValidator />
<div class="form-group mb-3">
<label>身份证号</label>
<InputText class="form-control" @bind-Value="model.IdCard" />
<ValidationMessage For="@(() => model.IdCard)" />
</div>
<div class="form-group mb-3">
<label>手机号</label>
<InputText class="form-control" @bind-Value="model.Phone" />
<ValidationMessage For="@(() => model.Phone)" />
</div>
<button type="submit" class="btn btn-primary">提交</button>
</EditForm>
@code {
private CustomModel model = new();
private void HandleSubmit()
{
Console.WriteLine("验证通过");
}
public class CustomModel
{
[CustomIdCardValidation]
public string IdCard { get; set; }
[CustomPhoneValidation]
public string Phone { get; set; }
}
// 自定义验证属性
public class CustomIdCardValidationAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var idCard = value as string;
if (string.IsNullOrEmpty(idCard))
{
return new ValidationResult("身份证号不能为空");
}
// 简单验证:18位或15位
if (idCard.Length != 15 && idCard.Length != 18)
{
return new ValidationResult("身份证号必须是15位或18位");
}
return ValidationResult.Success;
}
}
public class CustomPhoneValidationAttribute : ValidationAttribute
{
protected override ValidationResult IsValid(object value, ValidationContext validationContext)
{
var phone = value as string;
if (string.IsNullOrEmpty(phone))
{
return new ValidationResult("手机号不能为空");
}
// 简单验证:11位数字
if (!System.Text.RegularExpressions.Regex.IsMatch(phone, @"^1[3-9]\d{9}$"))
{
return new ValidationResult("手机号格式不正确");
}
return ValidationResult.Success;
}
}
}
17.5 调用 Web API
17.5.1 创建 API 客户端服务
csharp
// Services/ApiClient.cs
public interface IApiClient
{
Task<List<Product>> GetProductsAsync();
Task<Product> GetProductAsync(int id);
Task<Product> CreateProductAsync(CreateProductRequest request);
Task UpdateProductAsync(int id, UpdateProductRequest request);
Task DeleteProductAsync(int id);
}
public class ApiClient : IApiClient
{
private readonly HttpClient _httpClient;
private readonly ILogger<ApiClient> _logger;
public ApiClient(HttpClient httpClient, ILogger<ApiClient> logger)
{
_httpClient = httpClient;
_logger = logger;
}
public async Task<List<Product>> GetProductsAsync()
{
try
{
return await _httpClient.GetFromJsonAsync<List<Product>>("api/products");
}
catch (Exception ex)
{
_logger.LogError(ex, "获取产品列表失败");
return new List<Product>();
}
}
public async Task<Product> GetProductAsync(int id)
{
return await _httpClient.GetFromJsonAsync<Product>($"api/products/{id}");
}
public async Task<Product> CreateProductAsync(CreateProductRequest request)
{
var response = await _httpClient.PostAsJsonAsync("api/products", request);
response.EnsureSuccessStatusCode();
return await response.Content.ReadFromJsonAsync<Product>();
}
public async Task UpdateProductAsync(int id, UpdateProductRequest request)
{
var response = await _httpClient.PutAsJsonAsync($"api/products/{id}", request);
response.EnsureSuccessStatusCode();
}
public async Task DeleteProductAsync(int id)
{
await _httpClient.DeleteAsync($"api/products/{id}");
}
}
// Models/Product.cs
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
}
public class CreateProductRequest
{
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
}
public class UpdateProductRequest
{
public string Name { get; set; }
public decimal Price { get; set; }
public int Stock { get; set; }
}
17.5.2 注册服务
csharp
// Program.cs (Blazor Server)
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddRazorPages();
builder.Services.AddServerSideBlazor();
// 注册 HttpClient
builder.Services.AddHttpClient<IApiClient, ApiClient>(client =>
{
client.BaseAddress = new Uri("https://localhost:5001/");
client.DefaultRequestHeaders.Add("Accept", "application/json");
});
var app = builder.Build();
// ... 其他代码
17.5.3 在产品页面中使用 API
razor
@page "/admin/products"
@using Microsoft.Extensions.Logging
@inject IApiClient ApiClient
@inject ILogger<ProductsPage> Logger
<h3>产品管理</h3>
@if (isLoading)
{
<div class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">加载中...</span>
</div>
</div>
}
else
{
<button class="btn btn-primary mb-3" @οnclick="ShowCreateModal">
添加产品
</button>
<table class="table table-striped">
<thead>
<tr>
<th>ID</th>
<th>名称</th>
<th>价格</th>
<th>库存</th>
<th>操作</th>
</tr>
</thead>
<tbody>
@foreach (var product in products)
{
<tr>
<td>@product.Id</td>
<td>@product.Name</td>
<td>@product.Price.ToString("C")</td>
<td>@product.Stock</td>
<td>
<button class="btn btn-sm btn-warning" @οnclick="() => EditProduct(product)">
编辑
</button>
<button class="btn btn-sm btn-danger" @οnclick="() => DeleteProduct(product.Id)">
删除
</button>
</td>
</tr>
}
</tbody>
</table>
}
@if (showCreateModal)
{
<div class="modal show d-block" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">添加产品</h5>
<button type="button" class="btn-close" @οnclick="CloseModal"></button>
</div>
<div class="modal-body">
<div class="form-group mb-3">
<label>名称</label>
<input class="form-control" @bind="newProduct.Name" />
</div>
<div class="form-group mb-3">
<label>价格</label>
<input class="form-control" type="number" @bind="newProduct.Price" />
</div>
<div class="form-group mb-3">
<label>库存</label>
<input class="form-control" type="number" @bind="newProduct.Stock" />
</div>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" @οnclick="CloseModal">取消</button>
<button class="btn btn-primary" @οnclick="CreateProduct">保存</button>
</div>
</div>
</div>
</div>
<div class="modal-backdrop show"></div>
}
@code {
private List<Product> products = new();
private bool isLoading = true;
private bool showCreateModal = false;
private CreateProductRequest newProduct = new();
protected override async Task OnInitializedAsync()
{
await LoadProducts();
}
private async Task LoadProducts()
{
isLoading = true;
try
{
products = await ApiClient.GetProductsAsync();
Logger.LogInformation("加载了 {Count} 个产品", products.Count);
}
catch (Exception ex)
{
Logger.LogError(ex, "加载产品失败");
}
finally
{
isLoading = false;
StateHasChanged();
}
}
private void ShowCreateModal()
{
newProduct = new CreateProductRequest();
showCreateModal = true;
}
private void CloseModal()
{
showCreateModal = false;
}
private async Task CreateProduct()
{
try
{
var product = await ApiClient.CreateProductAsync(newProduct);
products.Add(product);
CloseModal();
Logger.LogInformation("添加产品成功:{Name}", product.Name);
}
catch (Exception ex)
{
Logger.LogError(ex, "添加产品失败");
}
}
private async Task DeleteProduct(int id)
{
if (await Confirm("确定要删除吗?"))
{
try
{
await ApiClient.DeleteProductAsync(id);
products.RemoveAll(p => p.Id == id);
Logger.LogInformation("删除产品成功:{Id}", id);
}
catch (Exception ex)
{
Logger.LogError(ex, "删除产品失败");
}
}
}
private async Task<bool> Confirm(string message)
{
return await Task.FromResult(true); // 实际应使用 JS 互操作
}
}
17.6 JavaScript 互操作
17.6.1 调用 JavaScript
razor
@page "/js-interop"
@inject IJSRuntime JS
<h3>JavaScript 互操作</h3>
<div class="row">
<div class="col-md-6">
<h4>浏览器 API</h4>
<button class="btn btn-info" @οnclick="ShowAlert">显示 Alert</button>
<button class="btn btn-info" @οnclick="GetBrowserInfo">获取浏览器信息</button>
<button class="btn btn-info" @οnclick="SetTitle">修改标题</button>
<button class="btn btn-info" @οnclick="CopyToClipboard">复制文本</button>
</div>
<div class="col-md-6">
<h4>LocalStorage</h4>
<div class="form-group mb-3">
<label>键</label>
<input class="form-control" @bind="storageKey" />
</div>
<div class="form-group mb-3">
<label>值</label>
<input class="form-control" @bind="storageValue" />
</div>
<button class="btn btn-primary" @οnclick="SaveToLocalStorage">保存</button>
<button class="btn btn-secondary" @οnclick="LoadFromLocalStorage">读取</button>
<p class="mt-2">读取结果:@storageResult</p>
</div>
</div>
@code {
private string storageKey = "myKey";
private string storageValue = "";
private string storageResult = "";
private async Task ShowAlert()
{
await JS.InvokeVoidAsync("alert", "Hello from Blazor!");
}
private async Task GetBrowserInfo()
{
var userAgent = await JS.InvokeAsync<string>("navigator.userAgent");
await JS.InvokeVoidAsync("alert", $"User Agent: {userAgent}");
}
private async Task SetTitle()
{
await JS.InvokeVoidAsync("eval", "document.title = 'Blazor 应用'");
}
private async Task CopyToClipboard()
{
var text = "这是要复制的文本";
await JS.InvokeVoidAsync("navigator.clipboard.writeText", text);
await JS.InvokeVoidAsync("alert", "已复制到剪贴板");
}
private async Task SaveToLocalStorage()
{
await JS.InvokeVoidAsync("localStorage.setItem", storageKey, storageValue);
}
private async Task LoadFromLocalStorage()
{
storageResult = await JS.InvokeAsync<string>("localStorage.getItem", storageKey);
}
}
17.6.2 从 JavaScript 调用 .NET
csharp
// 在 C# 中定义可调用的方法
[JSInvokable]
public static Task<string> GetDataFromCSharp(string input)
{
Console.WriteLine($"JavaScript 调用了 C# 方法,参数: {input}");
return Task.FromResult($"C# 返回:你发送了 '{input}'");
}
[JSInvokable]
public static Task<int> AddNumbers(int a, int b)
{
return Task.FromResult(a + b);
}
javascript
// wwwroot/js/callDotNet.js
window.callDotNetMethods = async (dotNetHelper) => {
// 调用 .NET 方法
const result1 = await dotNetHelper.invokeMethodAsync('GetDataFromCSharp', 'Hello from JS');
console.log(result1);
const result2 = await dotNetHelper.invokeMethodAsync('AddNumbers', 5, 3);
console.log(`5 + 3 = ${result2}`);
};
razor
@page "/call-dotnet"
@inject IJSRuntime JS
<h3>从 JavaScript 调用 .NET</h3>
<button class="btn btn-primary" @οnclick="CallFromJS">从 JS 调用 .NET</button>
@code {
private DotNetObjectReference<CallDotNetPage>? dotNetHelper;
protected override void OnInitialized()
{
dotNetHelper = DotNetObjectReference.Create(this);
}
private async Task CallFromJS()
{
await JS.InvokeVoidAsync("callDotNetMethods", dotNetHelper);
}
[JSInvokable]
public async Task<string> GetDataFromCSharp(string input)
{
Console.WriteLine($"JavaScript 调用: {input}");
return $"C# 处理完成: {input}";
}
[JSInvokable]
public async Task<int> AddNumbers(int a, int b)
{
return a + b;
}
public void Dispose()
{
dotNetHelper?.Dispose();
}
}
17.7 状态管理
17.7.1 使用依赖注入实现状态共享
csharp
// Services/AppState.cs
public class AppState
{
private int _counter;
private string _userName;
private List<Notification> _notifications = new();
public event Action? OnChange;
public int Counter
{
get => _counter;
set
{
if (_counter != value)
{
_counter = value;
NotifyStateChanged();
}
}
}
public string UserName
{
get => _userName;
set
{
if (_userName != value)
{
_userName = value;
NotifyStateChanged();
}
}
}
public IReadOnlyList<Notification> Notifications => _notifications;
public void AddNotification(string message, NotificationType type)
{
_notifications.Add(new Notification
{
Id = Guid.NewGuid(),
Message = message,
Type = type,
Timestamp = DateTime.Now
});
NotifyStateChanged();
}
public void RemoveNotification(Guid id)
{
_notifications.RemoveAll(n => n.Id == id);
NotifyStateChanged();
}
private void NotifyStateChanged() => OnChange?.Invoke();
}
public class Notification
{
public Guid Id { get; set; }
public string Message { get; set; }
public NotificationType Type { get; set; }
public DateTime Timestamp { get; set; }
}
public enum NotificationType
{
Info,
Success,
Warning,
Error
}
// Program.cs - 注册为 Scoped(每个连接一个实例)
builder.Services.AddScoped<AppState>();
17.7.2 使用共享状态
razor
@* 计数器组件 *@
@inject AppState AppState
<h3>计数器</h3>
<p>当前计数:@AppState.Counter</p>
<button class="btn btn-primary" @οnclick="Increment">增加</button>
@code {
protected override void OnInitialized()
{
AppState.OnChange += StateHasChanged;
}
private void Increment()
{
AppState.Counter++;
AppState.AddNotification($"计数器增加到 {AppState.Counter}", NotificationType.Info);
}
public void Dispose()
{
AppState.OnChange -= StateHasChanged;
}
}
@* 通知中心组件 *@
@inject AppState AppState
<h3>通知 (@AppState.Notifications.Count)</h3>
@foreach (var notification in AppState.Notifications)
{
<div class="alert alert-@GetAlertClass(notification.Type) alert-dismissible">
@notification.Message
<button type="button" class="btn-close" @οnclick="() => AppState.RemoveNotification(notification.Id)"></button>
<small class="d-block text-muted">@notification.Timestamp.ToLocalTime()</small>
</div>
}
@code {
protected override void OnInitialized()
{
AppState.OnChange += StateHasChanged;
}
private string GetAlertClass(NotificationType type) => type switch
{
NotificationType.Info => "info",
NotificationType.Success => "success",
NotificationType.Warning => "warning",
NotificationType.Error => "danger",
_ => "secondary"
};
public void Dispose()
{
AppState.OnChange -= StateHasChanged;
}
}
17.8 综合示例:待办事项应用
razor
@page "/todo"
@using System.Text.Json
@inject IJSRuntime JS
@implements IDisposable
<h3>待办事项</h3>
<div class="row mb-3">
<div class="col-md-8">
<input class="form-control" @bind="newTodoTitle" @bind:event="oninput"
placeholder="输入新任务..." @οnkeydοwn="HandleKeyDown" />
</div>
<div class="col-md-2">
<select class="form-select" @bind="newTodoPriority">
<option value="低">低优先级</option>
<option value="中">中优先级</option>
<option value="高">高优先级</option>
</select>
</div>
<div class="col-md-2">
<button class="btn btn-primary w-100" @οnclick="AddTodo" disabled="@string.IsNullOrWhiteSpace(newTodoTitle)">
添加
</button>
</div>
</div>
<div class="btn-group mb-3">
<button class="btn @(filter == "all" ? "btn-primary" : "btn-outline-secondary")" @οnclick="() => SetFilter("all")">
全部 (@todos.Count)
</button>
<button class="btn @(filter == "active" ? "btn-primary" : "btn-outline-secondary")" @οnclick="() => SetFilter("active")">
未完成 (@todos.Count(t => !t.IsCompleted))
</button>
<button class="btn @(filter == "completed" ? "btn-primary" : "btn-outline-secondary")" @οnclick="() => SetFilter("completed")">
已完成 (@todos.Count(t => t.IsCompleted))
</button>
</div>
<div class="list-group">
@foreach (var todo in filteredTodos)
{
<div class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<div class="form-check">
<input class="form-check-input" type="checkbox" @bind="todo.IsCompleted"
id="todo-@todo.Id" />
<label class="form-check-label @(todo.IsCompleted ? "text-decoration-line-through" : "")"
for="todo-@todo.Id">
@todo.Title
</label>
<span class="badge bg-@GetPriorityColor(todo.Priority) ms-2">
@todo.Priority
</span>
<small class="text-muted ms-2">
@todo.CreatedAt.ToString("yyyy-MM-dd HH:mm")
</small>
</div>
<button class="btn btn-sm btn-danger" @οnclick="() => DeleteTodo(todo.Id)">
删除
</button>
</div>
</div>
}
</div>
@if (filteredTodos.Count == 0)
{
<div class="text-center text-muted mt-4">
@if (filter == "all")
{
<p>还没有待办事项,添加一个吧!</p>
}
else if (filter == "active")
{
<p>所有任务都完成了!🎉</p>
}
else
{
<p>还没有已完成的任务</p>
}
</div>
}
<div class="mt-3">
<button class="btn btn-outline-danger" @οnclick="ClearCompleted" disabled="@(todos.All(t => !t.IsCompleted))">
清除已完成
</button>
<button class="btn btn-outline-secondary ms-2" @οnclick="ExportToJson">
导出 JSON
</button>
<button class="btn btn-outline-secondary ms-2" @οnclick="ImportFromJson">
导入 JSON
</button>
</div>
@code {
private List<TodoItem> todos = new();
private string newTodoTitle = "";
private string newTodoPriority = "中";
private string filter = "all";
private IEnumerable<TodoItem> filteredTodos => filter switch
{
"active" => todos.Where(t => !t.IsCompleted),
"completed" => todos.Where(t => t.IsCompleted),
_ => todos
};
protected override async Task OnInitializedAsync()
{
await LoadFromLocalStorage();
}
private void AddTodo()
{
if (string.IsNullOrWhiteSpace(newTodoTitle))
return;
todos.Add(new TodoItem
{
Id = Guid.NewGuid(),
Title = newTodoTitle,
Priority = newTodoPriority,
IsCompleted = false,
CreatedAt = DateTime.Now
});
newTodoTitle = "";
SaveToLocalStorage();
}
private void DeleteTodo(Guid id)
{
todos.RemoveAll(t => t.Id == id);
SaveToLocalStorage();
}
private void ClearCompleted()
{
todos.RemoveAll(t => t.IsCompleted);
SaveToLocalStorage();
}
private void SetFilter(string newFilter)
{
filter = newFilter;
}
private void HandleKeyDown(KeyboardEventArgs e)
{
if (e.Key == "Enter")
{
AddTodo();
}
}
private string GetPriorityColor(string priority) => priority switch
{
"高" => "danger",
"中" => "warning",
"低" => "info",
_ => "secondary"
};
private async Task SaveToLocalStorage()
{
var json = JsonSerializer.Serialize(todos);
await JS.InvokeVoidAsync("localStorage.setItem", "todos", json);
}
private async Task LoadFromLocalStorage()
{
var json = await JS.InvokeAsync<string>("localStorage.getItem", "todos");
if (!string.IsNullOrEmpty(json))
{
todos = JsonSerializer.Deserialize<List<TodoItem>>(json) ?? new();
}
}
private async Task ExportToJson()
{
var json = JsonSerializer.Serialize(todos, new JsonSerializerOptions { WriteIndented = true });
await JS.InvokeVoidAsync("downloadFile", "todos.json", json);
}
private async Task ImportFromJson()
{
var json = await JS.InvokeAsync<string>("readUploadedFile");
if (!string.IsNullOrEmpty(json))
{
try
{
todos = JsonSerializer.Deserialize<List<TodoItem>>(json) ?? new();
SaveToLocalStorage();
}
catch (Exception ex)
{
await JS.InvokeVoidAsync("alert", $"导入失败:{ex.Message}");
}
}
}
public void Dispose()
{
SaveToLocalStorage();
}
private class TodoItem
{
public Guid Id { get; set; }
public string Title { get; set; }
public string Priority { get; set; }
public bool IsCompleted { get; set; }
public DateTime CreatedAt { get; set; }
}
}
17.9 常见错误与陷阱
错误1:在异步方法中忘记调用 StateHasChanged
csharp
// ❌ 错误:UI 不会更新
private async Task LoadData()
{
var data = await api.GetDataAsync();
items = data;
// 忘记调用 StateHasChanged
}
// ✅ 正确
private async Task LoadData()
{
var data = await api.GetDataAsync();
items = data;
StateHasChanged(); // 通知 UI 更新
}
错误2:在循环中多次调用 StateHasChanged
csharp
// ❌ 错误:频繁触发渲染
foreach (var item in items)
{
Process(item);
StateHasChanged(); // 每次循环都渲染
}
// ✅ 正确:循环结束后统一渲染
foreach (var item in items)
{
Process(item);
}
StateHasChanged(); // 只渲染一次
错误3:在 Blazor Server 中使用长时间运行的同步操作
csharp
// ❌ 错误:阻塞 UI 线程
private void LoadLargeData()
{
Thread.Sleep(5000); // 阻塞 5 秒,UI 卡死
data = LoadFromDatabase();
}
// ✅ 正确:使用异步
private async Task LoadLargeData()
{
await Task.Delay(5000);
data = await LoadFromDatabaseAsync();
}
错误4:忘记释放 DotNetObjectReference
csharp
// ❌ 错误:内存泄漏
protected override void OnInitialized()
{
var objRef = DotNetObjectReference.Create(this);
js.InvokeVoidAsync("registerCallback", objRef);
// 没有释放
}
// ✅ 正确
private DotNetObjectReference<MyComponent>? objRef;
protected override void OnInitialized()
{
objRef = DotNetObjectReference.Create(this);
js.InvokeVoidAsync("registerCallback", objRef);
}
public void Dispose()
{
objRef?.Dispose();
}
17.10 本章总结
核心知识点导图
text
Blazor
├── 模式选择
│ ├── Blazor Server
│ └── Blazor WebAssembly
│
├── 组件基础
│ ├── Razor 语法
│ ├── 参数(Parameter)
│ ├── 生命周期
│ └── 级联参数
│
├── 数据绑定
│ ├── 单向绑定(@bind)
│ ├── 双向绑定
│ ├── 事件绑定(@onclick)
│ └── 表达式绑定
│
├── 表单验证
│ ├── EditForm
│ ├── 数据注解
│ └── 自定义验证
│
├── Web API 集成
│ ├── HttpClient
│ ├── CRUD 操作
│ └── 加载状态
│
├── JS 互操作
│ ├── 调用 JS
│ ├── 从 JS 调用 .NET
│ └── 本地存储
│
└── 状态管理
├── 依赖注入共享
└── 事件通知
17.11 练习题
基础题
-
创建一个 Blazor Server 项目,添加一个简单的计算器组件。
-
创建一个购物车组件,支持添加商品、删除商品、计算总价。
-
使用 EditForm 创建一个用户信息表单,包含姓名、邮箱、电话等字段,并添加验证。
应用题
-
实现一个天气应用:
-
调用免费的天气 API
-
显示当前天气和未来 5 天预报
-
支持搜索城市
-
-
实现一个简单的聊天应用:
-
使用 SignalR(Blazor Server 自带)
-
支持多用户同时聊天
-
显示在线用户列表
-
挑战题
-
实现一个文件上传组件:
-
支持拖拽上传
-
显示上传进度
-
支持图片预览
-
-
实现一个数据表格组件:
-
支持排序(点击列头排序)
-
支持筛选
-
支持分页
-
支持导出 Excel
-