简介这是一份专门面向ASP.NET开发者的GridView实用实例旨在解决Visual Studio自带GridView在数据增删改操作与交互体验上的明显短板。资源基于ASP.NET 4.0与SQL Server 2008环境实现了完整的无刷新Ajax增删改功能代码量少、结构清晰同时具备良好的可移植性可快速复用或迁移到正在开发的信息系统、后台管理模块中。压缩包共2个文件包含一个htm格式的说明与示例页面以及一个rar格式的完整源码包整体压缩后仅1.52MB结构紧凑适合下载后直接对照调试或按需拆解参考。目前已有650人学习浏览特别适合正在用GridView做数据管理、希望避开自带控件操作局限、快速获得成熟无刷新方案的中初级开发者收藏使用。1. 为什么说 GridView AJAX 效果是 Web Forms 老项目的最后一块甜点“非常好用的 gridview(asp.net,ajax效果)”这句话我是在接手一个遗留 OA 项目时第一次听到的。那个系统跑了快十年界面还是 Web Forms 那套灰底蓝框的风格但所有列表页都靠着 GridView 和 UpdatePanel 做到了“点分页不闪屏、点排序不跳转”在老运维眼里这就是能用的 AJAX 效果。说白了GridView 把表格展示、分页、排序、模板列、事件回发打包成一套声明式配置UpdatePanel 再补上一层局部刷新两件套配合下来一个内部管理系统的列表页几乎不用写多少 JavaScript。这也正是标题里“好用”二字的真实来源。不过这里有个反直觉的结论你看到的“AJAX 效果”大部分是伪异步。点击分页时浏览器确实没有整页刷新但服务器照样把整个页面生命周期完整执行了一遍只是最后只把 UpdatePanel 里的 HTML 片段回传给你。数据量小的时候体验很好一旦数据量上来ViewState 膨胀、线程占用、响应变慢这些老问题一个都跑不掉。这篇笔记不打算否定 GridView而是想把它真正的边界讲透什么时候直接用官方这套 Ajax 效果就够什么时候该换成真异步的数据接口加前端渲染。适合的人是刚接手 Web Forms 老项目的开发者以及还在用这套技术栈做企业内部系统的团队想在不换框架的前提下把交互体验和性能往上拉一档。2. GridView 为什么“好用”先看懂它替你干了哪些脏活2.1 数据绑定、自动分页、排序、模板列几千行数据内的痛快体验GridView 能火这么多年核心原因是把 Web 开发里最高频的表格场景封装成了声明式属性。你不需要写循环拼table不需要自己管分页条的状态不需要给每个列单独写渲染逻辑。AutoGenerateColumns可以自动生成列AllowPaging加PageSize就有分页按钮AllowSorting一开表头就能点击排序TemplateField能让你在列里放任意 ASP.NET 控件做编辑、删除、状态切换这类操作。对一个几千行以内的内部列表页这套能力基本是开箱即用。我一般会从一个最小可跑的页面开始先把 GridView 立起来再逐步加 ajax 效果。下面是一个典型的列表页分页和基础列都配好了asp:GridView IDgvList runatserver AutoGenerateColumnsFalse AllowPagingTrue PageSize10 OnPageIndexChanginggvList_PageIndexChanging OnRowDataBoundgvList_RowDataBound Columns asp:BoundField DataFieldId HeaderText编号 / asp:BoundField DataFieldApplyName HeaderText申请人 / asp:BoundField DataFieldApplyTime HeaderText申请时间 DataFormatString{0:yyyy-MM-dd} HtmlEncodefalse / asp:TemplateField HeaderText操作 ItemTemplate asp:LinkButton IDbtnEdit runatserver CommandNameEdit CommandArgument%# Eval(Id) %编辑/asp:LinkButton /ItemTemplate /asp:TemplateField /Columns PagerSettings ModeNumericFirstLast PageButtonCount5 / /asp:GridView后台代码只需要把 DataTable 塞进去protected void Page_Load(object sender, EventArgs e) { if (!IsPostBack) { BindGrid(); } } private void BindGrid() { DataTable dt GetData(); // 实际项目中按条件查询数据库 gvList.DataSource dt; gvList.DataBind(); } protected void gvList_PageIndexChanging(object sender, GridViewPageEventArgs e) { gvList.PageIndex e.NewPageIndex; BindGrid(); }逻辑说明PageIndexChanging是分页动作触发的事件先更新gvList.PageIndex再重新绑定数据源。这里必须重新DataBind()否则网格不会切换到新页码。PageSize10控制每页行数PagerSettings里的ModeNumericFirstLast表示分页条显示数字页码和首页末页按钮。参数说明里最容易被忽略的是GridView 自带分页是内存分页不是数据库分页。它会把整张 DataTable 先绑进来再在内存里切出当前页。所以几千行没问题几万行就会明显卡。后面第 3、4 章会重点讲这个坑的解法。关键属性的取舍可以看这张表属性作用建议值AutoGenerateColumns是否自动生成绑定列手动建列时设FalseAllowPaging开启自带分页小数据量开TruePageSize每页行数默认 10接口列表可以 20AllowSorting开启排序配合数据源里有名字段EnableViewState是否保存控件状态大数据量建议关掉2.2 UpdatePanel 的“Ajax 效果”是怎么工作的伪异步的黑匣子很多人说 GridView 好用其实说的是 GridView 放在 UpdatePanel 里好用。UpdatePanel 的原理一句话能讲完页面里放一个ScriptManager把需要局部刷新的区域包进UpdatePanel区域内任何控件触发回发时浏览器用 AJAX 把表单数据发到服务器服务器照常走完整的Page_Load、事件处理、DataBind、Render生命周期最后只把 UpdatePanel 对应区域的 HTML 片段传回来由ScriptManager生成的脚本替换掉 DOM 里的旧内容。这也就是我说的“伪异步”。对用户来说页面没闪没跳体验接近 AJAX但对服务器来说每次操作依然是一个完整的 ASP.NET 页面回发该加载的数据一样加载该执行的代码一样执行甚至因为要序列化和反序列化 ViewState请求体可能比普通回发还要大。最常见的搭配写法是这样asp:ScriptManager IDsm runatserver / asp:UpdatePanel IDup runatserver UpdateModeConditional ContentTemplate asp:Button IDbtnSearch runatserver Text查询 OnClickbtnSearch_Click / asp:GridView IDgvList runatserver AutoGenerateColumnsFalse AllowPagingTrue OnPageIndexChanginggvList_PageIndexChanging !-- 列定义同上 -- /asp:GridView /ContentTemplate Triggers asp:AsyncPostBackTrigger ControlIDbtnSearch EventNameClick / /Triggers /asp:UpdatePanel逻辑说明ScriptManager是整套异步能力的前提没有它 UpdatePanel 不工作。UpdateModeConditional表示只有当 UpdatePanel 内部的控件触发回发或者被Triggers指定的控件触发回发时才刷新这块区域如果设成默认的Always页面上任何一个 PostBack 都会连带刷新它网络流量白白浪费。参数说明ChildrenAsTriggers默认是True意思是面板内子控件的回发都会触发异步刷新。如果你希望更严格地控制刷新时机可以把UpdateModeConditional和ChildrenAsTriggersFalse搭配用然后在后台手动调用up.Update()。AsyncPostBackTrigger走的是 AJAX 局部刷新而PostBackTrigger会让指定控件强制走整页同步回发文件上传这种场景必须用后者。2.3 什么时候该警觉三个性能报警信号我在项目里总结出三个“该收手”的信号供你对照排查。第一个是 ViewState 膨胀。GridView 默认会把绑定进去的数据源整个塞进__VIEWSTATE隐藏字段里打开浏览器开发者工具看一眼 Network 面板的响应体积如果一个列表页的 HTML 有好几百 KB里面大半都是这个隐藏字段说明数据量已经不小了。第二个是每点一次分页服务器压力肉眼可见地上升。UpdatePanel 再怎么异步服务器执行的都是完整生命周期IIS 线程在请求期间一直被占着多人同时操作时线程池容易吃紧。第三个是页面里开始出现“点击行内按钮再查一下明细”这种联动需求数据刷新逻辑散落在多个事件里GridView 本身的回发模型会越写越绕代码维护成本明显上升。这三个信号对应一条判断标准如果 GridView 只是展示表格几百到几千行数据UpdatePanel 足够如果列表要支撑频繁搜索、大数据量分页、复杂的前端联动交互就应该考虑把数据加载从服务端渲染换成客户端渲染也就是第 3 章要讲的改造路线。提示UpdatePanel 不是真正的 AJAX它只是把完整页面生命周期照常执行再把属于 UpdatePanel 的 HTML 片段回传替换。排查性能问题时先看这一点能省很多时间。3. 把 GridView 改造成真异步一套不换框架的升级路线3.1 先做判断不是所有页面都值得改造成真异步动手之前先想清楚改造目标。常见误区是把所有 GridView 页面一刀切全部改成前端渲染结果老系统里明明只有几十行数据的字典表也被折腾了一遍收益极低。我的判断标准有三条数据量大不大交互频率高不高后续需求复不复杂。一个基础数据维护页面用户一天点不了几次UpdatePanel 原样留着反而是成本最低的选择改造成真异步要写前端渲染、要处理分页状态投入产出比不划算。反过来一个每天被反复搜索、翻页、排序的列表查询页面每操作一次就触发完整回发服务器压力和用户体验都成问题那就值得改造。改造策略上我一般会选一个查询列表页做试点把“查询、分页、排序”这几件事切成 JSON 接口前端用 fetch 拉数据再渲染表格而“编辑、删除、状态变更”这类行内操作继续保留传统回发用 UpdatePanel 或直接 PostBack 都行。这样风险最小老页面不会一次性被推翻。技术上后端接口用一般处理程序.ashx而不是 Web API原因是老项目里加 Web API 需要配路由、处理依赖注入、可能还要跨域改动范围一下子变大.ashx只要丢到项目里就能被 IIS 直接识别代码结构也直观最适合渐进式改造。3.2 后端接口用一般处理程序返回分页 JSON整个改造的核心是让后端只返回数据不再返回整个页面。下面是这个接口的完整写法包含分页、排序、关键字搜索和白名单校验% WebHandler LanguageC# ClassGridDataHandler % using System; using System.Collections.Generic; using System.Data; using System.Data.SqlClient; using System.Web; using System.Web.Script.Serialization; public class GridDataHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { // 前端通过 ?page1rows10sortIdorderasckeywordxxx 传参 int page; int rows; if (!int.TryParse(context.Request[page], out page) || page 1) page 1; if (!int.TryParse(context.Request[rows], out rows) || rows 1) rows 10; string sort context.Request[sort] ?? Id; string order context.Request[order] ?? asc; string keyword context.Request[keyword] ?? ; int total; DataTable dt GetPageData(page, rows, sort, order, keyword, out total); // 把 DataTable 转成 ListDictionary 再序列化 var list new ListDictionarystring, object(); foreach (DataRow row in dt.Rows) { var item new Dictionarystring, object(); foreach (DataColumn col in dt.Columns) { if (col.ColumnName ApplyTime) { // 时间字段统一格式化成字符串避免 JS 拿到时间戳后一脸懵 item[col.ColumnName] Convert.ToDateTime(row[col]).ToString(yyyy-MM-dd HH:mm:ss); } else { item[col.ColumnName] row[col].ToString(); } } list.Add(item); } var result new { total total, rows list }; string json new JavaScriptSerializer().Serialize(result); context.Response.ContentType application/json; charsetutf-8; context.Response.Write(json); } private DataTable GetPageData(int page, int pageSize, string sort, string order, string keyword, out int total) { string sql SELECT * FROM ( SELECT ROW_NUMBER() OVER (ORDER BY SortField(sort, order) ) AS row_num, * FROM T_Apply WHERE 11 BuildWhere(keyword) ) t WHERE row_num BETWEEN start AND end; using (SqlConnection conn new SqlConnection(your_connection_string)) { conn.Open(); DataTable dt new DataTable(); using (SqlCommand cmd new SqlCommand(sql, conn)) { cmd.Parameters.AddWithValue(start, (page - 1) * pageSize 1); cmd.Parameters.AddWithValue(end, page * pageSize); using (SqlDataAdapter da new SqlDataAdapter(cmd)) { da.Fill(dt); } } // 总数单独查 using (SqlCommand countCmd new SqlCommand( SELECT COUNT(*) FROM T_Apply WHERE 11 BuildWhere(keyword), conn)) { total (int)countCmd.ExecuteScalar(); } return dt; } } private string BuildWhere(string keyword) { // 演示写法正式环境必须改成参数化查询 if (string.IsNullOrEmpty(keyword)) return ; return AND ApplyName LIKE % keyword %; } private string SortField(string sort, string order) { // 白名单界面传什么只能映射到固定字段杜绝 SQL 注入 var map new Dictionarystring, string() { { Id, Id }, { ApplyName, ApplyName }, { ApplyTime, ApplyTime } }; if (!map.ContainsKey(sort)) sort Id; order order.ToLower() desc ? DESC : ASC; return map[sort] order; } public bool IsReusable { get { return false; } } }逻辑说明这段代码做了三件重要的事。第一所有入参都做了防御性解析page和rows用int.TryParse兜底防止前端传了非数字导致接口 500。第二排序字段强制走白名单映射前端传sortApplyTime能映射到ApplyTime列传任何别的字段都会被重置成默认的Id从根上避免注入。第三时间字段在序列化前统一格式化因为JavaScriptSerializer序列化DateTime会生成\/Date(635798410000000000)\/这种格式前端处理起来非常痛苦。参数说明ROW_NUMBER() OVER (ORDER BY ...)是 SQL Server 里做服务端分页的经典写法配合BETWEEN start AND end取当前页数据。start的计算公式是(page - 1) * pageSize 1end是page * pageSize。总数单独查一次COUNT(*)是为了让前端知道总页数。IsReusable一般设false避免处理程序被多线程复用时实例状态混乱。注意BuildWhere里直接拼接 LIKE 是演示代码真实项目一定要改成参数化 SQL。你在网上看到的所有“防注入”教程都强调这一点我这里也必须再说一次。3.3 前端页面保留 GridView 的视觉把渲染交给 JavaScript后端接口就绪后前端要做的事情是发请求拿 JSON自己把表格画出来。视觉上完全可以沿用原来 GridView 的样式只要把 CSS 类名保持一致,老用户几乎察觉不到底层换过。我通常的做法是把原来的asp:GridView换成普通的 HTMLtable结构保持近似然后写一个loadGrid函数统一负责数据拉取和渲染div idgridWrap table classgrid idgridTable thead tr th>asp:UpdatePanel IDup runatserver ContentTemplate asp:FileUpload IDfileUpload runatserver / asp:Button IDbtnUpload runatserver Text上传 OnClickbtnUpload_Click / /ContentTemplate Triggers asp:PostBackTrigger ControlIDbtnUpload / /Triggers /asp:UpdatePanel逻辑说明PostBackTrigger会让btnUpload的点击变成普通回发此时文件流能正常随请求发送FileUpload控件的值也不会丢失。这段配置的代价是上传时会整页刷新一下但文件传输本来就是重量级操作用户对页面闪一下的容忍度远高于“文件莫名其妙传不上去了”。4.4 排序后点分页排序状态丢了现象点击 GridView 表头按“申请时间”排序数据顺序正确。接着点击分页条的“下一页”数据回到默认的Id排序排序状态好像被重置了。原因GridView 自带的Sorting事件只负责告诉你“用户想按哪个字段排序”如果你没有把排序表达式保存下来分页事件重新绑定时 GridView 不知道还要带上前面的排序条件。手动绑定 DataTable 时这个问题尤其常见。解决在Sorting事件里把排序字段和方向存进 ViewState然后在BindGrid里把它应用到 DataView 上。完整代码如下protected void gvList_Sorting(object sender, GridViewSortEventArgs e) { if (ViewState[SortExpression] ! null ViewState[SortExpression].ToString() e.SortExpression) { // 同一个字段再点一次切换升序/降序 ViewState[SortDirection] ViewState[SortDirection].ToString() ASC ? DESC : ASC; } else { ViewState[SortExpression] e.SortExpression; ViewState[SortDirection] ASC; } gvList.PageIndex 0; // 切换排序时回到第一页 BindGrid(); } private void BindGrid() { DataTable dt GetData(); DataView dv dt.DefaultView; string sortExpr ViewState[SortExpression] as string; if (!string.IsNullOrEmpty(sortExpr)) { dv.Sort string.Format({0} {1}, sortExpr, ViewState[SortDirection]); } gvList.DataSource dv; gvList.DataBind(); }逻辑说明这个做法的关键是自行维护排序状态不再依赖 GridView 内部对SortDirection的临时判断。ViewState[SortExpression]记录当前排序字段ViewState[SortDirection]记录方向同一字段第二次点击时切换方向。注意一点DataView.Sort只能排序 DataTable 里真实存在的列名如果SortExpression是模板列里自定义的名称这里会直接报错所以排序字段必须和 DataTable 的列名一致。4.5 ViewState 越来越大页面越来越慢关了又怕状态丢现象一个几十行的列表页响应体积达到几百 KB浏览器加载时间明显变长。打开页面源码一看__VIEWSTATE隐藏字段里塞满了一整张表的数据。原因GridView 默认把绑定数据源完整存进 ViewState目的是回发后不用重新查询数据库也能还原控件状态。这个设计在数据量小时是优化在数据量大时就是灾难。而且 UpdatePanel 的异步回发也会一起携带 ViewState每点一次分页几百 KB 的数据就在浏览器和服务器之间来回传一次。解决给 GridView 设置EnableViewStatefalse让它在每次回发时重新从数据库取数。代价是多几次数据库查询换来的是页面体积骤降。内部系统的数据库通常扛得住这种查询量实际收益远比省一次查询大。如果整个页面只有这一个控件需要状态可以用ViewStateModeDisabled在页面级别关掉再单独给必要的控件开回来。需要说明的是关掉 ViewState 后分页、排序这些交互状态必须像 4.4 节那样自己保存否则回发后控件状态会全部丢失。5. 进阶技巧让 GridView 方案再多撑几年第一个技巧是给分页排序做一张参数清单。我习惯在每个列表页顶部用注释维护一段配置比如PageSize是多少、排序白名单有哪些字段、ViewState 是否关闭、是否允许导出 Excel。这样新同事接手时不用从头摸索参数改需求时也知道哪些地方会互相影响。这个习惯来自一次教训某次改一个查询页的PageSize没留意排序状态是存在 ViewState 里的结果分页和排序的顺序全部错乱排查了大半天。后来把所有状态参数集中管理类似问题基本绝迹。第二个技巧是导出 Excel 尽量别用老写法。网上流传的“GridView 导出 Excel”大多是改ContentType然后输出 HTML 表格文件能用 Excel 打开但每次都弹“文件格式与扩展名不匹配”的警告超过 65536 行还会截断。更稳的做法是用 NPOI 或 ClosedXML 在服务端生成真正的.xlsx前端拿文件流下载。老写法的优势是几行代码搞定劣势是用户每次都要多确认一次弹窗形象很减分。第三个技巧是给 GridView 留一层“查询隔离包装”。改造时不要让页面代码直接操作 SqlConnection而是把分页排序参数传给一个统一的数据访问方法由它拼 SQL、做白名单校验、格式化时间。我在第 3 章的GridDataHandler就是想表达这个思路。这样以后就算前端从 jQuery 换到别的框架后端接口和数据逻辑完全不用动只改渲染层就行。最后说句实在话Web Forms 确实不是新项目的首选但对存量系统GridView 这套方案修修补补再用几年完全没问题。最大的经验就是把状态和参数管清楚别让控件帮你记那些不该记的东西。希望帮到你。本文还有配套的精品资源点击获取