
简介面向C# MVC初、中级开发者这份资源包围绕“控制器前后端传值”这一核心主题深入讲解MVC模式下控制器如何通过多种机制实现数据交互包括ViewModel强类型视图模型、ViewBag/ViewData动态传递、TempData跨请求暂存、模型绑定自动映射表单参数以及基于jQuery的Ajax异步通信等。资源共112个文件包含17个C#源码、8个Razor视图、13个JavaScript脚本、7个CSS样式以及JSON、配置文件等压缩包仅1.31MB目录结构清晰代码与视图一一对应便于边读边练。已有755人学习下载适合在Visual Studio中直接打开调试通过完整示例理解每种传值方式的适用场景和最佳实践如避免在视图中处理业务逻辑、使用AntiForgeryToken防范CSRF攻击等能帮助学习者快速提升MVC项目开发中的数据交互能力。1. C# MVC 控制器前后端传值不是赋值那么简单用 C# 写 MVC 控制器传值很多人在第一个项目里就分不清 ViewBag 和 ViewData更别说为什么 Controller 收到的参数总是 null。这套机制看起来是几行赋值的事背后其实有模型绑定、过滤器、请求管道一圈东西。这篇文章从控制器给前端传值到前端回传控制器把 ViewBag/TempData/模型绑定/JSON 交互这几条路讲透顺带给出我排查空值和乱码的实战顺序。适合正在用 C# 做 MVC 项目、被前后端传值搞到失眠的开发者新手按步骤能跑起来熟手可以直接跳到避坑那一章。2. 控制器把数据交给视图从 ViewData / ViewBag / TempData 到强类型 Model 的取舍2.1 ViewData、ViewBag、TempData 三兄弟作用域和坑要分清在 C# MVC 的控制器里最简单的前后端传值就是从 Controller 往 View 丢数据。最常见做法是 ViewData、ViewBag 和 TempData 三兄弟虽然长得像但作用域、生命周期和内部类型完全不是一回事。ViewData 是ViewDataDictionary索引器用字符串取任何类型都能塞进去。ViewBag 是dynamic本质是对 ViewData 的语法糖写ViewBag.Title实际就是操作ViewData[Title]。两者都只在当前视图渲染时存活请求结束就没了。TempData 不同它在 Session 里走一圈默认两次请求内有效所以适合做跳转后短暂显示的状态消息。对象本质作用域典型用途ViewDataViewDataDictionary当前视图简单键值对ViewBagdynamic 包装 ViewData当前视图少写引号的临时数据TempDataSession 后备存储当前请求 后续一次请求跳转后提示消息写代码时我喜欢用 ViewBag 多过 ViewData因为它少写引号、看起来干净。但项目一旦复杂隐患就来了ViewBag 拼错的属性名在编译期不报错运行时不弹异常只是视图里显示空白。你根本不知道是控制器没赋值还是视图拼错了。这就是第一个坑——弱类型传值没有任何编译器帮你做检查。// Controller public IActionResult Index() { ViewData[Title] 首页; ViewBag.UserName 张三; TempData[Result] 保存成功; return View(); }* View * h1ViewData[Title]/h1 pViewBag.UserName/p if (TempData[Result] ! null) { divTempData[Result]/div }逻辑说明这三行分别演示了三种传值。ViewData 和 ViewBag 在当前视图响应里有效TempData 在后续一次请求中还能读。注意TempData[Result]读取后默认会被标记删除如果跳转两次就没了需要Peek才能保留。参数说明ViewData用字符串 keykey 拼错不会报错TempData适合“保存成功后跳转列表页并弹一条消息”不适合携带业务对象因为中途可能被清理。这里强调单页表单场景用 ViewBag 足够但视图里出现大量类似ViewBag.CategoryList的循环时就该考虑强类型模型。新手最容易踩的坑是试图在视图里用 ViewBag 循环绑定下拉框然后发现回传时控制器收到 null——因为 ViewBag 里的对象根本没有被模型绑定器识别后面前端到控制器的路是断的。2.2 强类型模型让视图不再靠 ViewBag 唱独角戏跨前后端最稳的传值是强类型模型。控制器返回View(model)视图第一行写model YourNamespace.YourClass然后视图里直接访问Model.Name。这个模式在编译期就能检查属性拼写和类型IntelliSense 还能给出提示。强类型模型的用法不止是传展示数据更重要的是它同时定义了“前端提交边界”。我在项目里通常建一个ViewModel目录专门放这些承载交互的类别和数据库实体分开。public class OrderFormViewModel { public int OrderId { get; set; } public string CustomerName { get; set; } public decimal TotalAmount { get; set; } public ListSelectListItem PayTypes { get; set; } }public IActionResult Edit(int id) { var order _orderRepository.GetById(id); var vm new OrderFormViewModel { OrderId order.Id, CustomerName order.CustomerName, TotalAmount order.TotalAmount, PayTypes _payTypeService.GetSelectItems() }; return View(vm); }model OrderFormViewModel using (Html.BeginForm(Save, Order, FormMethod.Post)) { Html.HiddenFor(m m.OrderId) Html.TextBoxFor(m m.CustomerName) Html.TextBoxFor(m m.TotalAmount) Html.DropDownListFor(m m.PayTypeId, Model.PayTypes) button typesubmit保存/button }逻辑说明先定义一个 ViewModel包含要展示的属性和供下拉框的选项。控制器从仓储拿数据后组装成 VM视图用强类型辅助方法生成表单控件。参数说明Html.HiddenFor生成的 hidden 字段名字是OrderId回传时模型绑定器会把表单里 name 为OrderId的值映射到控制器接收的 model.OrderId。这就是强类型模型同时管“展示”和“回传”的关键——name 属性由框架根据表达式自动生成不靠手写字符串对齐。不少老项目把 Entity 直接丢进 View图省事。这种做法的代价是DB 字段暴露给前端回传时如果有人构造一个表单多提交IsAdmintrue模型绑定会直接把它写入实体造成“过度发布”。用 ViewModel 可以在入口处只允许白名单属性进来这也是为什么强类型模型不只是排版问题而是边界安全的第一道闸门。2.3 控制器给前端 JS 传值的三种常见姿势除了服务端渲染视图C# MVC 控制器还常常要把数据交给页面里的 JavaScript。常见的姿势有三种ViewBag 隐藏域、Razor 直接序列化、以及控制器返回 JSON 由 Ajax 拉取。ViewBag 隐藏域适合“页面一加载就要用一次”的小数据。比如把用户 ID 放在一个 hidden 元素里JS 再读 value。这个方法兼容性最好但代码会很散。Razor 直接序列化的做法是把 C# 对象转成 JSON 字符串内嵌到脚本变量里注意防/script注入。script typetext/javascript var initialData Html.Raw(Json.Serialize(Model.PagedList)); console.log(initialData); /script第三种是标准做法页面里的脚本用fetch或axios请求控制器的一个 Action控制器返回JsonResult。这种做法解耦最彻底HTML 结构不用跟着数据走刷新局部数据时不用重新渲染整页。它同时对“C# MVC 控制器前后端传值”的诉求最直接——前端动态需要的数据就要用 JSON 接口给而不是藏在一堆 ViewBag 里。[HttpGet] public IActionResult GetOrderItems(int orderId) { var items _orderItemRepository.GetByOrderId(orderId); return Json(items); }逻辑说明这个 Action 接受orderId返回一个 JSON 数组。前端可以在页面加载后异步请求也可以在下拉框切换时按需请求相比一次把全部数据灌进页面这种方式更适合数据量大、按操作再取的场景。参数说明Json(items)默认使用的是 ASP.NET Core 的 JSON 序列化配置如果遇到循环引用或时间格式问题需要在 Startup 里配置JsonOptions后面避坑章会单独提。2.4 从 Controller 到 View 的传值路线优先这么选结合上面的内容我给一个自己的选型顺序。单个页面展示简单对象用强类型 Model 或 ViewBag 都行但项目里有下拉框、表格这种列表交互必须走 ViewModel。跨 Action 跳转后只提示状态用 TempData。页面里的 JS 初始数据能塞到>// Controller HttpContext.Session.SetString(UserName, 张三); HttpContext.Session.SetInt32(UserLevel, 3); Response.Cookies.Append(Theme, dark);* View 里读 Session * p欢迎回来Context.Session.GetString(UserName)/p逻辑说明Session 数据存在服务端Cookie 存在浏览器端。视图里通过Context.Session读取控制器里则通过HttpContext.Session。参数说明SetString和SetInt32是扩展方法需要在Startup或Program.cs里注册AddSession和UseSession否则运行时会直接提示“Session not configured”。Cookie 适合记住主题、分页大小不适合放敏感数据。这里要提醒的是Session 和 Cookie 都会让 Controller 和 View 之间出现隐式依赖。你在控制器里不传值视图也能读到 Session排查代码时很难一眼看出“这个值从哪里来”。我一般只在全局信息上用它页面粒度的传值尽量还是走 ViewModel 和 TempData这样每个 Action 的输入输出都清清楚楚。3. 前端把数据交给控制器表单、查询字符串、路由、Ajax 四路入口怎么收敛3.1 表单提交模型绑定的默认规则和 name 属性对应关系前端回传控制器的第一主流是表单。开发者最常见的幻觉是“我传了但控制器收到的 null”。根本原因多半是表单控件的name和控制器参数的名称对不上。C# MVC 的默认模型绑定器按“参数名或属性名匹配 name 属性”工作不要求控件 id 一致只看 name。验证这个规则不需要启动整套项目直接手工构造一个请求就能看到效果。假设控制器方法是Save(string customerName, int age)表单里必须有namecustomerName和nameage。文本框的id只是给 label 和 JS 用的与绑定无关。form action/Order/Save methodpost input typetext idnameInput namecustomerName / input typenumber idageInput nameage / button typesubmit提交/button /form[HttpPost] public IActionResult Save(string customerName, int age) { // customerName 匹配 namecustomerName // age 匹配 nameage return Json(new { ok customerName ! null age 0 }); }逻辑说明methodpost时表单数据在请求体里以application/x-www-form-urlencoded格式传输。模型绑定器找到customerName和age两个 key按参数类型转换。整数age如果传了空字符串绑定器会给它默认值0不会报错这也是一个容易看走眼的地方。参数说明控制器参数的名称大小写不敏感但建议完全一致省去猜。如果要绑定的参数是一个复杂对象比如OrderViewModel表单里的控件 name 要写成属性名嵌套对象则用前缀.属性名。例如绑定Address.City输入框的 name 就是Address.City。这套规则在生成的Html.TextBoxFor(m m.Address.City)里是自动完成的手写 HTML 时特别容易漏掉中间的点导致绑定器只能匹配到顶层于是Model.Address不为 null 但City是 null。3.2 查询字符串和路由参数何时自动绑定何时要手动指定GET 请求下前端把数据放在 URL 上这就要分清查询字符串和路由参数。查询字符串是?orderId123page2路由参数是/{controller}/{action}/{id}里的id段。模型绑定器对这两类都按名称匹配方法参数唯一要小心的是“我想从查询字符串取值但参数名和路由里某个变量同名”。常见做法是不要在 Controller 方法里混用两个通道同名的值。比如路由模板有{id}目录页又传?id5绑定器优先取路由值这个优先级很容易让人翻车。要是必须从查询字符串里取就不要用它当 Action 方法参数直接在方法里Request.Query[id]手动读取。public IActionResult Details(string sortOrder, int pageIndex 1) { // sortOrder 来自查询字符串 ?sortOrdername // pageIndex 也来自查询字符串没传时用默认值 1 return View(); }逻辑说明模型绑定器对简单类型参数默认从路由数据、查询字符串、表单值里依次找。pageIndex设置默认值 1前端传了就用前端的值没传也不会因为 null 抛异常。参数说明如果同一个名字同时出现在路由和查询字符串里框架内部是按顺序取第一个所以不建议在查询字符串里再加一遍同名参数。3.3 Ajax JSON 传值从 Request.Body 到 [FromBody] 的配合现代 C# MVC 项目里前后端传值更多走 Ajax JSON。这时候有一个高频坑前端$.ajax里明明写了data: JSON.stringify(obj)控制器 Action 参数却是 null。十有八九是少了[FromBody]特性或是请求头没有Content-Type: application/json。在 ASP.NET Core 里复杂类型默认从请求体找。对于 JSON 内容需要在控制器参数上明确写[FromBody]或者在[ApiController]控制器里会隐式推断。老式 ASP.NET MVC.NET Framework里同样用[FromBody]但要留意 JSON 只能绑定一个复杂参数不能同时[FromBody] int a和[FromBody] string b。[HttpPost] public IActionResult SaveOrder([FromBody] OrderInputDto dto) { if (dto null || string.IsNullOrEmpty(dto.CustomerName)) { return BadRequest(new { message dto 没绑定上 }); } return Json(new { ok true, received dto }); }前端请求const payload { customerName: 张三, items: [ { sku: A001, quantity: 2 } ] }; fetch(/Order/SaveOrder, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify(payload) }) .then(res res.json()) .then(console.log);逻辑说明[FromBody]告诉模型绑定器去读请求体JSON 反序列化由框架完成。这里 DTO 的字段名要和 JSON 的 key 大小写不敏感匹配默认CustomerName能匹配customerName。参数说明如果不开[ApiController][FromBody]是必须的开了[ApiController]后系统自动推断复杂类型参数从 Body 绑定但显式写出来对后来维护的人更友好。JSON 传值还有一个边界要注意前端Content-Type没设置fetch默认会用text/plain或application/x-www-form-urlencoded服务端[FromBody]去解析时拿到空体dto就成了 null。所以排查时第一眼看请求头再看请求体最后才看控制器代码这个顺序能省很多时间。3.4 防伪令牌与跨站请求传值之前先过的安全关当控制器接收前端传入的数据时还要注意 CSRF。ASP.NET Core 的 MVC 内置了防伪令牌机制表单里放Html.AntiForgeryToken()控制器方法加[ValidateAntiForgeryToken]针对 cookie-based 验证的 POST 请求有效。Ajax 时把防伪令牌放到请求头比较常见先拿到 Razor 页面里的 cookie再在 fetch 头里带X-CSRF-TOKEN服务端用中间件验证。[HttpPost] [ValidateAntiForgeryToken] public IActionResult SaveOrder(OrderFormViewModel vm) { // 令牌校验失败时这里根本不会进入 return RedirectToAction(Index); }这个不是给前端传值增加负担而是防止攻击者伪造表单诱导用户提交。我自己做内部系统时也保留这个动作只是因为如果跳过它C# MVC 的管道会直接拒绝请求那排查的时候会把问题引向“是不是传值代码写错了”真实原因其实是安全验证没过。所以遇到 POST 返回 400先看响应头里有没有防伪令牌校验失败的标记。3.5 数组、列表和可空类型批量数据怎么传才能不丢实际项目中经常要一次传多个相同类型的值比如批量和修改。对于数组绑定前端表单重复 name控制器方法参数用数组或列表接收绑定器会自动收集同名字段。input typecheckbox nameids value1 / input typecheckbox nameids value2 / input typecheckbox nameids value3 /[HttpPost] public IActionResult BatchDelete(int[] ids) { // 勾选了哪几个数组里就有哪几个 return Json(new { count ids?.Length ?? 0 }); }逻辑说明三个复选框同名ids提交后绑定器把所有 value 聚合成数组。没勾选任何一项时ids会是 null所以代码里用ids?.Length兜底。参数说明如果前端用 Ajax 传 JSON 数组给[FromBody] int[] ids也是一样的绑定效果区别只在内容格式。可空类型是另一个坑。控制器参数是bool? flag前端把值传成空字符串绑定器会把它当作 null但如果是int age空字符串会被当作 0。很多“明明没传值却收到默认值”的疑问根源就在这里。解决方案是给参数设NullableT并在视图里用Html.CheckBoxFor之类的强类型辅助方法它会自动处理未勾选时提交隐藏字段的问题。4. 前后端传值避坑指南5 个常见翻车现场与排查思路4.1 现象方法参数能收到 ID却收不到 JSON 对象反应最快的检查顺序是Network 面板看请求头Content-Type是不是application/json再看请求体是不是合法 JSON单引号或者多了一个逗号都会导致反序列化失败最后看控制器 Action 参数类型是不是class而且有没有[FromBody]。这三步能解决九成问题。我曾经排查过一个案例前端fetch明明写了 headers但 Controller 的[ApiController]没加复杂类型参数没有自动推断又没写[FromBody]结果一直 null。解决就是补上[FromBody]并确认请求体不是空字符串。4.2 现象修改 ViewBag 后前端仍然显示旧值这个问题常见于视图输出被浏览器缓存或者控制器有两个 Action 重名导致改错了方法。先把浏览器无痕窗口打开再看控制器断点是否真的进入了修改过的分支。如果断点进去了但值没变那就检查视图里是不是直接用了ViewBag.xxx而没有Html.Raw某些 HTML 编码会把特殊字符改写。顺带提一句如果用了Response.Cache.SetCacheability这类设置浏览器会把旧页面一直带着给控制器方法加[ResponseCache(Location ResponseCacheLocation.None, NoStore true)]可以避免。4.3 现象TempData 跨控制器跳转后消失现象RedirectToAction到另一个控制器的 Action读TempData[msg]得到 null。原因TempData 默认在第一次读取时标记删除如果中间路由或静态文件介入可能被提前消费。解决读取时用TempData.Peek(msg)保留值或者调用TempData.Keep(msg)。在 .NET Core 里 TempData 的存储默认用 Cookie 序列化数据体积大时会被加密或压缩跨服务器部署还要考虑机器密钥一致否则换台机器就丢掉。4.4 现象复选框和下拉多选传过来变成逗号串或丢失HTML 下多选下拉框 name 相同、值多个提交时是多个同名键值对模型绑定器如果能绑定Listint或数组就会正常收集。复选框没勾选时不会提交任何值控制器里拿到的布尔值就会是默认 false如果你用[Required]校验这个字段会导致“明明勾了但服务器说没传”的假象。解决控制器给布尔属性设默认值或者用隐藏字段补一个 false再让复选框覆盖 true。下拉多选直接在 ViewModel 里用Listint SelectedIds前端select multiple的 name 就写SelectedIds绑定器自动组装。4.5 现象Ajax 返回 JSON 被浏览器当文本下载这不是传值失败而是响应的 Content-Type 没有设置成application/json。常见发生在老 MVC 项目用Content(...)返回字符串、但前端直接用 jQuery 接管。换用Json()方法即可同时注意JsonRequestBehavior.AllowGet。在 .NET Core 里没有这个枚举只要Json()就不会有问题。还有一个相关性小坑返回的 JSON 包含DateTime时默认格式是 ISO 8601而老项目里习惯yyyy-MM-dd HH:mm:ss前后端解析不一致时你以为数据丢了其实只是格式没对齐。5. 把传值链路稳定下来的三个习惯ViewModel、JSON DTO 和调试拦截5.1 用 ViewModel 统一“控制器要的”和“前端给的”长期项目里我坚持一个约定页面渲染、表单回传、Ajax 请求体各建一个明确的 DTO 或 ViewModel。有人觉得这样文件太多我反而觉得这是一种“后悔药”——改需求时你看一眼 DTO 就知道前端会传什么不会在控制器里堆一堆Request.Form[xxx]。比如搜索页要传关键词、页码、筛选状态就建一个SearchFormVm属性上直接写默认值。这样控制器里写Search(SearchFormVm vm)绑定器自动把查询字符串映射进去。别人维护代码时看见参数类型就能猜到表单长什么样不用在 Action 里读十几行 Request。5.2 建立一套前后端 JSON 契约而不是让控制器直接暴露 Entity给前端提供 JSON 接口时我坚持不返回实体。原因是实体里经常有导航属性序列化时可能把不该暴露的内部字段发给前端而且包含循环引用时会直接让接口 500。项目里真实踩过的坑返一个Order和它的OrderItems序列化器默认带上全套导航属性返回体积巨大前端解析也慢。后来统一规定所有对外 JSON 都建XxxDto只挑前端需要的字段映射过去。这样传值链路的“值”就变成了明确契约里的字段名任何改名都会在编译期暴露。后端改实体字段名不会再偷偷影响前端前端要求加字段时你也能立刻看到影响范围。这不是让每个 Action 都写一套 DTO而是说只要接口承担跨端协作就要定义边界。5.3 给控制器加一个传值日志十次空值八次立刻现形最后一个习惯是在开发期给控制器加日志输出把方法接收到的参数和请求体原始文本打出来。与其靠猜不如直接写一个ActionFilter在OnActionExecutionAsync里遍历context.ActionArguments把模型绑定完成后参数的真实值打印出来。如果日志里已经是 null问题就在绑定层不用再去翻 HTML如果日志里非 null 但业务里读不到那就是自己的赋值逻辑问题。做完这一步绝大多数“玄学传值”都会变成可枚举的几种原因Content-Type 不对、name 不对、JSON 和 DTO 字段不匹配、TempData 被提前读取、安全验证拦截。按这个顺序排查比在视图里加一百个alert有效率得多。最后说一个我自己改不掉的习惯写任何带输入输出数据的 C# MVC 接口前一定先用 Postman 或浏览器手动打一次再写前端调用。把后端请求调到稳定返回再让前端接锅这样能避免“前端改一行、后端改两行、前端再改三行”的循环。希望这些传值细节能帮你在下一个 C# MVC 项目里少翻几次车。本文还有配套的精品资源点击获取