ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

WinForm通用分页控件设计:结合SQL分页查询的实战方案

2026/10/8 8:33:02 拓冰建站 浏览量
WinForm通用分页控件设计:结合SQL分页查询的实战方案 简介面向Winform开发者的分页控件实战资源演示如何结合SQL数据库实现大量数据的高效分页浏览避免一次性加载数据造成内存压力与界面卡顿。包内共65个文件压缩后约218KB以C#源码20个.cs与窗体资源8个.resx为主体同时包含exe可执行程序、dll动态库、配置文件、升级报告及gif示意图等目录划分了bin、obj、Debug、Release等标准工程结构并附有项目说明文本与程序集信息便于直接打开工程查看或集成复用。示例工程围绕PagerControl分页控件展开涉及StartForm、Form1、Form2多个窗体覆盖选择分页控件、连接SQL数据库、数据绑定、处理分页事件、通过OFFSET/FETCH NEXT获取当前页数据、利用索引与存储过程优化性能、错误处理等关键环节并实现了上一页/下一页等完整导航逻辑同时附带最新Asp.Net源码下载链接便于拓展学习。已有836人学习下载适合需要快速实现分页功能或深入理解Winform与SQL数据交互的中初级开发者。1. 别急着用 DataGridView 自带分页带 SQL 数据库的 WinForm 分页控件到底解决什么在 WinForm 项目里做数据列表最常见的手法就是把 DataTable 直接丢给 DataGridView然后 DataGridView 的假分页看起来挺像回事。但等你一张表到了几十万行用户敲一下筛选条件界面能卡到标题栏都拖不动。真正好用的分页控件不是把数据藏在控件里慢慢翻而是让 SQL 数据库只返回当前页那一小撮数据。我这个方案的做法很清楚自己做一个通用分页控件配合 SQL 端的 OFFSET FETCH 或 ROW_NUMBER 分页查询一个方法接一个事件把它塞进任何 WinForm 项目里都能跑。适合谁适合手里攒了一批 WinForm 老项目的维护者也适合新项目不想重复写翻页逻辑的开发者。读完你能得到一套完整可复制的代码思路包括控件自绘、SQL 写法、参数化查询和几个我踩过的大坑。2. 分页控件的两个基础先定数据契约再写 SQL 分页2.1 数据契约先行先定 PageChanged 事件和加载方法写分页控件之前我先说一个血泪经验千万别先画界面再定接口。分页控件最难的部分不是画几个按钮而是它和业务窗体之间的数据通道。如果通道没定好后面每接一个页面都要改控件源码这控件就成了维护负担。我一般会在控件里定义一个 PageChanged 事件事件参数带上当前页码和每页行数public class PageChangedEventArgs : EventArgs { public int CurrentPage { get; } public int PageSize { get; } public PageChangedEventArgs(int currentPage, int pageSize) { CurrentPage currentPage; PageSize pageSize; } }事件参数类只有两个只读属性构造时赋值后面没人能改。这个做法的好处是业务窗体只需要订阅 PageChanged在事件处理方法里重新查数据库、重新绑定完全不用关心控件内部怎么画页码。有了事件之后控件还需要暴露几个公开属性PageSize每页行数、RecordCount总行数由外部赋值、PageCount总页数由 RecordCount 和 PageSize 计算得到、CurrentPage当前页。这些属性的唯一作用就是告诉控件的绘制逻辑该在哪个位置显示什么数字。外部调用的最小流程应该是窗体加载时先查总记录数把它赋给控件的 RecordCount再把第一页数据查出来绑定到 DataGridView。用户点页码按钮触发 PageChanged窗体在里面重新查当前页数据并刷新列表。这样控件的职责边界非常清晰——它只负责“显示页码”和“通知外部翻页”数据查询永远是窗体的责任。2.2 SQL 分页的三种写法ROW_NUMBER、OFFSET FETCH 和临时表有了事件契约下一步就是让 SQL 数据库只吐出当前页的数据。这是整个方案的核心很多翻车现场就出在这里。我按 SQL Server 的兼容性和性能把三种写法都给出来按你自己的数据库版本选就行。第一种是 ROW_NUMBER 写法SQL Server 2005 以上都能用DECLARE PageIndex INT 3; DECLARE PageSize INT 10; SELECT * FROM ( SELECT t.*, ROW_NUMBER() OVER (ORDER BY t.CreateTime DESC) AS RowNum FROM dbo.Orders AS t ) AS p WHERE p.RowNum BETWEEN (PageIndex - 1) * PageSize 1 AND PageIndex * PageSize;逻辑说明内层查询给每行按 CreateTime 倒序编一个行号外层根据页码截取行号区间。BETWEEN 的两端要仔细算第 3 页每页 10 行取的是行号 21 到 30。参数说明PageIndex 从 1 开始不是从 0这个约定一定要在控件里统一。ORDER BY CreateTime 后面的字段选择很关键下面第 5 章会专门讲字段不唯一时出现的翻页错乱。第二种是 OFFSET FETCH 写法SQL Server 2012 以上的版本推荐用它DECLARE PageIndex INT 3; DECLARE PageSize INT 10; SELECT * FROM dbo.Orders ORDER BY CreateTime DESC OFFSET (PageIndex - 1) * PageSize ROWS FETCH NEXT PageSize ROWS ONLY;逻辑说明先按排序字段定好顺序然后跳过前面 2 页共 20 行再取接下来 10 行。OFFSET 和 FETCH 的顺序不能反很多人在写的时候把 FETCH NEXT 放前面直接报语法错。参数说明OFFSET 的运算结果必须是整数表达式所以在 C# 里传参数时直接传 (PageIndex - 1) * PageSize 和 PageSize 两个独立参数比让 SQL 自己算一整条表达式更容易排查问题。第三种是临时表写法适合排序条件极其复杂的场景DECLARE PageIndex INT 3; DECLARE PageSize INT 10; SELECT ROW_NUMBER() OVER (ORDER BY t.CreateTime DESC) AS RowNum, t.* INTO #temp_paged FROM dbo.Orders AS t; SELECT * FROM #temp_paged WHERE RowNum BETWEEN (PageIndex - 1) * PageSize 1 AND PageIndex * PageSize; DROP TABLE #temp_paged;逻辑说明先把编好号的全量数据放进临时表再按行号截取当前页。临时表方案的好处是如果后面还要基于相同结果集做统计可以直接复用。代价是每次翻页都要建临时表在高并发环境里会加重 tempdb 负担所以我只在排序字段特别复杂的时候用平时不碰它。参数说明这个写法里 RowNum 的 BETWEEN 区间和第一种写法完全一致控件端逻辑可以复用。2.3 总记录数查询分页控件的另一半一个完整的分页控件光有当前页数据是不够的。你总得知道一共有多少页不然用户翻到最后一页都不知道还有没有下一页。这个总记录数决定了 PageCount 的计算直接影响控件的页码显示。SELECT COUNT(1) AS RecordCount FROM dbo.Orders WHERE CreateTime 2024-01-01;逻辑说明这是一个独立的查询和分页查询是两条 SQL不是一条。很多人想一条 SQL 同时返回数据和总数用窗口函数嵌套去做结果数据量大之后性能反而恶化。常规做法是分开查一条简单的 COUNT 走索引扫描一条分页查按需返回各干各的活。参数说明WHERE 条件必须和分页查询里的 WHERE 条件保持一致不然总页数和实际数据对不上用户会在最后一页看到空白列表。这套“事件契约 分页 SQL 总记录数查询”的三个基础立住之后控件本身的绘制和交互就变得很纯粹了这就是第 3 章要做的事。3. 自绘分页控件把页码区域做成可复用 UserControl3.1 控件骨架继承 UserControl把属性当配置项分页控件的物理形态是一个 UserControl它内部由若干 Button、Label 和一个 TextBox 拼装而成。直接拖最原始的 Button 控件肯定是能用的但你会发现样式特别生硬后来我干脆全部自绘这样每个项目的主题色、圆角、字体都能统一走一套配置。最小骨架是这样public partial class PagerControl : UserControl { private Button btnFirst; private Button btnPrev; private Button btnNext; private Button btnLast; private TextBox txtJump; private Label lblInfo; public int PageSize { get; set; } 20; public int CurrentPage { get; private set; } 1; public int RecordCount { get; set; } public int PageCount RecordCount 0 ? 0 : (int)Math.Ceiling((double)RecordCount / PageSize); public event EventHandlerPageChangedEventArgs PageChanged; public PagerControl() { InitializeControls(); } }逻辑说明CurrentPage 只有属性没有公开 setter外部代码不能直接把页码改掉只能通过 MoveToPage 方法或点击按钮来翻页。这样的好处是翻页逻辑全部收敛在控件内部不会出现业务代码把 CurrentPage 改成一个负数的情况。PageCount 是只读计算属性RecordCount 和 PageSize 有任何一边变化页码都会自动重算。参数说明PageSize 默认值我设 20这个值和大多数表格的视觉密度比较匹配如果你们的列表字段多可以改成 15翻页次数少一点每行更宽一点。3.2 计算页码当前页前后各放几页这个算法要写对页码区域是所有分页控件里最容易写错的地方。最典型的错误是总页数 100 页控件把 1 到 100 全部画出 100 个数字按钮铺满整整一排。正确做法是只展示当前页前后各两页再加首尾页和一个省略号。private Listint GetVisiblePageNumbers() { Listint pages new Listint(); int start Math.Max(1, CurrentPage - 2); int end Math.Min(PageCount, CurrentPage 2); if (start 1) { pages.Add(1); if (start 2) pages.Add(-1); } for (int i start; i end; i) { pages.Add(i); } if (end PageCount) { if (end PageCount - 1) pages.Add(-2); pages.Add(PageCount); } return pages; }逻辑说明这段代码里 -1 和 -2 是省略号占位符绘制阶段遇到负数就画成“…”。关键点在于边界判断如果当前页是第 1 页start 被 Math.Max 拉回 1前面不会出现多余的省略号如果当前页是第 100 页end 被 Math.Min 拉回 100后面也不会画多余的东西。参数说明左右各 2 页这个数字是项目里调出来的平衡值如果你用 3 个小屏上会显得拥挤用 1 个又会让用户失去空间感。有了页码列表之后真正的交互逻辑就落在 MoveToPage 方法里public void MoveToPage(int page) { if (page 1) page 1; if (page PageCount) page PageCount; if (page CurrentPage) return; CurrentPage page; PageChanged?.Invoke(this, new PageChangedEventArgs(CurrentPage, PageSize)); Invalidate(); }逻辑说明MoveToPage 做两层边界保护然后判断页码是否真的变化变化了才触发事件和重绘。这个保护极其重要用户快速连点“下一页”按钮时事件会被压缩成有效的那几条不会产生重复查询。Invalidate 调用让整个控件重绘页码高亮和按钮状态都会刷新。3.3 绘制按钮与界面美化FlatStyle、颜色与字体控件界面我做成了自绘风格全部按钮用 FlatStyle.Flat 去掉默认的 Windows 立体边框再根据状态切换颜色。这么做的好处是整体观感能贴合现代 WinForm 的扁平化改造也就是常说的 WinForm 界面美化不用再引入第三方 UI 组件库。protected override void OnPaint(PaintEventArgs e) { base.OnPaint(e); Graphics g e.Graphics; g.SmoothingMode System.Drawing.Drawing2D.SmoothingMode.AntiAlias; foreach (var page in GetVisiblePageNumbers()) { Rectangle rect GetPageButtonRect(page); bool isCurrent page CurrentPage; Color bg isCurrent ? Color.FromArgb(79, 122, 199) : Color.FromArgb(245, 245, 245); Color fg isCurrent ? Color.White : Color.FromArgb(64, 64, 64); using (SolidBrush brush new SolidBrush(bg)) { g.FillRectangle(brush, rect); } string text page 0 ? ... : page.ToString(); TextRenderer.DrawText(g, text, this.Font, rect, fg, TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter); } }逻辑说明OnPaint 里先把之前画的内容清掉然后遍历可显示页码逐个绘制背景色和文字。用 TextRenderer.DrawText 而不是 g.DrawString是为了让文字在高 DPI 缩放的屏幕上依然清晰。参数说明当前页背景色用的 RGB(79,122,199) 是一种偏商务的蓝色如果你在暗色主题的项目里可以把这段颜色收敛成控件属性让使用方自定义而不是写死在内部。提示自绘按钮不需要复杂的圆角路径或阴影那会给 WinForm 的刷新带来额外开销。保持简洁的矩形加颜色变化翻页响应快视觉上也不会比第三方控件差。4. 把控件接到 SQL 数据库从连接串到数据填充4.1 最小接入SqlConnection 加参数化查询填充 DataGridView控件本身不碰数据库这是设计原则。但为了让新手能一次跑通我给出一个完整的业务窗体接入代码。这个窗体里放了一个 DataGridView 和我们的 PagerControl窗体加载时先查总数再查第一页。private void LoadPage(int pageIndex, int pageSize) { string connStr Serverlocalhost;DatabaseSalesDB;Integrated SecurityTrue;; string countSql SELECT COUNT(1) FROM dbo.Orders;; string pageSql SELECT * FROM dbo.Orders ORDER BY CreateTime DESC OFFSET Offset ROWS FETCH NEXT PageSize ROWS ONLY;; using (SqlConnection conn new SqlConnection(connStr)) { conn.Open(); using (SqlCommand cmdCount new SqlCommand(countSql, conn)) { pager.RecordCount (int)cmdCount.ExecuteScalar(); } using (SqlCommand cmdPage new SqlCommand(pageSql, conn)) { cmdPage.Parameters.AddWithValue(Offset, (pageIndex - 1) * pageSize); cmdPage.Parameters.AddWithValue(PageSize, pageSize); using (SqlDataAdapter adapter new SqlDataAdapter(cmdPage)) { DataTable dt new DataTable(); adapter.Fill(dt); dataGridView1.DataSource dt; } } } }逻辑说明这段代码先查 COUNT再查当前页两个查询共用一个连接减少了建立连接的开销。分页查询用 SqlParameter 而不是拼字符串这个事不能偷懒。OFFSET 参数传的是 (pageIndex - 1) * pageSize 的整数结果不是表达式本身这样 SQL 执行计划更容易命中缓存的参数化计划。参数说明如果项目里 ORM 用的是 EF Core这个逻辑本质一样区别在于把 SQL 换成 LINQ 的 Skip/Take但总记录数的 COUNT 还是要单独发一次查询ORM 不会帮你自动做。4.2 多表联查JOIN 之后的分页 SQL 要怎么写实际项目里极少只查一张表。订单关联客户、商品关联分类这都是常见场景。多表 JOIN 之后的分页重点在于不要把分页子查询做成全表扫描。DECLARE PageIndex INT 1; DECLARE PageSize INT 20; SELECT t.OrderId, t.OrderNo, c.CustomerName, c.Phone FROM ( SELECT o.Id AS OrderId, o.OrderNo, o.CustomerId, o.CreateTime, ROW_NUMBER() OVER (ORDER BY o.CreateTime DESC) AS RowNum FROM dbo.Orders AS o WHERE o.Status 1 ) AS t INNER JOIN dbo.Customers AS c ON t.CustomerId c.Id WHERE t.RowNum BETWEEN (PageIndex - 1) * PageSize 1 AND PageIndex * PageSize ORDER BY t.CreateTime DESC;逻辑说明核心技巧是先在子查询里只查主表并编号然后再 JOIN 其他表。如果反过来先 JOIN 再分页SQL 引擎要对两张表的笛卡尔积排序编号数据量一大就非常慢。这样的写法保证 ROW_NUMBER 只作用在主表的最小投影上JOIN 只发生在当前页那一小撮数据上。参数说明这里的 PageIndex 和 PageSize 依然走参数化Status 条件在子查询内层过滤不会把过滤后的总行数算错。这种写法有个细节要留意如果子查询里 SELECT 了 o.CustomerId但最终结果不需要这个字段也要保留它因为 JOIN 的关联条件要用。等外层 JOIN 完成后再在外层投影里把不需要的字段去掉。4.3 用 Dapper 接入依赖注入和更薄的调用层老项目用 SqlDataAdapter 很顺手但新项目很多已经用 Dapper 了。Dapper 的好处是映射到实体类、配合依赖注入代码比 DataTable 时代干净一个量级。public class OrderRepository { private readonly IDbConnection _conn; public OrderRepository(IDbConnection conn) { _conn conn; } public PagedResultOrderInfo GetOrderPage(int pageIndex, int pageSize) { int offset (pageIndex - 1) * pageSize; var sql SELECT o.Id, o.OrderNo, o.CreateTime, c.CustomerName FROM ( SELECT o.Id, o.OrderNo, o.CreateTime, o.CustomerId, ROW_NUMBER() OVER (ORDER BY o.CreateTime DESC) AS RowNum FROM dbo.Orders AS o ) AS t INNER JOIN dbo.Customers AS c ON t.CustomerId c.Id WHERE t.RowNum BETWEEN Start AND End;; var list _conn.QueryOrderInfo(sql, new { Start offset 1, End pageIndex * pageSize }).ToList(); int total _conn.ExecuteScalarint(SELECT COUNT(1) FROM dbo.Orders;); return new PagedResultOrderInfo(list, total); } }逻辑说明Dapper 的匿名对象参数替代了 SqlParameter 的手工拼装代码短了很多。COUNT 查询依然单独执行没有和分页查询混在一起。PagedResult 是一个简单的泛型包装类里面包含 List 和 TotalCount这个包装可以直接被 PagerControl 消费。参数说明Start 和 End 的计算方式要和 SQL 里的 ROW_NUMBER 区间保持一致Start 是 offset1End 是 pageIndex*pageSize少了任何一边都会导致首尾行丢失。依赖注入的场景下IDbConnection 从构造函数注入仓储类可以被窗体共享。这样分页控件的事件处理器里只需要调用 repo.GetOrderPage 再绑定到 DataGridViewUI 层不再出现 SQL 字符串。Dapper 的这些优势挺明显值得在成熟项目里逐步切过去。5. 分页控件避坑清单这五个坑我替你先踩了5.1 翻页后 DataGridView 滚到了中层数据用户找不到刚才看的那一行现象用户在 DataGridView 里看第 5 行点下一页数据刷新了但滚动条停在新数据的中间位置视觉上特别跳脱。原因DataGridView 绑定新数据源后第一行不一定是显示在视图内的那一行。它的垂直滚动位置保持旧状态有时会直接落在新数据的中间甚至底部。解决翻页绑定完成之后手动把视图拉回顶部。dataGridView1.DataSource dt; if (dataGridView1.Rows.Count 0) { dataGridView1.FirstDisplayedScrollingRowIndex 0; dataGridView1.CurrentCell dataGridView1.Rows[0].Cells[0]; }这行代码的作用是把可视区域的第一个数据行固定为第 0 行。注意要在 DataSource 赋值之后调用不能提前。绑定之前控件里还是旧数据调用 FirstDisplayedScrollingRowIndex 会报错。5.2 SQL Server 版本不满足 OFFSET FETCH线上半夜报错现象开发环境跑得飞快部署到客户的服务器上一点下一页就提示语法错误错误信息接近 “Incorrect syntax near OFFSET”。原因OFFSET FETCH 是 SQL Server 2012 才引入的语法。大量老客户的服务器还用 SQL Server 2008 R2 甚至更早版本。开发机是 2022测试环境是 2016都查不出这个问题只有生产环境的旧版本暴露出来。解决要么在连接串层面限制不现实要么直接退回到 ROW_NUMBER 写法。ROW_NUMBER 从 2005 年就在用了兼容性最稳。如果你必须用 OFFSET可以在发布前先执行 SELECT VERSION 检查目标服务器版本然后再决定用哪套分页 SQL。我的做法是默认写 ROW_NUMBER 版本只有确定目标全是 2012 以上才换 OFFSET。5.3 PageSize 被当成字符串拼接输入“20;DELETE”直接翻车现象用户在页码跳转框输入了包含分号的字符串程序直接报 SQL 异常严重的直接把数据删了。原因跳转文本框的校验没做好回车事件里直接把 TextBox.Text 拼进 SQL。这种拼串方式在数据量小的时候也能跑但一旦遇到恶意输入就是安全事故。解决先做输入校验再走参数化。校验逻辑放在 TextBox 的 KeyPress 事件里就够只允许数字和回车键。private void txtJump_KeyPress(object sender, KeyPressEventArgs e) { if (e.KeyChar (char)Keys.Enter) { e.Handled true; if (int.TryParse(txtJump.Text.Trim(), out int targetPage)) { MoveToPage(targetPage); } txtJump.Clear(); } else if (!char.IsDigit(e.KeyChar) e.KeyChar ! (char)Keys.Back) { e.Handled true; } }逻辑说明Enter 键触发翻页数字以外的字符直接被 Handled 屏蔽Backspace 用来删字。int.TryParse 兜底即使输错了也不会进 SQL。参数说明这个校验只挡非法字符真正的安全底线永远在 SQL 参数化那一层前端校验是用户体验问题参数化是安全问题两层都不能省。5.4 事件重复订阅翻几页之后页面越来越卡现象第一次翻页正常第三四次翻页之后明显变慢每次翻页要等两三秒操作越多越卡。原因窗体初始化时订阅了 PageChanged 事件但窗体在某个业务分支里被重复实例化或重新绑定时再次订阅。每个订阅器都会触发一次数据库查询事件链越滚越长。解决订阅之前先取消或者使用单例窗体避免重复实例化。简洁的做法是这样pager.PageChanged - Pager_PageChanged; pager.PageChanged Pager_PageChanged;这个模式叫“先退订再订阅”保证事件链里永远只有一个处理器实例。尤其是在选项卡或嵌套容器里窗体被频繁重建的坑最容易踩这个雷。5.5 总记录数每次全表 COUNT大表单页要等两秒现象单表 500 万行总记录数查询就要 1.5 秒用户点一页等两秒体验非常糟糕。原因COUNT(1) 这条 SQL 没有针对性的索引优化。在没有合适的覆盖索引时SQL Server 要扫描整张表去统计行数。很多开发者在数据量小的时候没感知等到线上数据膨胀才暴露。解决给计数查询的 WHERE 条件字段建合适的非聚集索引让 COUNT 走索引统计而不是全表扫描。如果你的过滤条件不固定那就缩小统计口径用软删除标记和固定时间窗去做 COUNT而不是每页都统计全表。分页控件本身不背这个锅但它的 PageChanged 事件频率会把这个性能问题放大十倍所以这里一定要重视。6. 进阶大数据量下的翻页体验与界面联动技巧6.1 滚动加载让鼠标滚轮也参与翻页传统分页控件靠点击按钮翻页用户在大表格里滚动鼠标看完当前页还得移鼠标去找“下一页”按钮。一个值得加的小功能是当鼠标滚轮滚动到 DataGridView 底部时自动加载下一页。这其实就是近年网页端流行的无限滚动在 WinForm 的投影。private void dataGridView1_MouseWheel(object sender, MouseEventArgs e) { if (e.Delta 0) { int lastRow dataGridView1.FirstDisplayedScrollingRowIndex dataGridView1.DisplayedRowCount(false); if (lastRow dataGridView1.RowCount - 1) { pager.MoveToPage(pager.CurrentPage 1); } } }逻辑说明滚轮向下滚一轮就检查当前可视区域的最后一行索引如果已经到达数据末尾就自动翻到下一页。这个逻辑并不会误触发因为用户还没滚到底时不会满足条件。参数说明这个功能适合数据流式浏览的场景但它会改变用户对“翻页”的预期如果团队里有人不喜欢就做成控件属性 AutoLoadNextPage 默认关闭。6.2 虚拟模式与状态栏进度条联动前面所有方案都是把当前页数据复制进 DataGridView如果单页数据本身就有 1 万行内存和绘制都有压力。这种情况可以开 DataGridView 的 VirtualMode同时配合状态栏显示“正在加载第 X 页”的进度提示也就是热搜里常说的 C# WinForm 状态栏与进度条联动。虚拟模式的核心是只给 DataGridView 提供当前可见行区域的数据滚动时才填充。配合分页控件时一个实用的联动方案是翻页开始时把 ToolStripStatusLabel 文本改成“正在加载…”然后用 BackgroundWorker 在后台查询当前页查询完成后再把状态栏恢复成“共 N 条 / 第 X 页”。这样即使分页查询耗时 500 毫秒用户也始终有反馈不会觉得程序卡死了。代码写到位并不难但虚拟模式本身是个黑匣子CellValueNeeded 事件里经常出各种奇怪问题调试成本不小。我给的建议是数据量在 1 万行以下用普通绑定模式加滚动加载就够了单页超过 1 万行再上虚拟模式别为了炫技提前复杂化。我自己的习惯是把这套分页控件当成 WinForm 项目里的标配基础设施新开项目先实例化一个然后把事件接上后面所有列表页都复用同一套逻辑。数据库版本确认用 ROW_NUMBER 稳一点用户输入校验严格一点事件记得先退订再订阅这三点做到位翻车概率会小很多。希望帮到你。本文还有配套的精品资源点击获取