简介:控制器前后端传值是C# MVC开发中的核心环节,这份资源整理了一套可运行的示例工程与配套笔记,面向ASP.NET MVC初学者和需要系统梳理数据传递方式的开发者。压缩包内共112个文件,以C#源文件(.cs)承载控制器与模型逻辑,Razor视图(.cshtml)负责页面展示,JavaScript和CSS实现前端交互与样式,另有JSON、Config等配置文件及项目工程文件,整体仅1.31MB,可快速下载并用Visual Studio直接打开调试。资源已有755人学习下载。内容依据MVC分层思想,逐一演示了ViewModel强类型传值、ViewBag/ViewData动态传值、TempData跨请求数据共享、模型绑定自动映射表单参数以及Ajax异步交互等五种前后端通信方式,每个示例都配有精简代码和实现说明;还总结了保持控制器简洁、使用AntiForgeryToken防止跨站请求伪造、按数据生命周期选择传递方式等最佳实践。对照练习后,可以建立起从控制器到视图再到前端的完整数据链路认知,提升实际项目开发中的传值效率与代码质量。
1. 控制器传值:所有 MVC 项目的第一个分水岭
“C#MVC控制器前后端传值”本质上解决的是一个非常具体的工程问题:浏览器里的表单、AJAX 请求、路由里的参数,怎么安全、完整地落到控制器方法里,再以 HTML 或 JSON 的形式回到页面。很多项目刚开始不重视这一层,等页面超过二十个、参数超过十几个,改一个字段名就得全链路排查,后端拿到的数据要么是 null,要么类型对不上,调试像开黑匣子。这篇文章把控制器接收参数、返回数据的完整链路拆开讲,从模型绑定到强类型视图,再到 JSON 交互和 PRG 模式,适合写过几个 MVC 页面但没系统理过传值体系的初级开发者,也适合被 ViewBag 和 TempData 坑过的中级开发者做一次排查。
2. 传值链路的第一站:路由与模型绑定,以及六种传值方式的选型
2.1 从 URL 到方法参数:控制器拿值的完整链路
浏览器发出一个 GET 或 POST 请求,ASP.NET MVC 先做路由匹配,把 URL 段映射到某个控制器的某个 Action 方法,再把 HTTP 请求里携带的数据经模型绑定器(Model Binder)按名字填进方法的参数。这个环节是前后端传值的地基,路由匹配错了或者参数名对不上,后面什么都白搭。
先看 ASP.NET Core MVC 的默认路由注册,这段代码在 Program.cs 里:
// ASP.NET Core MVC(.NET 6/7/8)的默认路由注册 app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");pattern 里的三个占位符自解释:controller 对应控制器名,action 对应方法名,id 是可选参数。请求/Order/Detail/1001会被映射到OrderController.Detail方法,1001自动填进id。注意查询字符串不在 pattern 里,它由模型绑定器另一路处理。
再看一个 Action 签名,展示同一个方法从不同来源取值的自然写法:
public class OrderController : Controller { // GET /Order/Detail/1001?source=portal public IActionResult Detail(int id, string source) { // id 来自路由片段 1001,source 来自查询串 portal // 绑定器自动完成了从字符串到 int 的转换 return Content($"order={id}, source={source}"); } }这段代码的逻辑说明:模型绑定器维护一个值集合,里面同时包含表单字段、路由片段、查询字符串,绑定器按参数名去这些源里找匹配项。id从路由片段里取到,source从查询串里取到,开发者不需要自己解析Request.QueryString。转换失败时,绑定器不会抛异常,而是把错误记进ModelState,所以 Action 里可以用ModelState.IsValid统一判断。
在 ASP.NET Core MVC 里,这套机制换了一层 endpoint 路由的外壳,但控制器接收参数的逻辑和 .NET Framework 时代的 MVC5 几乎一脉相承。搞过 Spring MVC 的老同学可以把控制器理解成 handler method,把模型绑定理解成参数解析器,上手会很快。有一点值得注意:ASP.NET MVC 里传值手段多,但新项目建议直接按 ASP.NET Core MVC 的方式写代码,避免把老 MVC5 的某些过时习惯带进来。
提示:路由的
id?表示可选,如果方法签名里有非可空int而路由没提供值,绑定器会尝试从查询串或表单里找,找不到就给默认值 0,不会直接 404。
2.2 六种传值方式的选型表
传值方式太多,很多人是“哪个顺手用哪个”,结果项目后期到处是黑匣子。先把 MVC 框架里常见的方式放进一张表,再讲我的取舍逻辑:
| 传值方式 | 生命周期 | 怎么读 | 典型场景 |
|---|---|---|---|
| ViewData | 当前请求内 | ViewData["Key"]需强转 | 给视图塞辅助数据 |
| ViewBag | 当前请求内 | ViewBag.Key | 写起来快,临时用 |
| TempData | 跨一次请求 | TempData["Key"],读一次会用掉 | PRG 后传提示语 |
| Model 强类型 | 当前请求内 | @model指令 | 主数据渲染(最推荐) |
| Session | 跨请求持久 | HttpContext.Session | 登录态等少量数据 |
| JsonResult + AJAX | 单次异步交互 | 前端response.json() | 局部刷新/前后端配合 |
我的取舍建议是:主线数据用强类型,辅助数据用 ViewBag,跨请求用 TempData,Session 只给那种每个页面都要读的场景,比如登录用户名。最忌讳在控制器里定义 static 变量传值——IIS 应用池回收、多用户并发都会让你怀疑人生,这种方案看起来省事,实际是最贵的。
ViewBag 和 ViewData 是同一份字典数据,ViewBag 只是 ViewData 的 dynamic 包装,两者可以混读,但项目里最好只选一个用。主数据不要走 ViewBag,因为它是黑匣子:编译器无法检查动态类型的属性是否存在,存进去的是不是正确类型,要等运行期才知道。TempData 适合传“一次性提示语”,但它背后有会话序列化开销,别拿它传大数据,更别传实体对象列表。
3. 把表单数据送进控制器:GET、POST 与复杂对象绑定的可复现代码
3.1 一个表单从页面到控制器的完整例子
这是最简单的场景,也是每个 MVC 项目都会遇到的:用户填表单,点提交,控制器接收后处理。下面的视图代码用 Razor 写,放在Views/Order/Create.cshtml:
@model OrderViewModel <form asp-action="Create" asp-controller="Order" method="post"> <label>订单号</label> <input type="text" name="OrderNo" value="@Model?.OrderNo" /> <label>金额</label> <input type="number" name="Total" value="@Model?.Total" /> <button type="submit">提交</button> </form>对应的控制器方法:
[HttpPost] public IActionResult Create(OrderViewModel order) { if (!ModelState.IsValid) { return View(order); // 校验失败,把已填的值送回表单 } return RedirectToAction(nameof(Index)); }参数说明:name属性是模型绑定器的查找键,绑定器拿到表单字段后,按名字OrderNo、Total去匹配OrderViewModel的同名属性,自动创建对象并赋值。asp-action和asp-controller是 Razor 辅助标签,运行时生成正确的action和method属性,比手写字符串更稳。ModelState.IsValid对应 ViewModel 上的数据注解,比如[Required]、[Range],校验失败时把对象原样送回视图,用户输入不会丢。
这里有个新手常踩的点:表单的 method 必须是post,否则浏览器用查询字符串提交,敏感数据会出现在 URL 里。POST 请求体默认是application/x-www-form-urlencoded编码,键值对形式和查询字符串很像,但位置不同。如果用 fetch 手动发请求,Content-Type 也要保持一致。
3.2 嵌套对象和集合怎么命名
订单场景里,ViewModel 经常包含客户信息和多条明细。C# 侧定义如下:
public class OrderViewModel { public int CustomerId { get; set; } public Customer Customer { get; set; } public List<OrderItem> Items { get; set; } } public class Customer { public string Name { get; set; } } public class OrderItem { public string Sku { get; set; } public int Qty { get; set; } }视图里的 input 名称要写成点号和索引器形式,绑定器才能识别出嵌套关系:
<input name="Customer.Name" value="@(Model?.Customer?.Name)" /> <input name="Items[0].Sku" value="@(Model?.Items?[0]?.Sku)" /> <input name="Items[0].Qty" value="@(Model?.Items?[0]?.Qty)" />这里点号表示嵌套属性,方括号表示集合下标。绑定器在Customer为 null 时会先 new 一个出来,再把Name填进去;对Items也是同理,按索引逐个生成对象。注意一个前提:集合属性必须有可写的 setter,或者在声明时初始化,比如public List<OrderItem> Items { get; set; } = new();。很多项目里绑定后Items为 null,就是属性只有 getter 或者根本没初始化,绑定器无法往里填充,这在调试时非常迷惑。
集合绑定还有一个边界坑:下标必须连续。如果页面只有Items[2].Sku,没有Items[0]和Items[1],绑定器会放弃解析整个集合。要么下标从 0 连续排列,要么用隐藏字段给缺失下标补空值,否则就等着参数集合为 null 吧。
3.3 文件上传的接收写法
文件上传是传值体系里比较特殊的一类:数据不是键值对,而是 multipart 格式的二进制流。视图端先要保证 form 有enctype:
<form enctype="multipart/form-data" asp-action="Upload" method="post"> <input type="file" name="file" /> <button type="submit">上传</button> </form>控制器端用IFormFile接收:
[HttpPost] public async Task<IActionResult> Upload(IFormFile file) { if (file == null || file.Length == 0) return BadRequest("没有拿到文件"); var saveDir = Path.Combine(Directory.GetCurrentDirectory(), "uploads"); Directory.CreateDirectory(saveDir); var savePath = Path.Combine(saveDir, Path.GetFileName(file.FileName)); await using var stream = System.IO.File.Create(savePath); await file.CopyToAsync(stream); return Json(new { fileName = file.FileName, size = file.Length }); }IFormFile是 ASP.NET Core 对上传文件的流式抽象,不要为了拿字节数先把整个文件读进内存。大文件场景下,全量读入内存会拖垮进程,CopyToAsync边读边写才是正道。生产环境还要补两道防线:扩展名白名单(防脚本文件)和大小限制(MaxRequestBodySize),否则一个超大文件就能让你的站点陷入假死。
有人把文件 base64 编码后塞进 JSON 提交,那是另一套方案,适合小文件或接口对接场景,但不适合常规表单。浏览器上传原生就是 multipart,没必要绕一圈。
4. 把数据送回前端:强类型视图与 JSON 交互的标准输出姿势
4.1 用 @model 传主数据,而不是 ViewBag
控制器把数据送回视图,最简单的方式是return View(vm),让 Razor 视图通过@model指令拿到强类型对象。看一个编辑页的完整写法:
public IActionResult Edit(int id) { var order = _repo.Find(id); if (order == null) return NotFound(); var vm = new OrderViewModel { Id = order.Id, OrderNo = order.OrderNo, Total = order.Total }; return View(vm); }视图顶部声明模型类型,页面里就能直接引用:
@model OrderViewModel <p>@Model.OrderNo</p> <input asp-for="OrderNo" />这里我一般不用 ViewBag 传主数据,原因很实际:ViewBag 是 dynamic,编译器不检查类型,属性名写错要等运行期才报错,查起来效率极低。强类型视图在 Razor 编译期就能发现属性不存在的问题,改字段名时全项目编译一遍,哪里断了立刻知道。asp-for="OrderNo"这个辅助标签会自动生成name和value,比手写name="OrderNo"更不容易出错。
补充一个细节:ViewData 和 ViewBag 是同一份字典,用哪个都行,但一个页面里别混着用。混用的后果是代码可读性差,后来的人不知道数据到底存在哪个容器里。
4.2 控制器返回 JSON:Content-Type 与 JSON 匹配配置
AJAX 场景下,控制器返回 JSON 是常态。前端用 fetch 发送数据,控制器用[FromBody]接收:
[HttpPost] public IActionResult Save([FromBody] OrderViewModel order) { if (order == null || order.OrderNo == null) return BadRequest(new { error = "字段没绑上" }); return Json(new { ok = true, orderId = order.Id }); }前端对应的请求写法:
fetch('/Order/Save', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ orderNo: 'A001', total: 299.9 }) })[FromBody]的作用是告诉绑定器:不要走表单解析,直接从请求体读 JSON。前端Content-Type必须设为application/json,否则请求体是纯文本,后端解析不出对象。
这里最值得花十分钟统一的就是 JSON 匹配配置。前端 JavaScript 习惯用 camelCase 的 key(orderNo),C# 属性是 PascalCase(OrderNo),如果不做配置,最容易出现“对象不为 null 但字段全是默认值”的现象。在 Program.cs 里加一段配置:
builder.Services.AddControllers() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.PropertyNameCaseInsensitive = true; options.JsonSerializerOptions.Encoder = System.Text.Encodings.Web.JavaScriptEncoder.Default; });PropertyNamingPolicy = CamelCase让序列化时把OrderNo输出成orderNo,两端命名习惯就对上了;PropertyNameCaseInsensitive是双保险,前端万一传了orderno也能匹配。做完这步,前后端 JSON 传值的大部分字段名问题都能消掉,调试时不再需要逐个字段对大小写。
4.3 POST-Redirect-GET:怎么避免刷新重复提交
表单提交后直接return View()有一个隐患:用户按 F5 刷新,浏览器会重新提交上一次的 POST 请求,订单被创建两次。业界标准做法是 PRG 模式,控制器代码长这样:
[HttpPost] public IActionResult Create(OrderViewModel order) { if (!ModelState.IsValid) return View(order); TempData["Notice"] = "创建成功"; return RedirectToAction(nameof(Index)); } public IActionResult Index() { ViewBag.Notice = TempData["Notice"]; return View(); }处理完业务先RedirectToAction,浏览器收到 302 后主动 GET 一次Index,地址栏变成/Order/Index。这时候按 F5 刷新只是重复 GET,不会重新提交表单。TempData在这里承担“一次性提示”的职责:它只存活到下一次请求读取,正好匹配“跳转后显示一条成功信息,刷新后消失”的交互需求。
需要提醒的是,TempData默认读一次就会标记删除,如果跳转后的页面还要在别处再读一次,第二次就拿到 null。保留数据要用TempData.Peek("Key")或TempData.Keep("Key"),后者适合在同一个请求里后续还要用的场景。
5. 前后端传值避坑:5 条让调试翻车的排查清单
5.1 参数全是 null:先看请求体再对签名
现象:POST 提交后成功进入 Action,但参数对象非 null,内部字段全是 null 或 0。
原因:前端 input 没有写name属性,或者name与后端 ViewModel 属性名不一致。另一个常见原因是表单里用了disabled控件——禁用状态的字段根本不参与提交,值到不了后端。
解决:打开浏览器 F12 的 Network 面板,找到提交请求,看 Form Data 或 Payload 里实际有哪些字段名,再与 Action 参数逐个核对。光靠肉眼对源码效率太低,请求体是真相。
public IActionResult Create(OrderViewModel order) { ... } // 前端必须是 <input name="OrderNo" />,OrderNo 才能绑上5.2 JSON 提交后端对象全是默认值
现象:fetch 提交 JSON,后端[FromBody]参数对象不是 null,但所有属性都是默认值——字符串 null,数字 0。
原因:两种可能。一是Content-Type没设成application/json,浏览器默认发text/plain,后端解析不到请求体;二是 JSON key 用了 camelCase,C# 属性是 PascalCase,且项目没有配置命名映射策略。
解决:先确认请求头,再用 curl 模拟一次:
curl -i -X POST http://localhost:5000/Order/Save \ -H "Content-Type: application/json" \ -d '{"orderNo":"A001"}'如果 curl 能通而页面不行,问题在前端;如果 curl 也不行,检查后端AddJsonOptions的 JSON 匹配配置。字段名大小写这种问题,配置了PropertyNameCaseInsensitive = true后基本能根治。
5.3 ViewBag 传 List,视图 foreach 时炸掉
现象:控制器里ViewBag.List = items;,视图里foreach (var item in ViewBag.List)第二行抛NullReferenceException或类型转换异常。
原因:ViewBag 是 dynamic,编译器不检查类型。控制器实际塞进去的是 null,或者塞的是别的类型,视图运行时才暴露。这个黑匣子行为在传值里最容易让人迷惑。
解决:主数据一律改用强类型视图。控制器里改成return View(items);,视图声明@model IReadOnlyList<OrderItem>。如果确实要用 ViewBag 传辅助数据,塞之前先判空,视图里再用as转换并判 null。
5.4 TempData 只读了一次就没了
现象:PRG 跳转后第一次显示“创建成功”,用户刷新页面提示消失,或者跳到第二个页面时拿不到。
原因:TempData默认读一次即标记删除,请求结束时就清理了。第二次读取自然为空,这不是偶发问题,是它的设计行为。
解决:需要保留时改用Peek读取,或在读取前调用Keep。
var notice = TempData.Peek("Notice") as string; // 读但不删除 TempData.Keep("Notice"); // 保留到下一次请求5.5 文件上传始终拿到 null
现象:选了文件点提交,IFormFile file参数是 null。
原因:form 标签没写enctype="multipart/form-data"。浏览器默认用application/x-www-form-urlencoded编码,文件内容不会按 multipart 格式发送,后端自然拿不到。另一个常见原因是 input 的name与参数名不一致。
解决:form 上明确写enctype="multipart/form-data",input 的name="file"和参数IFormFile file对齐。控制器开头加快速失败:
if (file == null || file.Length == 0) return BadRequest("没有拿到文件");尽早暴露问题,而不是带着 null 往下走,日志排查会省很多时间。
6. 把传值收口成一套约定:提交单元、命名与验收习惯
6.1 给自己定三条约定
传值代码写多了会发现,大部分返工都来自没有约定。我现在的做法是给自己定三条规则:一个 Action 只接收一个 ViewModel 参数,把它看作一个完整的“提交单元”,散装参数最多留给id这种查询场景;前端name或 JSON key 与 C# 属性名保持一致,相差的部分由配置统一负责转换,而不是靠人肉记住哪个字段改过名;返回视图时统一强类型 ViewModel,返回 JSON 时用 DTO 或匿名对象,别把 EF 实体直接序列化——导航属性循环引用会让你序列化直接炸掉。
6.2 验收习惯:三个 grep
提交代码前我会做三个快速检查:页面里出现的每个name字段,都应该能在 ViewModel 里 grep 到对应属性;控制器return View(模型)的类型,必须与视图顶行@model声明一致;每次调接口发现传值问题,先看 Network 里的 Payload 再下结论,不要第一时间怀疑后端。
注意:传值问题的根源往往在“两端各改了一边”,前端代码和后端代码不是同一个人维护时,这个检查尤其重要。
我最早做 MVC 项目时习惯用 ViewBag 到处塞数据,项目到中期一个字段重构直接劝退,改一个属性名全站静态查找。后来把传值收口成强类型 ViewModel,字段名错了编译期就报出来,才算把前后端传值这件事真正握在手里。如果你已经在传值上花过不少冤枉时间,从下一个功能开始,把提交单元、命名策略和强类型视图这三件事定下来,后面会顺畅很多。希望帮到你。
本文还有配套的精品资源,点击获取