4.1 Razor Pages 结构与约定 Razor Pages 以页面为单位组织 Web UI:页面的目录路径就是 URL,页面模型按 HTTP 方法自动分派处理器,标记与逻辑贴身存放。它是 MVC 业务管线的精简编组——站点没变,编队变了。本节建立结构认知并做处理器选择实验。 从 MVC 转到 Razor Pages,最先要适应的不是语法,是"找东西的方式变了"。MVC 里加一个页面要问三件事:控制器放哪、视图放哪、路由怎么配;Razor Pages 里只有一个问题:页面文件放哪个目录。观察哨本节蹲在页面端点旁,看一个请求怎么凭目录与方法名找到归宿。 建一个页面项目,看默认结构 页面都住在 Pages 目录下。
Razor Pages 以页面为单位组织 Web UI:页面的目录路径就是 URL,页面模型按 HTTP 方法自动分派处理器,标记与逻辑贴身存放。它是 MVC 业务管线的精简编组——站点没变,编队变了。本节建立结构认知并做处理器选择实验。
从 MVC 转到 Razor Pages,最先要适应的不是语法,是"找东西的方式变了"。MVC 里加一个页面要问三件事:控制器放哪、视图放哪、路由怎么配;Razor Pages 里只有一个问题:页面文件放哪个目录。观察哨本节蹲在页面端点旁,看一个请求怎么凭目录与方法名找到归宿。
# webapp 模板即 Razor Pages dotnet new webapp -n PageLab cd PageLab dotnet run # Now listening on: http://localhost:5179
页面都住在 Pages 目录下。访问首页对应的是 Pages 里的 Index 页面,Privacy 页面对应同名文件——目录即路由,不需要任何路由配置。一个页面最多三个文件:
Pages/ Products/ Index.cshtml ← 标记页:HTML + Razor,@page 指令开头 Index.cshtml.cs ← 页面模型:C# 逻辑,双文件的后半 Index.cshtml ← 也允许只有标记页(无代码的单文件页面)
标记页与页面模型用同一个名字配对,页面模型类默认叫 IndexModel。@page 指令是页面的身份证——没有它就是普通视图,有了它框架才把文件登记为端点。
页面模型类继承 PageModel,方法名按 HTTP 方法约定命名。与 MVC 控制器最大的不同是"物理位置":逻辑与标记同目录同名,点开一个文件夹就看到页面的全部:
// 页面模型:一个页面一个类,处理器按方法名分派 public class IndexModel : PageModel { private readonly IProductRepo _repo; public IndexModel(IProductRepo repo) => _repo = repo; public IList<string> Items { get; set; } = new List<string>(); public void OnGet() // GET 请求执行这里 => Items = _repo.Top3(); public string? Message { get; set; } public IActionResult OnPost(string name) // POST 请求执行这里 { if (string.IsNullOrWhiteSpace(name)) { ModelState.AddModelError("name", "名称不能为空"); return Page(); // 停留在本页显示错误 } _repo.Add(name); return RedirectToPage(); // PRG:重定向回本页的 GET } }
<!-- 标记页:模型就是配对的页面模型类 --> @page @model IndexModel <h2>热销榜</h2> @foreach (var item in Model.Items) { <p>@item</p> } <form method="post"> <input name="name" /> <button>添加</button> <span class="text-danger">@Model.Message</span> </form>
处理器命名规则可以带后缀细分:OnPostArchiveAsync 处理表单里 asp-page-handler="Archive" 的提交,一个页面多个按钮各走各的处理器——这是页面通道比 MVC 更顺手的地方, MVC 里得靠多个动作或 ActionResult 参数区分。

背景:团队里新同事写了个页面,表单提交后总是 405(方法不允许)。他发誓自己写了 OnPost。观察哨进场做个透明实验,把"URL 加方法名到底选中谁"看清楚。
操作:建一个含多处理器的页面:
public class IndexModel : PageModel { public string? Last { get; set; } public void OnGet() => Last = "OnGet 执行"; public void OnPost() => Last = "OnPost 执行"; public void OnPostArchive() => Last = "OnPostArchive 执行"; // 注意:没有 OnPut——留着等下验证 405 }
@page @model IndexModel <p>最后执行:@Model.Last</p> <form method="post"><button>普通提交</button></form> <form method="post"><button asp-page-handler="Archive">归档</button></form>
结果:浏览器访问页面显示 OnGet 执行;点"普通提交"显示 OnPost 执行;点"归档"显示 OnPostArchive 执行。用命令行模拟 PUT:
# 模拟不存在处理器的请求 curl -X PUT http://localhost:5179/ # 返回:405 Method Not Allowed
解读:页面端点的分派表在启动期就登记好——GET 找 OnGet、POST 找 OnPost,带 handler 参数再拼名字。同事的 405 真相是他把方法写成了静态方法(static),约定要求实例方法,分派表里压根没登记。处理器约定全是"反射能看见的公开实例方法",静态、私有、重载不明都会导致"写了等于没写"。
变式:异步处理器加 Async 后缀(OnPostArchiveAsync),框架匹配时自动剥掉后缀再拼名;一个表单多个按钮用 asp-page-handler 区分动作,比 MVC 的单动作多分支清爽;需要 REST 风格路由的页面可用 @page 指令带模板参数(如 @page "{id:int}"),路由参数直接进处理器签名。
页面通道不是万金油。我的判断线有三条:页面之间几乎无共享流程、每页逻辑独立——选 Razor Pages;应用有复杂的多视图编排(一个动作喂多个视图、大量子请求组合)——MVC 更顺手;纯接口服务没有页面概念——直接第 6 章的 API 通道。三条之外的场景两种都能写好,别在选型上消耗团队——观察哨见过为"哪个更先进"吵两周的团队,两种写法最后的性能差异远小于一次慢查询。
⚠️ 常见坑:把页面模型当服务用,塞进几十个处理器和跨页共享的业务方法。页面模型的边界是"这一个页面",业务逻辑放进注入的服务(第 5 章的仓储模式),页面模型只做编排——否则它会长成新的上帝类。
💡 关键直觉:MVC 像按工种分组的车间(木工区、油漆区各一处),页面通道像按产品分组的工作台(一个台面做完一件家具)。组织方式服务于变更频率:改得多的东西应该物理上贴近。