☰
Blazor组件式开发实战:基于SqlServer与EF Core的数据管理
2026/10/2 9:10:54 网站建设 项目流程

简介:基于C#与ASP.NET的Blazor组件式开发案例,基于Core 6.0框架,使用Visual Studio 2022开发,适合正在学习Blazor组件复用、数据库操作与前后端分离的.NET开发者。压缩包共169个文件,主要类型包括dll程序集、C#逻辑源码、CSS样式、JSON配置文件以及Razor组件文件,并附带SQL脚本、说明文档和运行效果截图,整体体积约5.1MB,目录规范,便于按需查阅。目前已有517人学习下载,案例围绕数据的新增、删除、修改、查询和明细显示展开,所有数据操作均调用同一个公共组件,可显著减少组件数量与重复代码,直观演示组件参数传递、事件回调以及EF操作SQL Server数据库的核心流程。项目使用SQL Server 2012及以上版本,数据库访问采用EF方式,适合有一定.NET基础、希望深入理解组件式结构的读者。解压后“说明”文件夹内含运行效果截图和代码说明,增删改功能均已测试通过,可直接打开项目对照学习,也可作为课程设计或Blazor实际项目的参考模板,帮助快速掌握组件式开发与数据库交互的落地方法。

1. 为什么我要把一个 SqlServer 后台项目做成 Blazor 组件式:先交底再动手

如果你和我一样,常年和 C#、Asp.net、SqlServer 这三个词打交道,大概率会遇到一个尴尬场景:公司要做一个带数据库增删改查的内部管理系统,工期紧,团队没人愿意碰前端。我最初对 Blazor 是有偏见的,觉得它就是个“套了 C# 皮的网页组件框架”,直到我在 .NET 6.0 下把一个完整的 SqlServer 案例从零搭起来,才意识到组件式开发真正的价值在于:ViewModel 和 UI 状态都在 C# 里,后端逻辑和前端渲染逻辑用同一套语言,几乎没有“前后端接口对不上”的吵架空间。这个项目全称是“基于 C# Asp.net Blazor 组件式开发的 Blazor 案例”,核心就是围绕 Blazor 组件、SqlServer 数据库和 EF Core 数据访问展开,适合想从 WebForms 或 Asp.net MVC 迁移过来的 .NET 开发者,也适合被前端工程化折腾到崩溃的 C# 上位机开发者。

2. 从空项目到数据库连通:项目骨架与 EF Core 接入细节

2.1 为什么选 Blazor Server 而不是 Blazor WebAssembly

这个案例选择的是 Blazor Server 模式。理由非常直接:项目里要连 SqlServer,数据查询和写入都在服务端完成,Blazor Server 通过 SignalR 长连接把 UI 事件传到服务端,再由服务端渲染回传,整个数据链路都在同一台服务器上,不需要像 WebAssembly 那样把数据库连接串暴露到浏览器端。数据安全性和开发成本上,Server 模式都更适合企业内部案例。

我一般会建议新手优先看 Server 模式,原因有三点:

  • 调试体验接近传统 Asp.net,断点能直接打在 .cs 文件里,不用像前端那样开 DevTools 疯狂找请求。
  • 数据库操作和 UI 事件处理可以写在同一个类里,代码量明显比 MVC + jQuery 少。
  • 部署不需要额外配 Nginx 托管静态文件,一个 Asp.net 站点就能跑。

2.2 创建项目与依赖注入的三处关键配置

先把项目骨架建出来。用 .NET 6.0 的 CLI 创建 Blazor Server 项目,命令如下:

dotnet new blazorserver -n BlazorCase -f net6.0 cd BlazorCase dotnet add package Microsoft.EntityFrameworkCore.SqlServer dotnet add package Microsoft.EntityFrameworkCore.Tools

创建完项目后,打开 Program.cs,你需要把 DbContext 注册到依赖注入容器里,同时配置连接串读取逻辑,常见做法是:

using BlazorCase.Data; using Microsoft.EntityFrameworkCore; var builder = WebApplication.CreateBuilder(args); // 从 appsettings.json 读取连接串 var connectionString = builder.Configuration.GetConnectionString("DefaultConnection"); // 注册 DbContext,指定 SqlServer 作为数据库提供程序 builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(connectionString)); // Blazor Server 必需:注册服务端组件服务 builder.Services.AddRazorPages(); builder.Services.AddServerSideBlazor(); var app = builder.Build(); app.UseStaticFiles(); app.UseRouting(); app.MapRazorPages(); app.MapBlazorHub(); app.MapFallbackToPage("/_Host"); app.Run();

这里有三处必须注意的参数:UseSqlServer指定了数据库提供程序,如果漏掉它会默认走内存数据库,数据一切换就全部丢光;AddDbContext的生命周期默认是 Scoped,在 Blazor Server 里这正好匹配组件实例的生命周期;MapBlazorHub是 SignalR 通道的入口,它没有注册的话页面会一直转圈不渲染。

2.3 从 SqlServer 生成实体类:逆向工程的两个常用参数

如果是拿现成数据库做例子,不需要手写实体类。我用的是Scaffold-DbContext命令逆向生成:

dotnet tool install --global dotnet-ef dotnet ef dbcontext scaffold "Server=localhost;Database=BlazorCaseDb;User Id=sa;Password=your_password;TrustServerCertificate=True" Microsoft.EntityFrameworkCore.SqlServer -o Models

敲完这条命令后,Models 目录下会自动生成与数据库表对应的实体类。需要注意的是,连接串里TrustServerCertificate=True必须带上,否则在 .NET 6.0 且没有正式证书的环境下,SqlClient 会因为证书校验失败直接抛异常。如果表比较多想只生成指定表,可以追加--table User --table Order这类参数来过滤。

提示:逆向生成完,强烈建议在AppDbContext里检查一下OnModelCreating里的表名映射,SqlServer 里的表如果带 Schema 前缀,EF Core 可能默认映射到dbo,和查询 SQL 不一致时会出现运行时警告。

3. 组件式开发的核心机制:参数传递、事件回调和组件生命周期

3.1 组件拆分的边界:什么时候该把页面拆成 Razor 组件

很多第一次接触 Blazor 的人会问:组件式开发听起来很玄学,到底哪些代码应该拆成组件?我的判断标准很简单:页面上重复出现两次以上的独立 UI 区域,就值得拆成组件。比如设备管理页面里的设备状态卡片、工单列表里的状态标签、仪表盘里的数据统计卡片,这些都在不同页面复用。最常见的拆法是把一个业务实体的“表单 + 列表”拆成两个组件:一个负责输入,一个负责展示。

以项目里的“设备台账查询”为例,我拆出了三个组件:DeviceSearch.razor(搜索条件区)、DeviceTable.razor(结果列表区)、DeviceStatusTag.razor(状态标签)。这样做的价值在于:设备状态标签这个组件只接收一个枚举值,内部负责把状态值映射为颜色和文字,其他页面要显示同样的标签时直接引用即可,不用重写样式逻辑。

组件的数量不是越多越好。组件拆得太碎,参数传递会变得非常啰嗦,父组件里一层层往子组件传对象,维护成本反而上升。我一般遵守一个原则:子组件只关心自己的局部状态,父组件负责所有跨组件共享的数据。

3.2 组件参数与事件回调的标准写法

组件之间通信的两种基本方式是参数传递和事件回调。先看参数传递的写法:

@code { // 组件参数:父组件通过这个属性传值进来 [Parameter] public string Title { get; set; } // 级联参数:从父组件往下传,不用每个子组件手动指定 [CascadingParameter] public DeviceContext Context { get; set; } [Parameter] public EventCallback<string> OnSearchCompleted { get; set; } }

[Parameter]属性必须声明成 public 属性,Blazor 框架在渲染时会把父组件里写的Title="xxx"映射到这个属性上,这个过程是自动完成的,不需要额外的绑定代码。[CascadingParameter]则用于那些每个子组件都要用的共享对象,比如登录用户信息、全局配置对象,这类参数如果每个页面都手工传一遍,代码会很冗余,用级联参数后只需要在顶层组件包一层<CascadingValue Value="@context">。

事件回调的写法相对特别一些,EventCallback本质上是一个封装好的委托,子组件触发它时,父组件里注册的方法会被框架自动调用。注意,EventCallback是泛型类型,里面的类型参数表示回调方法接收的参数类型,如果你不想传递任何数据,可以声明为EventCallback不带泛型参数。

3.3 生命周期钩子:什么时候查数据,什么时候释放资源

组件式开发最容易翻车的环节是生命周期。Blazor Server 组件的生命周期和 WebForms 有点像,但又不完全一样,主要有四个钩子:

生命周期方法执行时机常见用途
OnInitializedAsync组件第一次初始化时查询初始数据
OnParametersSet父组件传参更新后根据参数值重新加载数据
OnAfterRenderAsync每次渲染完成后调用 JavaScript 操作 DOM
IDisposable.Dispose组件销毁时释放订阅、定时器、数据库连接

我踩过的一个具体坑是:在OnInitializedAsync里查数据库时,没有判断组件是不是已经被销毁,导致用户快速切换页面时查询线程还在跑,返回后去更新一个已经被释放的组件状态,直接抛出ObjectDisposedException。解决方式是定义一个CancellationTokenSource,在Dispose里取消查询:

@implements IDisposable @code { private CancellationTokenSource _cts = new CancellationTokenSource(); protected override async Task OnInitializedAsync() { // 把 CancellationToken 传给 EF Core 的 ToListAsync _items = await _context.Devices .Where(x => x.IsActive) .ToListAsync(_cts.Token); } public void Dispose() { // 组件销毁时主动取消还在跑的数据库查询 _cts.Cancel(); _cts.Dispose(); } }

这里把CancellationToken传给ToListAsync是很多人会忽略的细节。EF Core 的异步方法都支持取消令牌,一旦组件销毁,令牌被取消后查询就会及时终止,不会白白占用数据库连接池里的连接,这在大并发场景下能明显减少超时报警。

4. SqlServer 数据交互实战:一个完整模块从表单到数据库落库

4.1 表单绑定与验证:指定Value和ValueChanged的来龙去脉

组件式开发里,表单绑定最常用的语法是@bind-Value,它等价于同时指定Value参数和ValueChanged回调。以项目里的设备信息编辑表单为例:

<EditForm Model="@device" OnValidSubmit="@HandleValidSubmit"> <DataAnnotationsValidator /> <ValidationSummary /> <div class="form-group"> <label>设备名称</label> <InputText @bind-Value="device.DeviceName" class="form-control" /> </div> <div class="form-group"> <label>所属车间</label> <InputSelect @bind-Value="device.Workshop"> <option value="">请选择车间</option> <option value="A车间">A车间</option> <option value="B车间">B车间</option> </InputSelect> </div> <button type="submit" class="btn btn-primary">保存</button> </EditForm>

@bind-Value在编译后会展开成Value="device.DeviceName"和ValueChanged="(value) => device.DeviceName = value"。理解这一点很重要:当你想在绑定同时做额外逻辑时,就直接手动声明这两个参数,而不是硬套@bind-Value。比如设备名称输入后需要立即去掉前后空格,可以写成:

<InputText Value="@device.DeviceName" ValueChanged="@((string val) => OnNameChanged(val))" />
private void OnNameChanged(string val) { driver.DeviceName = val?.Trim() ?? string.Empty; }

表单验证用的是DataAnnotationsValidator组件,它会在提交时自动校验 Model 上的数据注解。整个机制是异步的,校验失败时OnValidSubmit不会触发,而OnInvalidSubmit会执行,错误信息会展示在ValidationSummary里。

4.2 数据列表加载与分页:不要一次性查全表

项目里查询设备列表时,我第一次犯的错误是直接用ToListAsync()把整张表的数据拉到内存,导致页面渲染几千行数据,浏览器直接卡死。后来改成了服务端分页,关键代码长这样:

public async Task<(List<Device> Data, int Total)> GetDevicesAsync(int pageIndex, int pageSize, string keyword) { var query = _context.Devices.AsNoTracking(); if (!string.IsNullOrWhiteSpace(keyword)) { query = query.Where(x => x.DeviceName.Contains(keyword) || x.ModelNumber.Contains(keyword)); } var total = await query.CountAsync(); var data = await query .OrderByDescending(x => x.CreatedAt) .Skip((pageIndex - 1) * pageSize) .Take(pageSize) .ToListAsync(); return (data, total); }

分页查询有两个细节容易忽略。第一是AsNoTracking(),如果查询出来的实体不需要跟踪修改,加上这个标记会明显提升查询性能,EF Core 不会为返回的实体创建变更跟踪快照;第二是CountAsync和ToListAsync要分开执行,如果先ToQueryString()再直接ToList会导致每一次翻页都要把全表数据计算完再 Skip,SqlServer 的 I/O 压力会成倍增加。EF Core 的Skip和Take最终会生成OFFSET分页 SQL,这是 SqlServer 2012 及以上版本的标准实现。

组件里调用这个方法时,分页参数由 UI 上的页码控件提供:

private async Task LoadDataAsync() { _isLoading = true; var result = await _service.GetDevicesAsync(_currentPage, _pageSize, _keyword); _devices = result.Data; _totalCount = result.Total; _totalPages = (int)Math.Ceiling(_totalCount / (double)_pageSize); _isLoading = false; }

4.3 批量操作与事务:多表写入时保证数据一致性

在设备管理模块里,除了单条增删改,还有一种“批量报废”场景:选择多条设备,统一将它们标记为报废状态,同时写入一条操作日志到另一个表。这个场景必须用事务来保证一致性,我再三强调这一点,是因为很多人只做单表操作,没有意识到 EF Core 的SaveChangesAsync默认是隐式事务,一次性提交多个脏实体时才自动包裹。

涉及多个 DbContext 的写操作时,必须手动控制事务:

public async Task BatchScrapAsync(List<int> deviceIds, string operatorName) { await using var transaction = await _context.Database.BeginTransactionAsync(); try { var devices = await _context.Devices .Where(x => deviceIds.Contains(x.Id)) .ToListAsync(); foreach (var device in devices) { device.Status = "报废"; device.ScrapTime = DateTime.Now; } var log = new OperationLog { DeviceIds = string.Join(",", deviceIds), OperationType = "批量报废", Operator = operatorName, OperatedAt = DateTime.Now }; _context.OperationLogs.Add(log); await _context.SaveChangesAsync(); await transaction.CommitAsync(); } catch (Exception) { // 出任何异常都回滚,保证不会出现“设备已报废但日志没写入” await transaction.RollbackAsync(); throw; } }

这里的事务写法是从 .NET 6.0 开始推荐的方式,BeginTransactionAsync获得的事务对象可以配合await using进行异步释放。需要注意SaveChangesAsync只需要调用一次,因为 EF Core 的上下文会跟踪devices的修改和新增的log实体,最终生成一个包含 UPDATE 和 INSERT 语句的批量 SQL 提交,而不是多次往返数据库。

5. Blazor + SqlServer 避坑实录:五条血泪经验帮你少走一天弯路

5.1 连接串里缺TrustServerCertificate=True,部署后连不上数据库

现象是本地跑得好好的,发布到服务器上后页面一打开就报错,错误信息大致是“证书链是由不受信任的颁发机构颁发的”。原因很简单:目标服务器上的 SqlServer 使用自签名证书,而 .NET 6.0 的 SqlClient 默认要求校验证书。解决方法是把TrustServerCertificate=True显式写进连接串,这一点我在前面已经提到过,但值得单独拿出来再说一次:如果不想在连接串里明文写密码,可以改用ManagedIdentity认证,不过在小团队内网环境里直接信任证书更省事。

5.2 Blazor Server 页面长时间不操作后重连失败

现象是浏览器挂着页面过了一晚,第二天点按钮没反应,F12 能看到 SignalR 连接状态是Disconnected。原因是 Blazor Server 默认的DisconnectTimeout是 30 秒,如果在这段时间内没有重新连上,服务器就会释放该电路并销毁组件状态。解决思路有两个方向:一是前端定期发送心跳请求保活,二是在_Host.cshtml里配置重新连接机制。实际项目中,我更倾向把长时间无操作后自动释放这件事保留下来,但在Program.cs里把DetailedErrors打开,方便排查掉线后恢复失败的具体异常。

注意:如果页面里有正在编辑的大段表单数据,Blazor Server 的掉线会直接导致未保存的数据丢失,这是它和 WebAssembly 相比最大的痛点,选型时务必考虑到。

5.3 组件参数变更后 UI 不刷新,数据还是旧值

现象是在父组件里更新了传给子组件的对象属性,子组件显示的内容没有变化。原因很隐蔽:Blazor 的渲染对比是基于组件参数引用的,父组件只是修改了传入对象的某个属性,对象的引用没有变化,框架也就不会通知子组件重新渲染。解决方式有两种:一是修改后手动调用StateHasChanged();二是把这部分数据重新赋值成新对象,强制引用变更。我第一次遇到这个问题时排查了半天找不到原因,后来意识到是引用传递而非值传递的问题。现在写组件参数前都会先问一句:这里到底传的是对象引用还是基础类型的值。

5.4 EF Core 追踪多个实体时 FK 冲突

现象是保存订单时给Order实体赋值了已存在的CustomerId,但是在插入 Order 时 EF Core 同时把赋值给它的Customer对象也当作新增实体处理,导致主键冲突。原因是在同一个 DbContext 生命周期里,EF Core 的变更追踪器把所有到达Added状态的实体都视为新增,你手动指定的CustomerId和导航属性引用的Customer产生了冲突。解决方法是手动将导航属性置空,只保留外键属性:

var newOrder = new Order { CustomerId = existingCustomer.Id, // 不要给 Customer 导航属性赋值 CreatedAt = DateTime.Now }; _context.Orders.Add(newOrder); await _context.SaveChangesAsync();

5.5 布到 IIS 后 HttpContext 为 null

现象是代码里用IHttpContextAccessor获取用户 IP,本地运行时正常,部署到 IIS 后访问属性直接空引用。原因是 Blazor Server 组件里的作用域是 SignalR 而非 HttpContext,多数情况下组件运行在异步回调上下文中,没有直接关联的 HttpContext。解决方式是在组件初始化时将需要的 HttpContext 信息(如 IP、Claim)提取出来存入局部变量:

protected override void OnInitialized() { var httpContext = _httpContextAccessor.HttpContext; if (httpContext != null) { _currentUser = httpContext.User.Identity.Name; _userIp = httpContext.Connection.RemoteIpAddress?.ToString(); } }

6. 把组件复用做到极致:从列表页到编辑页的无缝衔接技巧

项目做到后期,设备列表和设备编辑是两个独立页面,用户每点一次“编辑”就要从列表页跳转到编辑页,保存后再跳转回来,体验和开发成本都不理想。我在第二个版本里把编辑功能改成了弹窗式组件,同时利用EventCallback把保存成功后的列表刷新做成了完全无感知的联动。

做法是先在父组件也就是列表页里定义一个布尔状态和一个当前选中实体:

private bool _showEditDialog; private Device _selectedDevice; private void OpenEditDialog(Device device) { // 传给弹窗组件一个全新的副本,避免弹窗内修改直接污染列表数据 _selectedDevice = new Device { Id = device.Id, DeviceName = device.DeviceName, Workshop = device.Workshop, ModelNumber = device.ModelNumber, Status = device.Status }; _showEditDialog = true; }

弹窗组件内部收到Device类型的参数后,编辑表单的绑定目标就是这个对象,而父组件已经拿到一个深拷贝副本,即使编辑过程中取消了操作,父组件列表中的原始数据也不会受影响。保存按钮触发EventCallback时,我可以把操作结果告诉父组件:

<button class="btn btn-primary" @onclick="HandleSave">保存</button>
private async Task HandleSave() { await _service.UpdateDeviceAsync(_device); // 通知父组件刷新列表,并关闭弹窗 await OnSaved.InvokeAsync(true); }

之后列表页只需要在收到回调后重新查一次数据,将_showEditDialog置回false即可。整套流程没有使用任何 JavaScript,纯 C# 把状态同步做完,这在过去的 Asp.net WebForms 里需要写一堆__doPostBack逻辑,在 MVC 里则需要更繁琐的 Ajax 数据拼接。组件式开发这种“后端驱动状态变更”的思维方式,是 Blazor 对比传统 Web 开发最大的体验提升点。

这个组件复用经验是从一次实际项目教训里提炼出来的:当初第一个版本直接在编辑页修改原设备对象,结果用户点了取消后,列表页显示的名字都已经变了,当时我还纳闷是不是缓存问题,后来才意识到是组件参数引用导致了数据污染。从那以后我每次编写涉及父组件和子组件共享数据对象的逻辑,都会强制走一遍“深拷贝副本 + EventCallback 回调”的流程,宁可多写两个类也不让 ViewModel 被莫名其妙地改掉。希望这一篇能帮你把 Blazor 组件式开发的脉络盘清楚,至少在部署和参数传递这些关键节点上不再走我走过的弯路。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询