ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Asp.net Core控制器与视图传值:ViewData、ViewBag、TempData与ViewModel全解析

Asp.net Core控制器与视图传值:ViewData、ViewBag、TempData与ViewModel全解析 大概所有写过Asp.net core的人都有过一段被控制器和视图之间传值方式绕晕的时期。我最早接触的是传统框架里那一套ViewData、ViewBag、TempData外加强类型模型的做法到了Asp.net core里函数签名变了、注入方式变了、底层的字典实现也换了但传值的核心思路其实一直没变控制器负责把数据准备好视图负责把数据呈现出来中间必须有一条清晰、可控、不容易踩坑的数据通道。这篇文章就把我在实际项目里反复用过的几种传值方式连同什么时候该用哪种、每种背后到底是怎么工作的、以及我踩过的坑一次性讲清楚。1. Asp.net core里Controller和View之间的数据链路先搞清楚一次请求的全貌1.1 为什么传值方式总让人纠结很多初学者把传值理解成把变量丢给页面所以第一反应是查各种方法名。但实际上在Asp.net core里Controller 和 View 根本不在同一个生命周期里。一次 HTTP 请求进来经过路由、中间件、模型绑定最后到达 Controller 的动作方法ActionAction 返回一个ViewResultMVC 框架渲染对应视图并输出 HTML。也就是说控制器和视图之间不是同一个进程里的两个类互调这么简单而是框架通过ViewResult、ViewDataDictionary、RouteData、ModelState这些内置对象作为媒介把一个动作方法的执行结果传递给视图引擎。一旦想通这条链路再看传值方式就不容易混乱了所有传值手段本质都是在往某种上下文容器里写数据再由视图引擎在渲染阶段从这些容器里读出来。Asp.net core相比旧版最大的变化是把这些容器从HttpContext.Current式的全局静态访问改成了通过依赖注入和Controller基类属性直接暴露同时引入了更多强类型约束。1.2 传值之前先看动作方法的返回类型Asp.net core的动作方法返回类型有IActionResult、ViewResult、PartialViewResult、JsonResult、RedirectResult等。这个设计放在传值场景里非常关键因为它决定了值传过去之后做什么。// 返回一个视图并把当前控制器里的 ViewData/ViewBag/TempData 一并传给视图引擎 public IActionResult Index() { ViewData[Title] 首页; return View(); } // 返回指定视图同时把强类型模型作为视图的 Model public IActionResult Detail(int id) { var vm new ProductViewModel { Id id, Name 示例商品 }; return View(ProductDetail, vm); } // 返回 JSON相当于把数据直接发给前端而不是交给 Razor 视图渲染 public IActionResult Api() { return Json(new { Code 0, Message ok }); }我见过很多项目里同事习惯在 Action 里到处塞ViewBag然后返回View()哪怕这个视图根本不需要那些数据。这种写法不是不能用但会让动作方法的职责变得模糊。Asp.net core官方推荐的思路是动作方法应明确表达我要渲染哪个视图、视图需要哪些数据能少用动态类型就不要用动态类型。后面我会专门展开强类型方案这是生产环境里真正值得主力采用的。1.3 视图引擎如何接收这些值Razor 视图编译后页面基类是RazorPageTModel视图里访问Model时实际是从ViewDataDictionary的Model属性里取值的。而ViewData、ViewBag、TempData也都是从同一个ActionContext链条上拿到的。也就是说视图能拿到什么控制器在创建ViewResult时就已经决定了。这一点解释了调试传值问题时的方向当页面显示不出来数据时不要去视图里翻语法应该先看控制器返回的ViewResult携带了什么、ModelState里有没有数据、TempData是否因为重定向而被消费了。我把这些常见切入点整理成了一个对照表排查维度检查对象常见问题数据来源Action 方法里是否赋值写了视图语法但忘了在控制器里赋值模型类型视图model声明与传入类型类型不一致导致运行时异常生命周期ViewData/TempData 的存活范围跨请求读取 TempData 被消费后丢失数据格式复杂对象、集合、JSON 序列化直接存对象进 ViewData 导致视图绑定失败重定向是否走了RedirectToAction原请求上下文数据被丢弃这看起来基础却是我每次排查问题最先过的五道筛子。大多数视图不显示数据的 bug基本都能在第三、四层找到根因。2. ViewData、ViewBag、TempData三姐妹的能力边界与选型逻辑2.1 ViewData 的字典本质与强转陷阱ViewData在Asp.net core里的类型是ViewDataDictionary本质上是一个Dictionarystring, object区分大小写的字符串作为键值为object。因为值是 object所以取出来的时候需要自己做类型转换// 控制器里赋值 ViewData[ProductName] 机械键盘; ViewData[Price] 399m; // 视图里读取 { var name ViewData[ProductName]?.ToString(); var price Convert.ToDecimal(ViewData[Price]); } pname价格 price 元/p陷阱就在这里ViewData只做了运行时类型装箱不做任何编译期检查。你赋值时写的是decimal视图里忘了转直接拼字符串得到的可能是399而不是399.00更麻烦的是如果你把一个复杂对象直接塞进去Razor 里只能靠强制转换拿回原类型一旦类型写错就是运行时InvalidCastException。所以我的建议是ViewData适合放简单标量值比如页面标题、面包屑导航、页码、开关标志这种视图辅助信息不适合放核心业务数据。用它负责零散的 UI 状态用强类型模型负责业务主体两者分工代码会干净很多。2.2 ViewBag 的动态语法只是 ViewData 的马甲ViewBag是ControllerBase上的一个dynamic属性底层还是ViewDataDictionary。你可以这样写ViewBag.Title 用户列表; ViewBag.Items new Liststring { a, b };视图里直接用ViewBag.Title。因为它是 dynamic所以写起来特别顺手不用像ViewData那样反复转型。但请注意ViewBag和ViewData共享同一份底层数据ViewBag.Title x等价于ViewData[Title] x反之亦然。如果你在控制器里用ViewData[Name]赋值在视图里用ViewBag.Name读取是能读到的。这个便利背后隐藏了两层问题。第一层是运行时才知道属性是否存在拼错属性名不会在编译期报错只会默默渲染空白。第二层是 dynamic 解析会损失 IntelliSense 和 Refactor 能力项目大了之后全局搜索改名很难做。我周围的团队里ViewBag在简单 Demo 里出现频率极高但在生产代码评审里基本是被扣分的点——不是说不能用而是用多了会让这个页面到底接哪些数据这件事变得不可控。2.3 TempData跨请求存活的一次性数据TempData是三者里最特殊的一个它不是为了在当前请求里传递给视图设计的而是为了跨一次请求传递数据。最典型的场景就是 PRG 模式用户在表单页提交数据控制器处理后执行RedirectToAction跳转到另一个动作然后在重定向后的页面上显示保存成功的提示。因为重定向会发起全新的 HTTP 请求原来的ViewData/ViewBag早就没了只有TempData能携带数据到下一个请求。[HttpPost] public IActionResult Save(ProductInput input) { // 保存逻辑... TempData[SaveMessage] 保存成功; return RedirectToAction(nameof(Index)); } public IActionResult Index() { // 在当前请求里读取 TempData var msg TempData[SaveMessage]?.ToString(); return View(); }TempData的默认实现基于Session或CookieTempDataProvider读取时有消费一次的性质。也就是说当你在某个视图里读了TempData[SaveMessage]这个键值对会被标记为已读下一次请求再来时它就消失了。这种一次性语义非常适合做一次性提示但如果你用它传大对象、传列表很容易踩到序列化相关的坑。默认的SessionStateTempDataProvider会把TempData内容序列化后存在 Session 中复杂类型如果不是可序列化的或者类型无法正确反序列化直接就是运行时异常。2.4 怎么选最小使用原则我在项目里给团队定的规矩是这样的场景推荐手段原因页面标题、按钮文案、布局参数ViewData / ViewBag轻量、灵活不影响核心模型表单校验失败后的回显强类型 ViewModel ModelState保持类型安全校验状态自动恢复重定向后的一次性提示TempData天然跨请求且一次性消费列表、详情等业务主体数据强类型 ViewModel编译期类型安全视图可维护跨多个请求的用户级上下文Session 或认证声明语义清晰不依赖临时容器别把ViewBag当万能口袋。一旦视图里的核心数据都是从ViewBag取出来的这个视图基本等于放弃了类型约束改一个字段名可能要全局排查半天。Asp.net core给了你弱类型容器是为了让你在零散场景下省事不是为了让你放弃强类型设计。3. 强类型传值ViewModel模式与模型绑定才是生产环境的正道3.1 为什么从 ViewData 走向强类型 ViewModel弱类型容器的最大问题是契约不成立控制器里写什么、视图里读什么全是靠约定俗成的字符串键来维系的。一旦两个人协作A 写ViewData[UserName]B 在视图里读ViewBag.UserName字符串一不一致完全靠肉眼。而Asp.net core里的model指令恰好解决了这个问题视图可以声明自己期望的数据类型控制器返回视图时必须传入匹配类型的对象不匹配在编译阶段就能暴露。注意Asp.net core里视图编译是发生在运行时的Razor 运行时编译所以编译期报错严格来说指的是 Razor 视图在首次访问时抛出的InvalidOperationException或者在使用预编译视图时于构建阶段暴露。不管是哪种都比页面空白、只在角落里有一个 null要友好得多。强类型模型还有一个额外的好处它顺带承载了数据校验特性[Required]、[StringLength]、[Range]这些DataAnnotations可以直接写在 ViewModel 上。3.2 从定义到视图的完整闭环我以用户列表加筛选条件为例走一遍强类型传值的完整流程。第一步定义 ViewModelpublic class UserListViewModel { public string Keyword { get; set; } public ListUserItemViewModel Items { get; set; } new(); } public class UserItemViewModel { public int Id { get; set; } public string Name { get; set; } public string Email { get; set; } public bool IsActive { get; set; } }第二步控制器组装数据并返回public IActionResult Index(string keyword) { var list _userService.Search(keyword); // 业务层返回实体 var vm new UserListViewModel { Keyword keyword, Items list.Select(u new UserItemViewModel { Id u.Id, Name u.Name, Email u.Email, IsActive u.IsActive }).ToList() }; return View(vm); }第三步视图顶部声明模型类型然后直接使用model UserListViewModel form methodget input typetext namekeyword valueModel.Keyword / button typesubmit搜索/button /form table foreach (var item in Model.Items) { tr tditem.Id/td tditem.Name/td tditem.Email/td td(item.IsActive ? 启用 : 禁用)/td /tr } /table这段代码里最值得留意的是控制器层根本不需要关注视图怎么显示视图也不需要关心数据从哪来双方通过UserListViewModel这个中间契约完成对接。实体对象和视图模型分离的做法避免直接把 EF 实体暴露给视图这也是我强烈推荐的生产级做法。3.3 模型绑定与表单回传传值不只是控制器往视图很多人把传值理解成单向的控制器 - 视图实际上视图提交表单到控制器的过程同样是传值的一部分。Asp.net core的模型绑定会从Form、QueryString、RouteValue、JSON Body等多个数据源里把请求数据绑定到动作方法的参数或 ViewModel 属性上。[HttpPost] public IActionResult Create(UserCreateViewModel input) { if (!ModelState.IsValid) { // 校验失败时把当前输入数据连同 ModelState 一起回传给视图 return View(input); } // 保存... return RedirectToAction(nameof(Index)); }这里有个非常实用的细节当校验失败时直接把input返回给视图input中用户填写的值会通过asp-for标签助手自动回显到input元素上。因为TagHelper渲染表单控件时会优先读取ModelState中对应的值再读取Model属性值所以用户不需要重新填一遍内容。这一点比旧版框架体验好很多也是强类型模型加模型绑定组合的杀手锏。校验相关的提示信息同样通过ModelState传递model UserCreateViewModel form asp-actionCreate methodpost div label asp-forName/label input asp-forName / span asp-validation-forName classtext-danger/span /div button typesubmit提交/button /form3.4 ViewModel 设计上的三个建议第一一个视图对应一个 ViewModel不要跨页面复用过头。页面 A 需要 20 个字段页面 B 只需要其中 3 个你硬共用一个 ViewModel就会看到一堆意义不明的 null 属性。第二不要在 ViewModel 里放业务方法放数据加轻量展示辅助属性就够了。比如FullName、FormattedPrice这种由两个字段组合出来的展示属性放在 ViewModel 里没问题但不应包含Save()这种业务行为那是服务层的事。第三集合属性一定初始化。public ListXxx Items { get; set; } new();这个习惯能救你一命。如果不初始化Model为 null 时视图里一foreach就炸了而初始化之后至少是空列表页面能正常渲染。4. 重定向、Session、局部视图与对象回显进阶场景的传值取舍4.1 TempData 在重定向场景的进阶用法Peek 与 Keep普通场景里TempData读完就没了但有时候你需要读但不消费。比如跳转后的页面既要在导航栏显示提示又要在页面正文里显示同一份提示如果前后两个地方都去读TempData第一次读就被标记消费了第二次读出来就是 null。解决办法是TempData.Peek()和TempData.Keep()var msg TempData.Peek(SaveMessage)?.ToString(); // 读取但不标记为已消费 // 或者 TempData.Keep(SaveMessage); // 主动把该键保留一个周期这个用法在保存成功后跳转但多个页面部位都要展示同一个成功提示的场景里特别好用。另外还要注意TempData在重定向后的读取时机。如果重定向到了另一个 Action而那个 Action 也去读TempData你要想清楚谁该消费它。最好不要在多个 Action 里都读同一个键否则流程稍微一变提示就丢了。4.2 大对象传视图的序列化与性能避坑我见过不少人在做从控制器传一个很大的列表给视图然后在视图里做二次筛选的操作。这里有两个问题。第一把整个大集合塞给视图Razor 渲染本身会有性能开销服务器上内存和 CPU 都撑不住大规模并发。我之前优化过一个后台报表页面原始写法是把几千行数据全Select成 ViewModel 然后return View(list)一个请求处理时间 900ms改成在控制器里完成筛选和分页只传当页 20 条耗时降到 80ms 左右。这个量级的变化不是框架传值的锅而是你把不该给视图的数据给了视图。第二TempData存复杂对象有序列化陷阱。默认SessionStateTempDataProvider会把对象序列化存储如果你的类型没有公共无参构造函数、属性是只读的或者含循环引用就会出现运行时异常。真要传对象请确保该类型可序列化。我对团队的要求是TempData里只放字符串、int、短小的 DTO禁用 EF 实体和匿名对象。4.3 Session 与 HttpContext.Items 的使用边界Session也是一种跨请求传值方式但它不是控制器和视图传值的首选而是跨多个请求保存用户级状态的方式。在Asp.net core中使用 Session 前必须在Program.cs里注册builder.Services.AddSession(options { options.IdleTimeout TimeSpan.FromMinutes(20); }); app.UseSession();控制器里用HttpContext.Session.SetString(Key, Value)、GetString(Key)存取视图里很少直接访问 Session一般经过IHttpContextAccessor或控制器中转。要注意Session默认只能存已知类型复杂对象需要序列化成字符串或byte[]不要直接往里塞对象。HttpContext.Items则更轻量它只在当前请求内存活适合在中间件和控制器之间传递一些临时数据。如果你恰好在自定义中间件里拿到了请求相关数据希望后面的 Action 或视图也能读取可以放入Items[RequestId]。它的生命周期比ViewData还要短只用于请求内管道传递普通 CRUD 页面极少直接用。4.4 视图里渲染 JSON 数据给 JavaScript 的传值姿势前端和后端在同一页面协作时常见诉求是控制器取到数据视图渲染时同时把一份 JSON 塞给页面里的 JavaScript。最容易踩的坑是 XSS 和转义问题。比较稳妥的做法是using System.Text.Json { var dataJson JsonSerializer.Serialize(Model.SomeList); } script var data Html.Raw(dataJson); /script但Html.Raw直接输出dataJson有 XSS 风险如果数据里有用户可控的字符串就可能被注入脚本。更安全的做法是用Newtonsoft.Json结合JsonConvert.SerializeObject后配合Html.Raw(System.Text.Json.JsonSerializer.Serialize(...))或者直接使用官方提供的Json.Serialize方法搭配Html.Raw同时在输出前对字符串做 HTML 编码。实践上我见过很多团队为了省事直接Html.Raw(Json.Serialize(model))这在纯后端返回自己组装的数据时问题不大但凡是数据来自用户输入就必须充分验证编码策略。我的偏好是尽量走接口fetch拿 JSON让页面数据和视图渲染彻底解耦既避免 XSS 面也让前端逻辑更容易维护。4.5 局部视图和 ViewComponent 的传值习惯页面拆分成PartialView时数据传递有两类一种是把当前Model的某个子对象作为局部视图的 Model 传过去另一种是局部视图需要完全不相关的额外数据。第一种直接用Html.PartialAsync(_UserCard, Model.User)或Html.RenderPartialAsync视线model声明对应类型即可。第二种建议使用ViewComponent而不是在父视图里拼ViewData给局部视图。ViewComponent自带InvokeAsync返回ContentViewComponentResult有自己的ViewData上下文数据源清晰职责单一。我在页面上渲染最新文章推荐这种跟主模型无关的侧边栏时基本都用组件而不是从主 Action 里把推荐列表硬塞到ViewData[LatestPosts]里再让局部视图去读。5. 从实战里总结的传值检查清单最后分享一个我每次审查传值代码都会过一遍的清单当作这段时间经验的浓缩第一先判断数据生命周期。当前请求内用ViewData/ViewBag/强类型 Model跨请求的一次性提示用TempData需要用户级别长期保存用Session或认证存档。生命周期判断错了问题往往在页面切换后才会暴露调试成本最高。第二优先尝试强类型 ViewModel。视图和控制器之间的契约一旦用字符串键维系代码量一大就变成词语接龙。我用ViewBag最多承载标题、菜单激活状态、页面级配置这些辅助信息。第三表单回显考的是ModelState而非手工赋值。校验失败return View(input)是标准姿势不需要手动把用户输入塞回 ViewData那反而会双写混乱。第四TempData里的值要短小可序列化。别放 EF 实体别放匿名对象放进来的东西要能明确说出谁会消费它、消费几次。第五重定向场景先确认是否真的需要跨请求保留。如果只是退出页面时在浏览器地址栏显示一个标志把目标值放进RouteValueDictionary或QueryString就够了完全没必要动用TempData增加复杂度。我经历过的最神秘的一次传值 bug就是TempData在某个控制器里被一个埋得很深的 BaseController 提前读取了一次导致真正想用的页面拿到 null后来全家桶查代码才发现问题。从那一刻起我就特别强调传值容器的使用者必须少而明确最好在一个 Action 里完成读写规划和交接不要到处伸手。这套传值方式的选择逻辑其实跟做架构设计是一样的——先约束边界再谈灵活运用。你把这些边界理清了Asp.net core里的控制器和视图传值就再也不算是个问题。
返回列表