C# 零基础到精通教程 - 第十七章:前端集成——Blazor 基础

第十六章我们学习了 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 练习题

基础题

  1. 创建一个 Blazor Server 项目,添加一个简单的计算器组件。

  2. 创建一个购物车组件,支持添加商品、删除商品、计算总价。

  3. 使用 EditForm 创建一个用户信息表单,包含姓名、邮箱、电话等字段,并添加验证。

应用题

  1. 实现一个天气应用:

    • 调用免费的天气 API

    • 显示当前天气和未来 5 天预报

    • 支持搜索城市

  2. 实现一个简单的聊天应用:

    • 使用 SignalR(Blazor Server 自带)

    • 支持多用户同时聊天

    • 显示在线用户列表

挑战题

  1. 实现一个文件上传组件:

    • 支持拖拽上传

    • 显示上传进度

    • 支持图片预览

  2. 实现一个数据表格组件:

    • 支持排序(点击列头排序)

    • 支持筛选

    • 支持分页

    • 支持导出 Excel

相关推荐
LDR0061 小时前
LDR6020:多 Type‑C 端口角色管理与外设上电顺序的智慧核心
c语言·开发语言·云计算
daopuyun1 小时前
《C#语言源代码漏洞测试规范》解读,如何依据GB/T 34946-2017标准建立代码测试技术体系
c#·代码测试·源代码安全检测
小杍随笔1 小时前
【Rust 工具链管理完全指南:rustup toolchain 命令实战详解】
开发语言·后端·rust
五月君_1 小时前
放弃 Python,Kimi 用 TS + Node.js 重写了一个 Kimi Code
开发语言·python·node.js
Cloud_Shy6182 小时前
解读《Effective Python 3rd Edition》:从练气到老魔
开发语言·python
雨辰AI2 小时前
MySQL 迁移至达梦 DM9 完整改造指南|99% SQL 零改动
java·开发语言·数据库·sql·mysql·政务
弹简特2 小时前
【Java项目-轻聊】05-AI赋能设计接口文档
java·开发语言
AI行业学习2 小时前
.NET Framework 3.5 SP1 完整离线包(2029.5.29)
开发语言·windows·.net
cany10002 小时前
C++ -- 队列std::queue
开发语言·c++