ASP.NET Core Razor Pages 实战:从零构建用户反馈系统
如果你是一位 .NET 开发者正在寻找一个比传统 MVC 更轻量、更聚焦的 Web 开发框架或者你厌倦了为每个简单页面都去配置控制器和路由那么 ASP.NET Core Razor Pages 很可能就是你一直在等的答案。很多人第一次接触 Razor Pages 时会误以为它只是 MVC 的一个简化版或“玩具”用来做做后台管理页面。但事实恰恰相反它代表了微软对现代 Web 开发范式的重新思考将页面Page作为组织逻辑的核心单元让代码和视图在物理和逻辑上都更紧密地结合在一起。这不仅仅是语法糖它直接改变了我们构建 Web 应用的思维模式。在过去一个显示用户列表的功能你需要在Controllers文件夹下创建UserController在Views文件夹下创建对应的Index.cshtml还要处理路由映射。而在 Razor Pages 中一个Users文件夹下的Index.cshtml和其背后的Index.cshtml.cs文件就完成了所有工作。这种“页面即功能”的封装极大地提升了开发体验和代码的可维护性尤其适合表单密集、以页面为中心的应用。随着 ASP.NET Core 9 的发布整个生态的现代化和性能提升又达到了新的高度。本文将带你从零开始深入理解 Razor Pages 的核心哲学并通过一个完整的实战项目展示如何用它高效地构建一个功能齐全的网站。我们不仅会跑通“Hello World”更会探讨数据绑定、表单处理、远程验证、页面路由等高级主题并最终将其部署运行。你会发现用它来快速构建原型或开发生产级应用都同样得心应手。1. Razor Pages 解决了什么问题为什么是现在在深入代码之前我们必须先厘清 Razor Pages 的设计初衷。它并非要取代 MVC而是提供了一种更契合特定场景的替代方案。传统 MVC 在 Web 开发中的“错配”MVCModel-View-Controller模式因其清晰的分离关注点而广受欢迎。然而在构建许多典型的 Web 页面时这种分离有时会显得“过度”。考虑一个常见的“用户联系表单”页面控制器Controller需要定义ContactController和Index、Post等 Action。视图View在Views/Contact文件夹下创建Index.cshtml。模型Model可能需要创建ContactViewModel。路由Routing需要在控制器或全局配置中定义路由规则。这一套流程下来你会发现为了一个简单的表单页面你的代码分散在三个甚至四个不同的地方。对于小型应用或功能明确的页面这种开销显得不那么必要。Razor Pages 的“页面中心”模型Razor Pages 将上述所有元素聚合到了一个以.cshtml文件为核心的单元中一个物理文件Contact.cshtml包含了页面的 HTML 结构Razor 视图。一个关联的代码文件Contact.cshtml.cs包含了处理该页面 GET 和 POST 请求的逻辑PageModel。隐式路由文件在Pages文件夹下的路径直接决定了其访问 URL例如/Contact。这种模型带来了几个立竿见影的好处更直观的组织功能相关的所有代码都在同一个地方易于查找和维护。更少的样板代码无需为简单页面创建控制器和配置路由。内置的双向数据绑定通过[BindProperty]特性可以轻松地将表单字段绑定到 PageModel 的属性极大简化了表单处理。更适合组件化与 Partial View、View Component 以及 Blazor 组件化思想一脉相承鼓励构建高内聚的 UI 单元。它适合谁初学者学习曲线比完整的 MVC 更平缓可以更快地看到成果。全栈开发者希望快速构建后台管理系统、数据仪表盘、营销落地页等以页面为核心的应用。MVC 老手在开发某些功能时寻求一种更简洁、更高效的实现方式。它不适合谁构建纯 API 后端服务应使用 Web API 项目模板。构建高度动态、单页面应用SPA风格的前端应考虑 Blazor 或搭配前端框架。2. 核心概念与架构剖析要玩转 Razor Pages必须理解其几个核心构建块。2.1 PageModel页面的“大脑”PageModel是一个类通常位于与.cshtml文件同名的.cshtml.cs文件中。它充当了控制器的角色但作用域仅限于其关联的页面。// 文件路径Pages/Index.cshtml.cs using Microsoft.AspNetCore.Mvc.RazorPages; namespace MyWebApp.Pages { public class IndexModel : PageModel { // 绑定属性用于接收表单数据或向视图传递数据 [BindProperty] public string SearchTerm { get; set; } // 页面处理器处理 GET 请求 public void OnGet() { // 初始化页面数据 } // 页面处理器处理 POST 请求方法名对应表单的 asp-page-handler public IActionResult OnPostSearch() { if (!ModelState.IsValid) { return Page(); // 返回本页显示验证错误 } // 处理搜索逻辑 return RedirectToPage(/SearchResults, new { term SearchTerm }); } // 辅助方法 public string GetGreeting() Hello from PageModel!; } }关键点继承自PageModel。OnGet、OnPost是默认的处理器方法。你可以创建自定义处理器如OnPostSearch。[BindProperty]特性是实现数据绑定的关键支持 GET 和 POST 请求。2.2 Razor 视图 (.cshtml)页面的“面容”Razor 视图文件包含了 HTML 和 Razor 语法用于渲染 UI。它可以直接调用关联PageModel的属性和方法。* 文件路径Pages/Index.cshtml * page model MyWebApp.Pages.IndexModel h1Welcome/h1 pModel.GetGreeting()/p * 调用 PageModel 方法 * form methodpost input asp-forSearchTerm / * 绑定到 PageModel 的 SearchTerm 属性 * button typesubmit asp-page-handlerSearchSearch/button * 触发 OnPostSearch 方法 * /form if (!ViewData.ModelState.IsValid) { div classalert alert-danger div asp-validation-summaryAll/div /div }关键点page指令必须放在第一行它将该文件标记为 Razor Page并启用路由等功能。model指令指定了后端的 PageModel 类型。asp-for、asp-page-handler等 Tag Helper 是 Razor Pages 的利器用于生成正确的 HTML 并绑定逻辑。2.3 路由基于文件系统的约定Razor Pages 的路由极其简单直观根Pages文件夹下的Index.cshtml对应站点根路径/。Pages/About.cshtml对应/About。Pages/Products/Index.cshtml对应/Products和/Products/Index。Pages/Products/Details.cshtml对应/Products/Details。你可以在page指令中自定义路由模板例如page “/product/{id:int}”为页面提供参数化路由。2.4 与 MVC 的对比特性ASP.NET Core MVCASP.NET Core Razor Pages组织单元控制器 (Controller)页面 (Page)代码位置Controller, View, Model 分离PageModel (.cshtml.cs) 与 View (.cshtml) 配对路由基于属性路由或约定路由需配置基于文件路径page指令可自定义数据绑定通过 Action 参数手动绑定通过[BindProperty]自动双向绑定适用场景大型应用、API、需要高度定制路由页面为中心的应用、表单处理、快速开发3. 环境准备与项目创建我们使用最新的 .NET 9 SDK 和 Visual Studio 2022或 VS Code进行演示。1. 检查环境打开终端命令行运行以下命令dotnet --list-sdks确保输出中包含9.x.x版本。如果没有请前往 .NET 官方网站 下载安装。2. 创建新的 Razor Pages 项目使用 .NET CLI 可以快速创建项目骨架# 创建一个名为“RazorPagesWeb”的 Razor Pages 项目 dotnet new webapp -n RazorPagesWeb -o RazorPagesWeb # 进入项目目录 cd RazorPagesWeb # 运行项目默认使用 Kestrel 服务器监听 5000 和 5001 端口 dotnet run执行dotnet run后控制台会输出应用正在监听的 URL通常是https://localhost:5001和http://localhost:5000。在浏览器中打开https://localhost:5001你将看到默认的 Razor Pages 模板页面。3. 项目结构解析使用tree /f命令Windows或find . -type fLinux/macOS查看生成的项目结构核心部分如下RazorPagesWeb/ ├── Pages/ │ ├── Index.cshtml # 主页视图 │ ├── Index.cshtml.cs # 主页 PageModel │ ├── Privacy.cshtml # 隐私页面 │ ├── Privacy.cshtml.cs │ ├── Shared/ # 共享布局和部件 │ │ ├── _Layout.cshtml │ │ ├── _ValidationScriptsPartial.cshtml │ │ └── ... │ └── _ViewImports.cshtml # 全局导入的命名空间和 Tag Helpers │ └── _ViewStart.cshtml # 指定默认布局页 ├── wwwroot/ # 静态资源CSS, JS, 图片 ├── appsettings.json # 应用配置 ├── Program.cs # 应用入口和服务配置 └── RazorPagesWeb.csproj # 项目文件这个结构清晰地体现了“页面中心”的思想所有页面都位于Pages目录下。4. 构建一个完整的用户反馈系统让我们通过构建一个简单的“用户反馈”功能来实践 Razor Pages 的核心特性。该功能包含列表展示、提交表单、详情查看和远程验证。4.1 创建数据模型首先在项目根目录创建一个Models文件夹并添加Feedback.cs类。// 文件路径Models/Feedback.cs using System.ComponentModel.DataAnnotations; namespace RazorPagesWeb.Models { public class Feedback { public int Id { get; set; } [Required(ErrorMessage 请输入您的姓名)] [StringLength(50, ErrorMessage 姓名不能超过50个字符)] [Display(Name 姓名)] public string Name { get; set; } [Required(ErrorMessage 请输入邮箱地址)] [EmailAddress(ErrorMessage 邮箱格式不正确)] [Display(Name 邮箱)] public string Email { get; set; } [Required(ErrorMessage 请填写反馈内容)] [StringLength(1000, ErrorMessage 反馈内容不能超过1000个字符)] [Display(Name 反馈内容)] public string Message { get; set; } public DateTime SubmittedOn { get; set; } DateTime.Now; } }这里使用了数据注解Data Annotations进行属性装饰它们不仅用于后端验证前端的 Tag Helper 也会根据这些注解生成相应的 HTML5 属性和验证信息。4.2 创建服务层模拟数据存储为了简化我们不引入真实数据库而是创建一个内存中的服务。在根目录创建Services文件夹添加IFeedbackService和FeedbackService。// 文件路径Services/IFeedbackService.cs using RazorPagesWeb.Models; namespace RazorPagesWeb.Services { public interface IFeedbackService { TaskListFeedback GetAllAsync(); TaskFeedback? GetByIdAsync(int id); Task AddAsync(Feedback feedback); Taskbool IsEmailUniqueAsync(string email); } }// 文件路径Services/FeedbackService.cs using RazorPagesWeb.Models; namespace RazorPagesWeb.Services { public class FeedbackService : IFeedbackService { private readonly ListFeedback _feedbacks new(); private int _nextId 1; public TaskListFeedback GetAllAsync() { return Task.FromResult(_feedbacks.OrderByDescending(f f.SubmittedOn).ToList()); } public TaskFeedback? GetByIdAsync(int id) { return Task.FromResult(_feedbacks.FirstOrDefault(f f.Id id)); } public Task AddAsync(Feedback feedback) { feedback.Id _nextId; _feedbacks.Add(feedback); return Task.CompletedTask; } // 用于远程验证的方法 public Taskbool IsEmailUniqueAsync(string email) { var isUnique !_feedbacks.Any(f f.Email.Equals(email, StringComparison.OrdinalIgnoreCase)); return Task.FromResult(isUnique); } } }4.3 注册服务在Program.cs中注册我们刚创建的服务为单例Scoped 在生产中更常见这里为演示方便使用单例。// 文件路径Program.cs using RazorPagesWeb.Services; var builder WebApplication.CreateBuilder(args); // 添加 Razor Pages 服务 builder.Services.AddRazorPages(); // 注册我们的反馈服务 builder.Services.AddSingletonIFeedbackService, FeedbackService(); var app builder.Build(); // 配置 HTTP 请求管道 if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler(/Error); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthorization(); app.MapRazorPages(); // 这是关键它映射了所有 Razor Pages 的路由 app.Run();4.4 创建反馈列表页在Pages文件夹下创建Feedback子文件夹然后添加Index.cshtml和Index.cshtml.cs。// 文件路径Pages/Feedback/Index.cshtml.cs using Microsoft.AspNetCore.Mvc.RazorPages; using RazorPagesWeb.Models; using RazorPagesWeb.Services; namespace RazorPagesWeb.Pages.Feedback { public class IndexModel : PageModel { private readonly IFeedbackService _feedbackService; public ListFeedback Feedbacks { get; set; } new(); public IndexModel(IFeedbackService feedbackService) { _feedbackService feedbackService; } public async Task OnGetAsync() { // 异步获取所有反馈 Feedbacks await _feedbackService.GetAllAsync(); } } }* 文件路径Pages/Feedback/Index.cshtml * page model RazorPagesWeb.Pages.Feedback.IndexModel { ViewData[Title] 用户反馈列表; } h1ViewData[Title]/h1 p a asp-pageCreate classbtn btn-primary提交新反馈/a /p if (Model.Feedbacks.Any()) { table classtable thead tr thID/th th姓名/th th邮箱/th th提交时间/th th操作/th /tr /thead tbody foreach (var item in Model.Feedbacks) { tr tditem.Id/td tditem.Name/td tditem.Email/td tditem.SubmittedOn.ToString(yyyy-MM-dd HH:mm)/td td a asp-page./Details asp-route-iditem.Id classbtn btn-sm btn-info查看详情/a /td /tr } /tbody /table } else { div classalert alert-info暂无反馈记录。/div }关键点asp-page”Create”Tag Helper用于生成指向Pages/Feedback/Create.cshtml页面的链接。asp-route-id”item.Id”用于在链接中传递路由参数id。4.5 创建反馈提交页含远程验证这是 Razor Pages 的精华所在展示了强大的表单处理和验证能力。首先创建Create.cshtml.cs// 文件路径Pages/Feedback/Create.cshtml.cs using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using RazorPagesWeb.Models; using RazorPagesWeb.Services; namespace RazorPagesWeb.Pages.Feedback { public class CreateModel : PageModel { private readonly IFeedbackService _feedbackService; public CreateModel(IFeedbackService feedbackService) { _feedbackService feedbackService; } // 使用 [BindProperty] 将表单数据直接绑定到此属性 [BindProperty] public Feedback NewFeedback { get; set; } new(); // 用于远程验证的 Action 方法 [AcceptVerbs(GET, POST)] public async TaskIActionResult OnPostCheckEmailAsync(string email) { // 这里可以添加业务逻辑比如检查邮箱是否已注册 var isUnique await _feedbackService.IsEmailUniqueAsync(email); return new JsonResult(isUnique ? $邮箱 {email} 可用。 : $邮箱 {email} 已被使用。); } // 处理表单提交 public async TaskIActionResult OnPostAsync() { // 手动触发验证虽然前端会验证但后端验证是必须的 if (!ModelState.IsValid) { return Page(); // 返回本页显示验证错误 } await _feedbackService.AddAsync(NewFeedback); // 使用 TempData 传递一次性成功消息 TempData[SuccessMessage] $感谢 {NewFeedback.Name} 的反馈; return RedirectToPage(./Index); } } }关键点[BindProperty]这是魔法发生的地方。表单中asp-for”NewFeedback.Name”的输入框在 POST 请求时会自动将值绑定到NewFeedback.Name属性。OnPostCheckEmailAsync这是一个自定义的页面处理器专门用于处理远程验证请求。它返回JsonResult。OnPostAsync默认的 POST 处理器。ModelState.IsValid会检查[BindProperty]模型的所有数据注解验证规则。接下来创建视图Create.cshtml并实现远程验证* 文件路径Pages/Feedback/Create.cshtml * page model RazorPagesWeb.Pages.Feedback.CreateModel { ViewData[Title] 提交反馈; } h1ViewData[Title]/h1 if (TempData[SuccessMessage] ! null) { div classalert alert-successTempData[SuccessMessage]/div } form methodpost idfeedbackForm div classform-group label asp-forNewFeedback.Name/label input asp-forNewFeedback.Name classform-control / span asp-validation-forNewFeedback.Name classtext-danger/span /div div classform-group label asp-forNewFeedback.Email/label input asp-forNewFeedback.Email classform-control >// 文件路径Pages/Feedback/Details.cshtml.cs using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; using RazorPagesWeb.Models; using RazorPagesWeb.Services; namespace RazorPagesWeb.Pages.Feedback { public class DetailsModel : PageModel { private readonly IFeedbackService _feedbackService; public Feedback FeedbackItem { get; set; } default!; public DetailsModel(IFeedbackService feedbackService) { _feedbackService feedbackService; } public async TaskIActionResult OnGetAsync(int id) { FeedbackItem await _feedbackService.GetByIdAsync(id); if (FeedbackItem null) { return NotFound(); } return Page(); } } }* 文件路径Pages/Feedback/Details.cshtml * page “{id:int}” * 定义路由约束id 必须是整数 * model RazorPagesWeb.Pages.Feedback.DetailsModel { ViewData[Title] 反馈详情; } h1ViewData[Title]/h1 div dl classrow dt classcol-sm-2ID/dt dd classcol-sm-10Model.FeedbackItem.Id/dd dt classcol-sm-2姓名/dt dd classcol-sm-10Model.FeedbackItem.Name/dd dt classcol-sm-2邮箱/dt dd classcol-sm-10Model.FeedbackItem.Email/dd dt classcol-sm-2反馈内容/dt dd classcol-sm-10Model.FeedbackItem.Message/dd dt classcol-sm-2提交时间/dt dd classcol-sm-10Model.FeedbackItem.SubmittedOn.ToString(yyyy-MM-dd HH:mm:ss)/dd /dl /div div a asp-page./Index classbtn btn-outline-secondary返回列表/a /div关键点page “{id:int}”在页面指令中定义路由模板和约束确保只有整数id才能访问此页面。5. 运行与效果验证启动项目在项目根目录运行dotnet run。访问列表页打开浏览器访问https://localhost:5001/Feedback。你将看到一个空列表和一个“提交新反馈”按钮。测试表单提交与验证点击“提交新反馈”进入/Feedback/Create。不填任何信息直接提交会看到基于数据注解的前端验证错误提示姓名、邮箱、内容为必填。填写一个错误格式的邮箱会提示“邮箱格式不正确”。测试远程验证先提交一条反馈如邮箱testexample.com。然后再次进入提交页在邮箱栏输入testexample.com并移开焦点触发onblur事件。页面会异步调用OnPostCheckEmailAsync方法并提示“该邮箱已被使用。”。这是一个非常实用的功能用于检查用户名、邮箱等是否重复而无需提交整个表单。填写正确信息后提交页面会跳转回列表页并显示成功消息和新增的记录。测试详情页在列表页点击某条记录的“查看详情”会跳转到/Feedback/Details/1显示该反馈的完整信息。6. 常见问题与排查思路问题现象可能原因排查方式解决方案访问/Feedback返回 4041.Pages/Feedback/Index.cshtml文件不存在。2.Program.cs中未调用app.MapRazorPages()。3. 文件夹或文件命名不正确Razor Pages 默认区分大小写。1. 检查文件路径和名称。2. 检查Program.cs的配置。3. 尝试访问/feedback全小写看是否有效。1. 创建正确的文件。2. 确保app.MapRazorPages()在管道中正确调用。3. 统一使用约定的大小写建议与路由访问保持一致。表单提交后[BindProperty]模型属性为 null1. POST 处理器的名称不是OnPost或OnPostAsync。2. 表单字段的name属性与模型属性名不匹配。3. 未使用asp-forTag Helper。1. 检查 PageModel 中的方法名。2. 查看浏览器开发者工具中 Network 标签页检查 POST 请求的 Form Data。3. 确保表单使用form method”post”且字段使用asp-for。1. 将处理器重命名为OnPost或OnPostAsync。2. 确保asp-for的值是Model.YourProperty的格式。3. 在_ViewImports.cshtml中确保有addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers。远程验证不工作没有异步请求发出1. 未引入 jQuery 和 jQuery Unobtrusive Validation 脚本。2.>1. 检查浏览器控制台是否有 JS 错误。2. 查看页面源代码检查>1. 确保_ValidationScriptsPartial被引入。2. 使用Url.Page()辅助方法生成 URL。3. 处理器方法应命名为OnPost[HandlerName]Async并使用[AcceptVerbs(“GET”, “POST”)]装饰以支持两种请求。page “{id:int}”路由不生效无法捕获参数1.page指令书写有误。2. 链接生成时未使用asp-route-id。3. 访问的 URL 中 id 不是整数。1. 检查page指令的语法和位置必须在第一行。2. 检查生成链接的 Tag Helper。3. 尝试直接访问/Feedback/Details/abc看是否被拦截。1. 确保指令格式正确page “{id:int}”。2. 使用a asp-page”./Details” asp-route-id”item.Id”生成链接。3. 路由约束会阻止非整数参数这是预期行为。布局页 (_Layout.cshtml) 未应用1.Pages/_ViewStart.cshtml文件丢失或内容错误。2. 页面中显式设置了Layout null。1. 检查_ViewStart.cshtml文件是否存在内容是否为{ Layout “_Layout”; }。2. 检查当前.cshtml文件顶部是否覆盖了布局设置。1. 创建或修复_ViewStart.cshtml。2. 移除页面中不必要的Layout设置。7. 最佳实践与进阶建议掌握了基础之后遵循以下最佳实践能让你的 Razor Pages 项目更加健壮和可维护。1. 清晰的文件夹结构按功能模块组织页面。例如/Pages/Products//Pages/Admin/Users/。将共享的显示模板DisplayTemplates、编辑器模板EditorTemplates放在Pages/Shared/下。复杂的业务逻辑应抽离到服务层ServicePageModel 只负责协调和视图逻辑。2. 善用 PageModel 的生命周期方法除了OnGet和OnPostPageModel 还有其他生命周期方法如OnGetAsync、OnPostAsync、OnPageHandlerExecuting等。异步版本 (Async) 是推荐做法。3. 使用 Partial View 和 View Components 复用 UIPartial View用于复用一块 Razor 标记。使用partial name”_PartialName” model”Model.SomeData” /引入。View Components更强大的复用单元包含独立的逻辑和视图。适合渲染动态导航菜单、购物车摘要等。在 PageModel 中通过ViewComponent()方法调用在视图中使用await Component.InvokeAsync(“ComponentName”)。4. 保持 PageModel 精简PageModel 不应成为“垃圾堆”。遵循单一职责原则数据绑定使用[BindProperty]。命令处理使用OnPost[Action]方法。初始化使用OnGet。复杂逻辑委托给注入的服务。5. 安全的绑定策略使用[BindProperty]时要小心过度绑定攻击。可以通过[BindProperty(SupportsGet true)]启用 GET 绑定但通常不建议。对于编辑场景考虑使用[BindProperty]配合一个专门的InputModel而不是直接绑定实体模型或者使用[BindNever]排除敏感属性。6. 有效的错误处理使用ModelState.AddModelError添加自定义验证错误。在Program.cs中配置全局异常处理app.UseExceptionHandler(“/Error”)。为关键操作如删除实现 Post-Redirect-Get (PRG) 模式防止重复提交。7. 性能考量对于数据量大的列表页务必实现分页。可以考虑使用PaginatedListT类或第三方库。使用ResponseCache特性为静态或半静态页面添加缓存。在开发环境下启用 Razor 运行时编译AddRazorRuntimeCompilation在生产环境下关闭以获得最佳性能。8. 与 ASP.NET Core 9 及更高版本的新特性集成最小 API 集成你可以在同一个项目中混合使用 Razor Pages 和最小 API用最小 API 处理简单的 HTTP 端点。原生 AOT对于追求极致启动时间和内存占用的场景可以探索将 Razor Pages 应用发布为原生 AOT但需要注意其限制如动态代码生成。改进的 Blazor 集成在需要高度交互性的组件中可以考虑在 Razor Pages 中嵌入 Blazor 组件实现混合渲染。从简单的页面到复杂的表单处理再到与远程验证、依赖注入的集成Razor Pages 提供了一套连贯、高效且易于理解的开发模型。它削减了不必要的抽象让开发者能更直接地思考“页面”这个核心概念。对于大多数以内容展示和表单交互为主的 Web 应用来说它很可能是比标准 MVC 更优的选择。下次当你开始一个新的 .NET Web 项目时不妨先问问自己“这个功能用 Razor Pages 是不是更简单”