3.4 视图 Razor 与表单处理 视图是业务管线的出站段:Razor 视图在运行前被编译成类,拿到动作递来的强类型模型,混写 HTML 与 C# 产出最终页面;布局页管公共骨架,TagHelper 让表单与模型绑定无缝衔接。本节把前三节串成一个完整的表单往返。 动作返回 View(model) 之后、HTML 写回浏览器之前,还有最后一段路:视图引擎找到对应视图、编译执行、渲染输出。观察哨在本章的最后一站看清它,顺便用一个完整表单案例把路由、控制器、绑定、验证全部串起来。 视图这一站拿到什么 视图不是"字符串模板",是编译类。项目构建时每个 Razor 视图被编译成一个类文件,执行就是调用它的 ExecuteAsync 方法——这解释了为什么视图里写错变量名是编译错误而不是运行时爆炸。
视图是业务管线的出站段:Razor 视图在运行前被编译成类,拿到动作递来的强类型模型,混写 HTML 与 C# 产出最终页面;布局页管公共骨架,TagHelper 让表单与模型绑定无缝衔接。本节把前三节串成一个完整的表单往返。
动作返回 View(model) 之后、HTML 写回浏览器之前,还有最后一段路:视图引擎找到对应视图、编译执行、渲染输出。观察哨在本章的最后一站看清它,顺便用一个完整表单案例把路由、控制器、绑定、验证全部串起来。
视图不是"字符串模板",是编译类。项目构建时每个 Razor 视图被编译成一个类文件,执行就是调用它的 ExecuteAsync 方法——这解释了为什么视图里写错变量名是编译错误而不是运行时爆炸。视图与动作的约定是目录查找:ProductsController.List 默认找 Views 目录下 Products 子目录里的 List 视图,View("Detail") 可显式指定别的名字。
<!-- 视图开头声明模型类型,之后 Model 即强类型对象 --> @model IList<string> @{ ViewData["Title"] = "商品列表"; // 代码块:给布局页传标题 } <h2>商品列表</h2> <ul> @foreach (var item in Model) // C# 流程控制直接混写 { <li>@item</li> // @ 开头的表达式输出到页面 } </ul> <!-- 渲染结果: 商品列表 · 键盘 · 显示器 · 鼠标 -->
Razor 的解析规则只需记三条:@表达式 输出值(默认 HTML 编码,防注入);@{ ... } 是语句块不输出;@: 或 <text> 标记输出纯文本。服务端注释 @* ... *@ 编译时直接丢弃,不会到浏览器。
每个页面都写一遍导航栏和页脚显然不现实。布局页定义骨架,视图填空:
<!-- 布局页:占位符等视图来填 --> <!DOCTYPE html> <html> <head> <title>@ViewData["Title"] - 观察哨商城</title> </head> <body> <nav>导航栏:全部 / 数码 / 外设</nav> @RenderBody() <!-- 视图内容注入点 --> @await RenderSectionAsync("Scripts", required: false) <!-- 可选区块:视图用 @section Scripts { ... } 填充 --> </body> </html>
视图启动文件(Views 目录下的 ViewImports)里一句 @addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers 打开 TagHelper 支持,布局查找由 ViewStart 文件统一指定。两个小文件各管一件事:一个管"默认布局是谁",一个管"哪些 TagHelper 可用"。
TagHelper 是本节的枢纽——它让 HTML 保持 HTML 的样子,同时偷偷接上模型绑定与路由。看一个完整的编辑表单:
<!-- asp-for 把输入框绑到模型属性:生成 name、id、验证特性 --> <!-- form 的 asp-action 生成指向当前控制器 Edit 动作的地址,自动带防伪令牌 --> <form asp-action="Edit" method="post"> <div asp-validation-summary="ModelOnly"></div> <input type="hidden" asp-for="Id" /> <label asp-for="Name"></label> <input asp-for="Name" class="form-control" /> <span asp-validation-for="Name" class="text-danger"></span> <label asp-for="Price"></label> <input asp-for="Price" class="form-control" /> <span asp-validation-for="Price" class="text-danger"></span> <button type="submit" class="btn">保存</button> </form>
渲染后的 HTML 揭示它做的事:input 的 name 变成 Name(3.3 节绑定靠的就是它)、label 文本取自显示注解、validation 挂上客户端校验脚本钩子、form 自动插入防伪令牌隐藏字段。服务端与客户端共用同一份模型元数据,这是"一处声明、处处生效"的关键。
背景:把本章四节串起来的时刻到了——做一个"新增商品"页面,要求:空提交打回并显示错误、合法提交落库后跳列表、刷新列表页不产生重复提交。
操作:三个文件配合。模型与验证规则:
public class ProductCreateInput { [Required(ErrorMessage = "商品名必填")] [StringLength(40)] [Display(Name = "商品名称")] public string Name { get; set; } = ""; [Range(0.01, 99999, ErrorMessage = "价格须在 0.01 到 99999 之间")] [Display(Name = "价格")] public decimal Price { get; set; } }
控制器两个动作分管 GET 与 POST:
public class ProductsController : Controller { public IActionResult Create() => View(); // GET:渲染空表单 [HttpPost] [ValidateAntiForgeryToken] // 校验表单防伪令牌 public IActionResult Create(ProductCreateInput input) { if (!ModelState.IsValid) return View(input); // 打回:错误就地显示 // 生产代码此处落库,第 5 章接 EF Core TempData["msg"] = $"已添加 {input.Name}"; // 跨请求暂存一条提示 return RedirectToAction("Index"); // PRG:重定向断开刷新链 } }
视图用 TagHelper 渲染表单(同上文的 Edit 结构),列表页读 TempData 显示提示。
结果:空表单直接提交——Name 与 Price 字段旁分别显示"商品名称必填""价格须在 0.01 到 99999 之间";填入"显示器 899"提交——跳到列表页顶部显示"已添加 显示器";在列表页反复按刷新——只重复请求列表,不会重复添加商品。
解读:这条往返链路把四节机制各用了一次——路由把 POST 导到 Create 动作(3.1),谓词区分展示与提交(3.2),绑定器把表单值拼成对象、验证器拦下非法数据(3.3),TagHelper 表单与 PRG 模式完成体验闭环(本节)。TempData 是"跨一次跳转"的短期存储,底层默认用 Cookie,正好承载"操作成功"这类一次性提示。
变式:需要向导式多步表单时,每步一个动作、逐步把中间态存入会话或隐藏域;批量编辑用集合前缀命名(items[0].Name)配合 3.3 节的集合绑定;若页面交互很重,可只保留 Razor 做骨架、局部用前端框架接管,管线模型不变。
Partial 是"可复用的小视图",适合纯展示片段(一行商品卡片);ViewComponent 带"小控制器",适合需要自己查数据的独立模块(侧栏热销榜)。判断标准:要不要自己取数——要,就上 ViewComponent。
⚠️ 常见坑:视图里用
@Model.Price.ToString("C")直接格式化金额,服务器区域文化与用户期望不一致时显示错位——金额格式化交给显示注解或前端本地化处理,别在视图里硬编码文化。