简介C# MVC控制器前后端传值学习资料包面向初学ASP.NET MVC或希望理清Controller数据传递思路的开发者系统讲解控制器如何连接视图与模型。内容覆盖ViewModel强类型传值、ViewBag/ViewData临时数据、TempData跨请求传递、模型绑定自动接收表单数据以及jQuery Ajax异步交互并结合AntiForgeryToken等安全最佳实践帮助减少CSRF风险。压缩包共112个文件以17个cs源文件、8个cshtml视图模板、13个js脚本、7个css样式以及json、config等配置为主整体仅1.31MB结构紧凑便于直接编译查看或按需搜索。包内同时包含sln工程文件与csproj项目文件可快速还原示例环境。目前已有755人学习下载适合初学者系统掌握传值方式也可供开发者在日常编码时查阅对比。1. 前后端传值没你想的那么简单先分清四种路径再动手面试里最常见的 C# MVC 题目到了项目上最容易翻车的也是它。很多人写完 Action在视图中用 ViewBag 取数据发现值是 null或者表单提交后控制器收到一个全默认值的对象查了两个小时定位不到原因。这类问题的根源往往不是代码写错了而是没把“控制器到视图”和“视图到控制器”这两个方向分开看也没搞懂每条路径背后到底是强类型绑定还是动态字典的存取。这段时间做 C# MVC 项目最直观的感受是传值本质上不是在“传”而是在“匹配”。控制器把数据放进某个容器视图按约定好的名字拿出来视图把表单字段提交回去控制器按参数名或模型属性映射回去——两端只要有一处命名对不上值就丢在半路。这套匹配机制叫模型绑定理解了它之后前后端传值就不再是玄学而是一套能预测、能排查的工程流程。这套内容对三类人最有用刚接手老 MVC 项目的维护者想搞清 ViewBag、TempData、Model 到底该用谁的新手以及被 AJAX 提交 JSON 反复折腾的 .NET 全栈工程师。后面的章节会把四条主要路径做对比再拆开模型绑定器的工作原理最后落一批真实高频的踩坑修复记录。2. 控制器把数据交给视图ViewBag、ViewData、TempData 和强类型 Model 怎么选从控制器向 Razor 视图传值是写 MVC 每个页面都要做的动作也是新手第一次产生困惑的地方。这一章把三种动态容器和一种强类型方式放在同一份数据上对比看清它们的存储位置、作用域和读取策略。2.1 ViewBag / ViewData / TempData 三兄弟的适用边界先在控制器里写一组最典型的传值代码public class HomeController : Controller { public ActionResult Index() { // 方式一ViewData 是键值字典取值要转型 ViewData[SystemName] 订单后台; // 方式二ViewBag 是 dynamic 包装本质还是读 ViewData ViewBag.CurrentUserName 张三; // 方式三TempData 存在 Session 中读取一次后默认清除 TempData[LastLogin] DateTime.Now.AddHours(-2); // 方式四强类型 Model直接传对象给视图 var productList new ListProduct { new Product { Id 1, Name 联想小新Pro, Price 5499m }, new Product { Id 2, Name 罗技MX Master 3S, Price 499m } }; return View(productList); } }视图端的读取代码是这样model ListProduct div系统名称ViewData[SystemName]/div div当前用户ViewBag.CurrentUserName/div div上次登录TempData[LastLogin]/div ul foreach (var p in Model) { lip.Id - p.Name - p.Price/li } /ul逻辑说明ViewBag 和 ViewData 共享同一份 ViewDataDictionaryViewBag 只是提供了动态属性访问语法两者存的键互相可见。TempData 底层走 Session适合重定向后再取数据的跨 Action 场景。最值得注意的反而是 TempData 的读取策略它默认是“读一次删一次”刷新页面第二次取就变成 null。参数说明ViewData 取出来是 object使用前要强转ViewBag 虽然省去强转但编译器无法检查属性名是否正确写错了运行时不报错只给 null排查起来很被动。TempData 想保留到下次读取需要配合 Peek 或 Keep 方法具体行为在避坑章节展开。三种动态容器和强类型 Model 的差异落到一张表里更直观传值方式底层存储类型安全跨 Action读取策略ViewDataViewDataDictionary需强转否页面渲染期间一直可读ViewBagViewDataDictionary 动态包装编译期不检查否同上TempDataSession需强转是读取一次后默认清除强类型 ModelAction 返回值编译期检查否随页面渲染一次成型这张表的核心记忆点就一句话页面内部传值优先 ViewBag 或强类型 Model跨 Action 短时传值才考虑 TempData长期保留的数据一律走 Session 或数据库。按这个判断顺序选型大多数传值方案都不会跑偏。2.2 强类型模型与集合数据的传值写法动态容器传小量数据很方便可页面一旦要循环渲染列表或者希望在编译期就看到模型属性的智能感知就应该切到强类型 Model。做法是视图第一行用 model 声明类型Action 里 return View(数据对象) 把对象传进去。return View(new OrderViewModel { OrderId SO20240501-001, Items new ListOrderItem { new OrderItem { Sku SKU-1001, Qty 2 }, new OrderItem { Sku SKU-1002, Qty 1 } } });视图这边读取model OrderViewModel h3订单号Model.OrderId/h3 foreach (var item in Model.Items) { spanitem.Sku × item.Qty/span }写传值代码时有一个经常被问到的问题传集合给视图用数组还是 List 我的原则是只要数据来自数据库查询或者服务层就声明成 List 或 IReadOnlyList 因为要 foreach、要取 Count、要按索引访问数组在动态增删场景下反而别扭。数组的优势只在长度固定、内容不变的场景比如枚举选项其余情况直接用集合类型更符合 C# 的日常用法。这里还有一层容易被忽略的理由强类型模型变更属性时视图会在编译期直接报错错误发生在生成阶段而不是运行阶段。相比之下ViewBag 的字符串键无法被全局搜索重构时改一处漏一处等用户点开页面才发现某块区域空白再回头定位是哪个键被改掉了时间成本完全不是一个量级。2.3 一个综合案例把数据库查询结果完整渲染到表格视图前面几种写法单独看都不难组合起来才是真实项目的形态。下面这个例子演示一个列表页的完整链路控制器查数据库、按关键字过滤、投影成视图模型、传给强类型视图渲染成表格。public ActionResult CustomerList(string keyword) { var query db.Customers.AsQueryable(); if (!string.IsNullOrWhiteSpace(keyword)) { query query.Where(c c.Name.Contains(keyword)); } var vm new CustomerListViewModel { Keyword keyword, Customers query .OrderBy(c c.CreateTime) .Select(c new CustomerRow { Id c.Id, Name c.Name, Mobile c.Mobile, LastOrderAt c.LastOrderAt }) .ToList() }; return View(vm); }model CustomerListViewModel form methodget input typetext namekeyword valueModel.Keyword / button typesubmit查询/button /form table foreach (var row in Model.Customers) { tr tdrow.Id/td tdrow.Name/td tdrow.Mobile/td tdrow.LastOrderAt.ToString(yyyy-MM-dd HH:mm)/td /tr } /table这里的信息量比看上去大。表单用 Get 方法提交keyword 会出现在 QueryString 中控制器同名参数自动接收这是“视图到控制器”传值方向的一种形态下一章详细展开。先关注控制器到视图这一侧数据库查询结果没有直接把 EF 实体丢给视图而是先 Select 投影成 CustomerRow再放进 CustomerListViewModel。这么做的原因是 EF 实体带着导航属性和变更跟踪状态序列化或循环输出时容易把不必要的数据带出去视图模型只保留页面需要的字段干净、可控、也更安全。如果查询结果为空Model.Customers 是一个空 Listforeach 自然不输出任何行页面不会报错。但很多新手在这里会踩一个坑Action 返回 null 而不是空集合视图 foreach 直接抛 NullReferenceException。所以控制器里做查询时任何可能返回 null 的地方都要养成“返回空集合而不是 null”的习惯这是传值环节最便宜的防御性写法。3. 视图把数据交还给控制器表单提交、QueryString 与 AJAX 三条路控制器到视图只是传值的前半程真正的难点往往在后半程用户改了数据、点了保存浏览器怎么把结构化的数据完整交回给控制器。这一章拆开三条路同步表单 POST、GET 查询字符串、异步 AJAX POST JSON。3.1 同步表单 POST从 HTML Form 到 Action 参数这是传统 MVC 项目里最常见的提交方式。用户在页面上填好信息点击提交浏览器以 application/x-www-form-urlencoded 格式组织请求体MVC 模型绑定器按 Action 参数名去请求体里找同名键值。form action/Order/Create methodpost input typetext nameOrderNo value / input typetext nameCustomerName value / input typenumber nameTotalAmount value0 / button typesubmit提交订单/button /form[HttpPost] public ActionResult Create(OrderInput input) { if (!ModelState.IsValid) { return View(input); } // 保存业务逻辑 ... return RedirectToAction(Detail, new { id input.Id }); }public class OrderInput { public string OrderNo { get; set; } public string CustomerName { get; set; } public decimal TotalAmount { get; set; } }逻辑说明表单控件的 name 属性与 OrderInput 的属性名一一对应。模型绑定器根据 Action 参数类型实例化 OrderInput再按属性名匹配请求体里的键。这里成功的关键是两端命名完全一致——大小写不敏感但拼写不能差少一个字母该属性就停留在默认值。参数说明decimal 类型在绑定失败时不会抛异常而是往 ModelState 里追加一条错误TotalAmount 保持默认值 0。所以 Action 开头检查 ModelState.IsValid 是必要操作这一行能拦截掉大部分脏数据。更重要的一个习惯校验失败时 return View(input)把用户已填的内容回显到页面而不是跳转到一个空白表单页。用户填了五分钟的表单因为一个字段格式不对全部清空这种体验在内部系统里最容易招骂。回显就是传值方向上的“后悔药”。3.2 GET 方式传值的适用场景与 URL 长度边界GET 传值适合查询列表、翻页、详情跳转这类幂等操作。控制器在 Action 参数上声明同名参数MVC 自动从 QueryString 取值。a href/Product/Detail?id1024查看商品详情/apublic ActionResult Detail(int id) { var product db.Products.Find(id); if (product null) { return HttpNotFound(); } return View(product); }这种写法简洁直观但不意味着所有数据都可以塞进 URL。URL 有长度上限部分浏览器和代理服务器会限制在 2KB 到 8KB 之间超过后直接 404 或静默截断。所以搜索条件、分页页码适合走 GET提交正文、长文本、敏感信息一律走 POST。还有一类常见误用把数组或对象用逗号拼在 URL 里再在控制器里 Split 解析这是把模型绑定器的活揽到自己身上还要额外处理转义问题得不偿失。GET 传值还有一个容易验证的细节下拉列表选中值传给控制器时类型要统一。Html.DropDownList 渲染出来的 option value 全是字符串模型绑定器遇到 int 参数会尝试转换一旦 option 里混进空字符串绑定失败会给 ModelState 加错。所以“未选择”状态最好用可空类型 int? 接收或者在前端给空选项一个明确的默认值而不是让它空着提交。3.3 AJAX POST JSON从 fetch 到 ActionResult 的数据来回路现代项目里 AJAX 是主流。页面不刷新前端把 JS 对象转成 JSON 发出去控制器处理后返回 JSON前端再局部更新界面。MVC 5 默认注册了 JsonValueProviderFactory只要请求的 Content-Type 是 application/json模型绑定器就能把 JSON 属性映射到 Action 参数对象上。const payload { orderNo: SO20240501-001, customerName: 李四, items: [ { sku: SKU-1001, qty: 2 }, { sku: SKU-1002, qty: 1 } ] }; fetch(/Order/CreateByJson, { method: POST, headers: { Content-Type: application/json; charsetutf-8 }, body: JSON.stringify(payload) }) .then(response response.json()) .then(data { if (data.code 0) { // 成功后刷新订单列表 loadOrderList(); } else { alert(data.msg); } });[HttpPost] public ActionResult CreateByJson(OrderInput input) { try { // 业务处理保存订单主表与明细 return Json(new { code 0, id input.Id, msg 保存成功 }); } catch (Exception ex) { // 记录 ex 日志堆栈细节留在服务端不要把内部异常抛给前端 return Json(new { code 1, msg 服务器内部错误 }); } }逻辑说明前端 JSON 键名是 camelCase控制器属性是 PascalCase模型绑定器属性匹配默认忽略大小写所以 orderNo 能正确对应 OrderNo。请求头里的 charset 指定后中文 JSON 在传输过程不容易乱码配合后端 Json(...) 按 UTF-8 序列化返回数据来回路就完整了。有一个容易翻车的细节如果 Action 参数声明成 string data 而不是复杂类型对象模型绑定器不会自动把 JSON 请求体反序列化到字符串里data 会得到 null。想要接原文需要手动读 Request.InputStream 再用 JSON.NET 反序列化。这个场景通常出现在后端需要先验签、再解包的业务里普通的增删改查不需要这么干。4. 模型绑定器工作机制为什么参数名对不上就收不到值前面两章把传值方向讲清楚了控制器把数据放进容器视图取出来展示用户把数据提交回来控制器接收并处理。这中间全靠 MVC 框架的自动转换机制在支撑。如果对它的认知停留在“名字对上就行”遇到复杂对象和嵌套集合就会无从下手。这一章把黑匣子打开看模型绑定器到底做了什么。4.1 值提供器和模型绑定器到底做了什么MVC 收到一个请求后进入 Action 之前会先走一遍模型绑定管线。管线的输入是 Request 上的几个数据源Form 表单键值对、QueryString 查询字符串、RouteData 路由参数以及 JSON 请求体。值提供器的职责是把这些原始数据整理成统一的字典入口模型绑定器的职责则是根据 Action 参数的元数据类型、属性名和绑定特性从这个字典里取值并构造对象。MVC 设计模式里控制器负责接收输入并调度处理这套绑定机制本质上是框架替控制器做了“输入解析”这一层让 Action 参数直接和业务数据形态对齐而不是在控制器里写一堆 Request[xx] 的硬编码。两种写法的优劣在 4.3 的演进过程里看得更清楚。值提供器的匹配顺序是固定的通常 RouteData 优先于 QueryStringQueryString 优先于 Form。这个顺序会引发一个典型的翻车场景路由里定义了 id 为固定值表单里也提交了 id最后 Action 收到的可能是路由里的那个而不是用户提交的数据。遇到这种参数重复的情况第一件事不是改代码而是抓包看真实请求里数据的来源再决定是改路由约束还是改表单字段名。4.2 复杂对象与集合的下标绑定写法当 Action 参数是一个包含 List 成员的对象时单纯同名绑定就不够了必须按下标命名规则提供路径信息。form action/Order/BatchCreate methodpost input typetext nameItems[0].Sku valueSKU-1001 / input typenumber nameItems[0].Qty value2 / input typetext nameItems[1].Sku valueSKU-1002 / input typenumber nameItems[1].Qty value1 / button typesubmit批量提交/button /form[HttpPost] public ActionResult BatchCreate(OrderBatchInput input) { // input.Items.Count 2 // input.Items[0].Sku SKU-1001 return Json(new { code 0 }); } public class OrderBatchInput { public ListOrderItem Items { get; set; } } public class OrderItem { public string Sku { get; set; } public int Qty { get; set; } }逻辑说明name 属性中的 Items[0].Sku 是模型绑定器的路径语法——先定位集合 Items再按索引 0 定位元素再绑定元素的 Sku 属性。只要命名严格按照这个约定走前端一个动态表格里的多行明细就能完整绑定进 List。参数说明索引必须从 0 开始且连续。一旦出现 0、1、3 这种跳号绑定器会认为索引 2 的元素不存在后面的元素全部丢弃。这是动态表格交互里最隐蔽的问题前端删除一行时下意识只移除 DOM 节点name 序号没有重排提交后集合数量就少了。如果使用 nameItems[] 这种无下标写法MVC 只能把它解析成 string[]无法直接绑定自定义类型集合。因此动态表格在删除行后必须显式重排所有 name 下标或者每次都重新生成整行 HTML。4.3 从 Request 取值到强类型绑定一段代码的演进过程值提供器和模型绑定器这套机制不是一开始就有的。早年写 ASP.NET 时常见做法是在控制器里直接读 Request解析和业务代码纠缠在一起。// 演进前手动从 Request 取值逐字段拆、转、验 var orderNo Request[orderNo]; var qty int.Parse(Request[qty]); // 前端传“abc”时直接异常 var memo Request[memo] ?? string.Empty; var input new OrderInput { OrderNo orderNo, Qty qty, Memo memo };// 演进后模型绑定器自动完成构造、转换、校验 public ActionResult Create(OrderInput input) { if (ModelState.IsValid) { // 业务逻辑 } }演进后的好处不只是少写几行样板代码。int.Parse 遇到无法解析的文本会直接抛异常让页面 500模型绑定器则会把解析失败记录到 ModelState由开发者决定在页面上提示还是记录日志不会因为一个字段的脏数据打断整个请求。这个行为差异在实际项目里非常重要用户的输入是不可信的绑定失败应该是可控的业务错误而不是未处理的异常。理解这段演进也就能理解为什么传值问题大多数根因都是“两端契约不一致”。Action 参数的类型与前端提交的文本格式本质上是一份需要人工维护的契约模型绑定器按默认规则尽力转换转换不了就留在 ModelState.Errors 里。排查传值问题最高效的方式不是打印一堆日志而是把请求体完整抓出来和 Action 签名的名称、格式、类型逐一对照差异点就是问题点。5. 高频传值故障避坑记录五个真实翻车现场与修复这一章把传值过程中最容易让人耗费半天时间的问题挑出来按现象、原因、解决三步写出。每一条都是可以直接对照操作的排错路径。5.1 现象提交“2024-05-06T08:30:00”绑定不到 DateTime有些页面日期选择器输出的是 ISO 8601 格式提交后控制器里的 DateTime 参数一直是默认值 0001-01-01但同一份代码在另一台机器上又正常。原因模型绑定器解析 DateTime 时受当前线程 CultureInfo 影响。zh-CN 环境对“2024/5/6”这种斜杠格式友好对带 T 分隔的 ISO 格式解析不稳定同时如果页面没有指定明确的日期格式前端输出和后端期望之间容易出现错位。解决前后端统一序列化成 ISO 8601 格式并给模型属性加上明确的格式声明。public class DeliveryModel { [DisplayFormat(DataFormatString {0:yyyy-MM-ddTHH:mm:ss}, ApplyFormatInEditMode true)] public DateTime DeliveryTime { get; set; } }如果项目里大量使用日期传值可以考虑重写一个 DateTimeModelBinder 做统一解析但实际项目里很少需要走到这一步。更常见的落地是前端直接传 ISO 字符串后端不依赖用户浏览器所在区域的文化习惯契约明确后日期绑定问题基本绝迹。另外一个关联点后端返回日期到 JSON 时要注意时区偏移量否则前端显示会差 8 个小时。5.2 现象提交的中文到控制器全是乱码老项目里这个问题很常见页面停留在 GB2312或者 HTML 头里没有声明 charset。提交“张三”到后端变成“寮犱笁”存进数据库再读出来就是一串无法辨认的字符。原因编码问题涉及三重环节。浏览器按页面声明的字符集编码输入内容请求体以字节流发送服务端按 web.config 配置的 requestEncoding 解码。任何一环断掉中文就变成乱码。解决在 web.config 里统一三个编码设置。system.web globalization requestEncodingutf-8 responseEncodingutf-8 fileEncodingutf-8 / /system.web配置之外还要确认两件事页面源文件本身以 UTF-8 保存HTML 头里有 meta charsetutf-8。AJAX 场景记得在 Content-Type 里带上 charsetutf-8。排查这个问题最快的方式是打开浏览器开发者工具先看请求头里的 Content-Type 是否带 charset再看请求体里中文是否已经乱掉——这一步直接定位是发送端坏了还是接收端坏了不用猜。5.3 现象AJAX 提交表单后端返回 RedirectResult页面没跳转有些团队习惯在接收 POST 的 Action 里写 return RedirectToAction(Detail)期望前端 fetch 拿到后自动跟随跳转。但实际页面没有跳转反而拿到了一段 HTML。原因控制器把“视图跳转”和“数据响应”混用了。AJAX 场景里前端要的是结构化数据而 RedirectResult 返回的是一个 302 响应fetch 拿到的是重定向后的最终页面 HTML前端无法从中判断成功还是失败。解决AJAX 请求一律返回 JSON把跳转 URL 作为数据带出去由前端控制跳转。return Json(new { code 0, redirectUrl Url.Action(Detail, new { id input.Id }) });if (data.code 0) { window.location.href data.redirectUrl; }调整之后控制器的职责也更清晰同步表单场景用 RedirectToAction异步 AJAX 场景用 Json 返回不再出现“返回了页面还要求前端解析”的尴尬状态。5.4 现象TempData 存了数据跳转页面第一次取到了刷新一次就没了TempData 底层的 Session 存储是跨请求保留的但它的读取策略是“读后即删”。第一次读取后该项被标记删除第二次请求即使还在同一个页面再读就是 null。这个行为和 ViewBag 完全不同后者在页面渲染期间一直可用。解决如果需要在同一请求内多次读取或者要保留到后续请求用 Peek 表示“读但不删”用 Keep 表示“保留一次”。// 第一次读且不删 var nextMsg TempData.Peek(Message); // 在后续某个 Action 里标记这个键再保留一次 TempData.Keep(Message);TempData 适合放“操作成功提示”这类一次性消息不适合放购物车、用户身份等必须持久的数据。传值方案的层级感在这里体现得很明显ViewBag 和 ViewData 是页面内部传TempData 是跨请求短时传Session 是跨会话持久传。不要图省事把数据全塞进 TempData等到二次读取时才发现被清掉了那种排查过程非常痛苦。5.5 现象动态表格的数组绑定提交后集合总数对不上列表页允许用户增删行、编辑多行后批量保存提交到控制器后 List 的元素数量和页面显示的行数不一致而且缺失的总是中间某几行。原因集合绑定的下标连续性被破坏。前端删除行时只移除了 DOM没有重排剩余行的 name 下标或者新增行时用随机数当下标导致绑定器在某个索引处中断后续元素全部丢弃。解决每次增删行之后显式调用一次重排函数把所有字段的 name 重新写成 Items[0].xxx、Items[1].xxx 这种连续下标。function reindexRows() { document.querySelectorAll(#detailTable tbody tr).forEach((row, index) { row.querySelector(input[name*.Sku]).name Items[${index}].Sku; row.querySelector(input[name*.Qty]).name Items[${index}].Qty; }); }这段逻辑看起来朴素但恰恰是动态表格项目里翻车频率最高的一环。如果觉得每次都重排太麻烦另一个思路是前端把整个表格序列化成 JSON 对象树一次性提交给接收强类型参数的 Action用 JSON 绑定来保证结构完整不再依赖下标。6. 传值之外防重复提交、大体积传输与前后端分离边界6.1 接口快速提交的防重闸门“c# mvc 防止接口快速提交”是很多线上事故的源头。用户连点两次保存按钮表单 POST 两次产生两笔重复订单——前端按钮置灰能挡住正常用户但拦不住脚本重放。我一般会在后端加一道一次性令牌校验。页面 GET 时生成令牌存入 Session同时放进隐藏域提交时控制器比对 Session 与表单值通过后立即移除令牌。第二次请求因为 Session 里已经没有对应令牌直接拒绝。// 页面 GET 的 Action public ActionResult Create() { Session[OrderSubmitToken] Guid.NewGuid().ToString(N); return View(); } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Create(OrderInput input) { var expected Session[OrderSubmitToken] as string; var submitted Request.Form[submitToken]; if (string.IsNullOrEmpty(expected) || submitted ! expected) { return Json(new { code 1, msg 请勿重复提交 }); } Session.Remove(OrderSubmitToken); // 业务保存逻辑 ... }前端在页面里渲染一个隐藏域用于提交 submitToken。这套方案比纯按钮置灰可靠因为令牌只能被消费一次。更重的做法是拿订单号去数据库做唯一约束但那样得把数据库异常当业务判断用我一般只在金额类核心单据上这么做普通创建接口用上面的令牌方案就足够了。6.2 数据量一大就换武器批量提交请绕开表单裸传前后端传值一旦涉及大批量数据比如表格导入、批量保存几十上百行明细直接走表单 POST 容易撞上超时和体积限制。web.config 里 maxRequestLength 的默认值是 4MBIIS 7 之后还有 maxAllowedContentLength 这道独立限制只改一处常常收到 404.13。大量数据传值的稳妥做法是前端把数据组装成 JSON 字符串用 fetch 发送后端手动读取请求体再反序列化绕开模型绑定器对大对象的限制同时保留对整个原文做压缩解压或验签的空间。[HttpPost] public ActionResult BatchSave() { string body; using (var reader new StreamReader(Request.InputStream)) { body reader.ReadToEnd(); } var items JsonConvert.DeserializeObjectListOrderItem(body); if (items null || items.Count 0) { return Json(new { code 1, msg 明细不能为空 }); } // 批量入库逻辑 ... return Json(new { code 0, count items.Count }); }注意 Request.InputStream 的位置是游标式的读取一次后位置到末尾第二次 ReadToEnd 会返回空字符串需要手动把 Position 重置为 0。前端如果对超过几百 KB 的 JSON 做 GZip 压缩传输时间会明显下降但后端要额外处理解压。到了这个阶段传值问题已经不只是绑定问题而是产品形态问题——明细超过几百行应该改成逐行保存或后台任务而不是把所有数据压进一次请求里。6.3 前后端分离之后MVC 控制器还承担什么职责新项目越来越多地放弃 Razor 渲染页面把 MVC 控制器改造成纯 API 接口。这时候 ViewBag、TempData 这类面向页面的东西基本退场前后端传值收敛成两件事接口参数模型绑定和返回统一 JSON 响应结构。数据注解校验仍然有用只是渲染层从 cshtml 换成了前端框架。我现在写控制器的习惯是只做三件事接收参数并校验、调用业务层、把结果包装成统一响应返回。传值契约用 DTO 类型定义维护加字段、改类型都有编译期提示用户身份从 Claims 里取而不是相信前端传过来的字符串。把传值问题压缩到“模型契约加数据校验加安全边界”这三个维度之后系统的稳定性确实比老式 ViewBag 满天飞的写法好维护得多。翻看早年写的控制器最大的变化是不再写那种“从 Request 取到值就万事大吉”的代码而是先问数据从哪来、格式是否稳定、用户能不能伪造。强类型习惯一直保留着多拆 DTO、少塞动态字典前后端传值就不再是每次上线的焦虑点。希望这些思路能帮你在排查传值问题时少走弯路。本文还有配套的精品资源点击获取