列表页面太常见了常见到我都懒得把它当做一个“项目”来看。但说实话这些年我经手的电商后台、社交 App、数据可视化平台里真正说得上“稳定、不卡、状态不混乱”的列表页面比例并不高。很多人对“列表页面的实现”的理解就是调接口、循环、渲染完事。结果一到线上就开始翻车数据量大了白屏搜索快了列表乱跳翻页翻多了接口超时勾选的记录还没提交就被覆盖。这篇文章我就把自己做列表页面的完整思路掏出来讲一遍——从接口设计到前端渲染从状态管理到线上问题排查全部按实操串起来。不管你是刚接触前端的新人还是要带项目的中级开发者只要你每天跟列表、表格、Feed 流打交道静下心读二十分钟应该能少踩不少坑。1. 先想清楚你的列表页面到底属于哪一类每次拿到列表页需求我做的第一件事不是打开编辑器而是先把数据量级和交互复杂度问清楚。这两个问题没定下来后面的技术方案全是空中楼阁。1.1 数据量级决定技术路线我在需求沟通阶段必问的一句话是这个列表最多会有多少条数据别小看这个问题它是整个技术路线的分水岭。如果是一个后台的字典列表、栏目配置列表数据量可能就几十条撑死了几百条那最简单粗暴的方案就是接口一次性返回全部数据前端拿到数组后用 filter、sort 自己搞定筛选和排序。这种场景下做分页反而是过度设计。用户根本不想点“下一页”他希望一屏看完所有内容直接在页面上搜索过滤。这时候你去搞什么虚拟列表、滚动加载纯属给自己加戏。但如果是订单列表、登录日志、短信发送记录这种动辄几十万上百万条数据的场景一次性返回全部数据接口响应慢不说前端就算只塞 5000 个 DOM 节点浏览器也会肉眼可见地卡顿。这类列表必须走服务端分页前端一次只加载当前页的数据必要时还要叠加滚动加载、虚拟渲染来压住 DOM 数量。还有一种容易被忽略的中间状态数据总量不多但每个列表项的渲染成本很高。比如列表里嵌了图表、富文本、多级嵌套结构一条数据的 DOM 就要几百上千个节点。这种情况就算只有 300 条数据浏览器也能给你渲染到崩溃。所以我的经验是评估数据量级时不能只看条数还要看“单条数据的渲染成本”。我把这两个因素合并评估之后通常会给团队一个简单的经验值单条是纯文本列表最多撑到 1000 条左右单条含复杂组件渲染上限就往 100 条压。超过这个量就要考虑分页、滚动加载或者虚拟列表。1.2 交互复杂度决定状态放哪第二个必须问清楚的问题是用户在这个列表上除了看和点跳转还能干什么列表页的交互复杂度跨度非常大。低端一点的纯展示顶多加个翻页按钮点进详情页就完事中等程度的要支持搜索、筛选、排序用户切来切去不能丢状态复杂的则要支持跨页多选、批量操作、拖拽排序、行内编辑甚至每一行展开、折叠的状态都得记住。交互复杂度直接决定状态管理怎么做。一个纯展示的列表组件内部用一个 useState 或者 ref 就能解决完全不必要引入 Redux、Pinia 这类全局状态库。我见过有的团队一个列表翻页功能都要往全局 store 里塞数据代码越写越绕最后维护的人自己都不知道状态是哪个 reducer 更新的。反过来如果列表要跨页多选、批量删除那“选中的记录集合”就是个全局状态因为它得跨越多个路由页面层层传递。再比如筛选条件要通过 URL 参数流转刷新之后页面还能还原用户的操作痕迹那状态就得分一部分给路由器而不是闷头放在组件里。我的判断标准很朴实同一份数据在一个页面生命周期内只用一次放局部多个页面共享或者被多个不相关的组件消费放全局。全局 store 里应该放的是跨模块的业务数据而不是每个页面自己查询出来的结果。把这一条想透了状态管理方案基本不会出大错。2. 接口层设计一个好的列表接口应该长什么样后端接口设计这个东西前端通常没法完全掌控但一定要有参与意识。接口字段定义得好前端写起来顺水推舟定义得烂前端每天都在跟后端还嘴皮子修 bug。下面这几项是我在设计列表接口时最看重的点。2.1 分页方案offset 分页和 cursor 分页怎么选列表接口第一个要定的就是分页方案。目前主流就两种offset 分页和 cursor 分页。offset 分页就是 page 和 pageSize 参数对应的 SQL 大致是LIMIT 20 OFFSET 20。它的优点是直观前端好实现也支持“直接跳转到第 50 页”这种随机跳页操作。后台管理系统我基本都推荐这个因为用户是有跳页诉求的。但 offset 分页有个绕不开的坑OFFSET 值越大数据库扫描的行越多翻到后面接口响应越来越慢。这个就是经典的“深分页”问题。数据量在几千条时根本感觉不出来真到了几百万条还让大家来回跳页后端迟早要来找你聊优化。cursor 分页则是拿上一页最后一条记录的某个唯一字段一般是主键 id 或时间戳当游标下一页查询用WHERE id :cursor LIMIT 20。这种方案的好处是查询走索引不管翻到多深性能都稳如老狗。代价是前端没法随机跳页只能一页一页往下翻。所以 Feed 流、消息历史、订单流水这种“只能往下翻”的场景我基本无脑用 cursor 分页。实测一条 500 万行的日志表offset 翻到第 10 万条已经是秒级响应cursor 分页仍然稳定在几十毫秒这个差距到了线上就是用户体验的生死线。2.2 过滤、排序、搜索参数怎么设计列表接口除了分页肯定要支持过滤和排序。这块的参数设计如果一开始没定好后面改接口的成本极高。我的惯用做法是过滤条件全部通过 query 参数平铺比如statuspendingcategory1startTime2025-01-01endTime2025-01-31。为什么不建议把筛选条件塞进一个filtersJSON.stringify({...})的字符串里因为平铺参数可读性好、可缓存、方便调试。浏览器地址栏直接改一个参数就能复现后端 bug代理日志里一眼就能看清前端传了哪些条件。塞进 JSON 串里的条件每次排查问题都要先解包实在磨人。排序参数我建议固定成sortBycreateTimesortOrderdesc两个字段分开放。别贪图省事做sortcreateTime_desc这种拼接前后端约个分隔符被误解就又是一场灾难。搜索关键词单独给一个search字段后端负责做全文检索或模糊匹配。这里有个语义上的细节得说清楚过滤是针对结构化字段的精确匹配搜索是针对内容的模糊匹配。两者语义不同后端处理逻辑就不同。前端传参时可以混在一起但后端一定要明确优先级、是否叠加生效否则很容易出现“看着接口参数都对但结果就是不对”的低级事故。2.3 返回响应体的约定响应体结构的规范程度直接影响前端代码的稳定性和扩展空间。我折腾过好多项目后现在惯用的列表返回体长这样interface ListResponseT { code: number; message: string; data: { list: T[]; total: number; page: number; pageSize: number; hasMore: boolean; }; }这里面有个细节是很多新手团队容易忽略的。total要返回因为分页组件要展示“共 1230 条”也要计算总页数。hasMore也要返回尤其在滚动加载场景里前端直接用hasMore判断还有没有下一页就不用每次都用page * pageSize total去猜减少很多边界计算错误。有些接口会把total放在最外层这也行但从扩展性来说我更喜欢整个列表的元信息都塞在data里。以后列表要附加“服务端提示语”“本次命中的筛选条件”等字段直接往data对象里加破坏性小前端也不用改接口调用代码。还有一个很容易吵起来的点接口报错时要不要返回code而不是纯 HTTP 状态码我的建议是两者都要保留。HTTP 状态码表达的是传输层面的状态业务code表达的是业务层面的结果比如“参数不合法”“内容已删除”。前端封装函数时代码里基本只关心codeHTTP 状态码只是用来快速筛查四层问题的。如果团队已经习惯了只靠 HTTP 状态码也不是不能做但一定要统一。2.4 数据请求的封装思路接口返回体定了前端请求层也要趁早封装。我一般会把列表请求统一封装成一个通用的useFetchList之类的 Hook把 loading、error、data、reload 这些状态都收纳进去。这里有几个参数是必须暴露给使用方的分页参数、筛选参数、依赖刷新项。依赖刷新项一改变就自动重新请求。封装好之后页面组件里只关心业务展示和交互不用每个列表页面都重复写一套网络请求逻辑。封装时我习惯把“是否手动触发”这个开关做进去。列表页面第一版通常进页面就自动请求但后期一定会冒出一些“点击按钮后才加载”的需求留个manual参数会方便很多。另一个必须做的参数是debounce配置。搜索框场景下输入变化触发搜索如果没有防抖键盘每次敲击都会发一个请求那后端压力巨大不说前端渲染也会频繁被打断。封装成debounce: 300这样配置一条比每个列表页面里自己写 setTimeout 干净得多。3. 前端渲染从普通渲染到虚拟列表接口层搞定了数据也拿到了接下来就是重头戏——怎么渲染。这部分我按数据量级从小到大讲每种方案适合什么场景、有什么坑一次性说清楚。3.1 一次性渲染和分页渲染如果接口方案已经是服务端分页那前端渲染其实是比较简单的拿当前页的数据循环输出列表项。这个过程中我认为最值得警惕的就是 key 的使用。key 是组件渲染时用来识别“哪个列表项对应哪个组件实例”的标识。很多人图省事直接用数组 index 当 key一到删除、排序、批量操作组件状态就开始错乱。举个我实际遇到的例子一个可勾选的列表用户勾选了第二项的复选框然后把第一项删掉了原来第二项的数据现在跑到了第一项的位置。如果 key 是 index那原来第二项的 DOM 状态还是保留的但显示的内容却是现在的第一项单选状态和数据就对不上号了。这种 bug 排查起来非常费劲因为逻辑代码完全没错错的是 key。所以从第一天开始就要养成用数据唯一 id 当 key 的习惯。后端没给 id让后端加或者自己在最前面拼一个稳定字段这比你省那几行代码划算得多。分页渲染还有一个很多项目没做好的细节翻页时的滚动位置。用户点击了第 2 页数据刷新了但页面还停在原来滚动到一半的位置体验等于被腰斩。正确做法是翻页时自动滚到列表顶部。这个判断很简单——用户从第 1 页翻到第 2 页本质上他在“看新的内容”应该回到开头但如果他是因为勾选了几行还想继续看你硬让他回顶部那又过于粗暴。折中方案是仅当页码发生变化时滚回顶部筛选条件变化时同样滚回顶部但页码不变时保持原位置。做产品时把这一条的交互体验理清楚用户的体感会好很多。3.2 滚动加载无限滚动的实现要点移动端和某些 PC 端的 Feed 流喜欢用滚动加载也就是触底自动拉取下一页数据。这种交互看起来是省事实际自己实现一遍就会遇到不少细节问题。我把无限滚动的实现拆成三步监听滚动事件、判断是否到底、触发加载。听起来简单坑都在细节里。第一步最容易翻车的是“监听的滚动容器搞错了”。有的页面滚动发生在 window有的页面滚动发生在内部某个设了 overflow 的 div。如果你在 window 上监听了一个内部容器滚动的页面那事件永远触发不了。我通常会写一个工具函数把这个容器判断封装起来自动向上找真正的可滚动父级。第二步的核心是阈值计算。判断公式是scrollTop clientHeight scrollHeight - threshold。这里的 threshold 我建议给 100px 到 300px 的余量不要等到完全到底才加载。留余量的目的是提前把数据塞进列表里用户在滚到边界之前就看到了新内容体感更流畅。第三步要特别注意加加载锁。因为在快速滚动时同一个滚动事件里可能连续触发几十次如果每次都发起请求后端会被打满前端也会收到一堆重复数据。我常见的写法是loading 状态为 true 时直接返回等这次加载完成后才重新监听。还有一个容易忽略的点首次加载数据返回很慢时页面会先渲染“暂无数据”的空状态然后数据回来又被撑开用户就看到一个短时间的闪烁。我在后面的问题排查章节还会专门讲这个但这里提一嘴就是滚动加载列表的初始 loading 态和空状态要分开判定不要在数据没回来的时候直接给空态占位。3.3 虚拟列表数据量再大的终极解法当列表数据量大到“一次性签回几千上万个 DOM 节点”的时候即使你用滚动加载浏览器也会开始卡顿。因为滚动加载只会一直往容器里追加节点滚到底也积累了上千个节点。浏览器渲染成百上千的列表项、计算样式、重绘布局每一帧都在烧性能。这时候唯一靠谱的方案就是虚拟列表。虚拟列表的逻辑一句话说透我只渲染你滚动到可视区域内能看到的那些列表项其他数据只是躺在内存里不生成 DOM。固定行高的虚拟列表相对简单。比如容器高 600px每个列表项高 50px那可视区域内最多 12 条。加上上下缓冲区各多渲染几行总共 20 多条。滚动发生时根据 scrollTop 算出当前应该渲染的起始 index再把列表项整体 transform 平移过去。这样无论底层数据是 1000 条还是 100 万条DOM 节点永远只有二三十个性能自然稳定。不固定行高的情况就麻烦一些。你需要在渲染过程中逐个测量真实高度并维护一个位置表滚动时根据这个表估算当前应该显示哪一段。动态高度的虚拟列表实现成本要高不少所以一般团队不建议纯手写直接用现成库。React 生态里我常用 react-window轻量API 简洁场景复杂的用 react-virtualized功能更全。Vue 生态则推荐 tanstack/react-virtual设计得很干净。虚拟列表也有一些“天然缺点”要提前想清楚。因为它只渲染部分 DOM浏览器自带的 CtrlF 全文查找基本对你列表里的内容失效列表项里的锚点位置也无法直接跳转。如果产品的列表内容对 SEO 有要求或者业务上确实需要浏览器原生查找那虚拟列表就要谨慎选用了。做技术选型时想清楚这些边界比后期返工踏实得多。4. 状态管理列表页最容易翻车的地方列表页的状态管理是区分“会写列表”和“把列表写好”的关键。我没法把每个框架的写法都列一遍但会把几个通用的实践原则讲透。4.1 局部状态与全局状态的边界列表页的状态第一件要搞清楚的是放哪儿。一个一次性的、纯展示型的列表数据完全可以放组件局部。比如一个“本周活动列表”进入页面请求一次展示完就销毁下一回进入再重新请求完全合理。把这种数据硬塞进全局 store纯粹是增加内存占用和团队的理解成本。但如果同一个列表数据要出现在多个页面比如“我最近浏览的订单”既出现在订单页侧边栏又出现在个人中心这就是跨模块共享的数据必须放全局。不然每个页面都各自请求一份接口压力大而且用户在一个页面浏览过的数据换到另一个页面却要重新加载体验很撕裂。我见过很多团队喜欢用一句话判断——“万一以后要用呢”然后把普通页面数据塞进全局 store。我强烈反对这种预判式的设计。全局 store 是用来放高复用、高共享的业务数据的不是备胎存储。判断标准就一条如果这份数据只在一个页面里被消费其他任何组件都不需要那它就待在那儿别动。4.2 筛选条件、选中状态、URL 同步列表页状态管理的第二个核心点是筛选条件和选中状态的处理。筛选条件我强烈建议同步到 URL 参数。因为这样做天然支持刷新保留、收藏夹分享、以及用户把链接丢给别人之后打开保持原样。比如搜索关键词、当前页码、选中的状态 tab全部映射成?page2statuspendingsearchxx。这对测试也非常友好产品反馈问题时只需要复制一个 URL就能完整复现当时的页面状态。选中状态要分两种场景。第一种是只作用于当前页翻页或切换条件后清空那直接用组件状态就行。第二种是跨页多选用户在第一页勾了 3 条翻到第二页又勾了 5 条最后要把这 8 条批量提交。跨页多选的实现关键是把“选中的记录集”独立存放一般是维护一个 Map 或对象key 是数据的唯一 idvalue 是完整的数据行。勾选时 set取消时 delete最后提交时把 Map 转成数组。千万不能只在当前页的数据数组里打一个勾选标记因为切页后当前页数据可能直接被覆盖你连判断“之前勾了哪些”的凭据都没了。4.3 缓存与失效策略列表接口的缓存我想单独拎出来说。因为很多团队要么完全不缓存慢得骂人要么盲目缓存数据明明已经改了前端还是显示旧值又苦不堪言。我的方案是“内存缓存 版本号失效”。在页面组件层面做一个短期缓存比如用户离开列表页再回来如果缓存的存活时间还在一两分钟之内就不重新请求。与此同时用户的任何修改操作——新增、编辑、删除、批量状态变更——可以让后端返回一个全局递增的版本号。前端把当前列表的版本号和最新的版本号对比发现变化了就主动清缓存重新请求。这个思路实现成本低又能在“数据一致性”和“加载性能”之间取得平衡。有些团队会考虑用 React Query、SWR 这类数据请求库它们自带缓存和失效机制本身是很好的工具。但我还是那句话不要为了引库而引库。如果你的项目里只有列表页需要缓存手写一个轻量的缓存 Hook 完全可以搞定引入一整套数据流框架反而加重心智负担。4.4 列表加载时的 UI 状态机最后一个状态管理问题是列表页应该有一个清晰的状态机loading 态、success 态、error 态、empty 态。这四个状态缺一不可也不能混在一起。我见过最典型的问题是把 empty 态单纯写成list.length 0。这样列表在数据还没返回时因为初始 list 是空数组就会先闪一下“暂无数据”等接口响应后数据才填充进去用户肉眼可见地看到状态闪烁。正确做法是只有在请求完成且确认列表为空的时候才渲染空状态。判定逻辑应该是!loading !error list.length 0而不是一上来就判断长度。error 态也经常被忽略。接口报错时很多页面会直接空白用户完全不知道发生了什么。更好的做法是为列表页设计一个错误占位视图展示错误信息和一个“重新加载”按钮。这个设计在产品文案上不用太复杂关键是要给用户一个操作出口而不是让他盯着空白页怀疑人生。5. 常见问题与排查技巧实录这部分我把自己和团队在实际做列表页时踩过的几个高频问题整理出来每一个都是线上真实遇到过的附上排查思路和最终解决方案。5.1 搜索防抖与竞态条件搜索框是列表页里出问题最多的交互组件。不加防抖每敲一个字母就请求一次后端和带宽都受不了加了防抖用户快速输入时又会出现“上一个请求的结果后到覆盖了最新结果”的竞态问题。防抖的选值是第一个决策点。我通常给 300ms 到 500ms。太短起不到聚合效果太长用户会觉得“怎么打半天不出结果”。300ms 是个比较平衡的默认值查询结果渲染较重的场景可以再加到 500ms。竞态条件解决起来有几种常见套路。最简单可靠的是请求序号对比法每次发新请求时对自增 id 计数保存最新 id在接口回调里对比返回时的 id 是否等于最新 id不等就丢弃。代码大概长这样const requestIdRef useRef(0); async function fetchList(params) { const currentId requestIdRef.current; const data await api.fetchList(params); if (currentId ! requestIdRef.current) return; // 旧请求丢弃 setList(data.data.list); }你可能会问为什么不用 AbortController 把旧请求取消掉取消请求听起来更干净但实际体验中Axios 取消请求会抛 Cancelled 异常如果你的全局拦截器里统一提示了“请求异常”用户就会被莫名弹出一个错误提示。所以我在团队内部统一用 id 对比法侵入小、不依赖库特性、也好解释。5.2 空数据与初始加载闪烁列表页的“先闪‘暂无数据’然后数据才返回”这个问题我在 4.4 提过原因这里说排查手段。遇到这个现象第一步是看初始化的 list 状态。很多代码在初始化时直接list: []然后渲染时只判断list.length 0。就算你加了个loading状态变量如果空状态的渲染条件里面没把 loading 排除掉照样闪。我排查这个问题时喜欢直接在 render 函数里打印 loading 和 list 值看时间线上的顺序。你经常能看到第一帧 loadingtrue、list[]第二帧接口已出结果、loadingfalse、list[...数据]。问题就出在第一帧直接渲染了空态。解决方案是刚才在状态机章节里说的把loading这个条件前置。初始化 loading 为 true 时渲染骨架屏或者转圈loading 为 false 且数据为空才渲染空状态视图。如果你连骨架屏都懒得设计至少用一个全屏透明遮罩把空状态挡住等接口结果回来再移除。这在用户体感上是天壤之别。5.3 频繁更新的列表带来的渲染性能有些列表比如在线状态监控、游戏对战列表数据本身不多但更新的频率很高隔几秒甚至每秒就全量刷新一次。很多人的第一反应是“页面卡了”但真正的问题不在接口慢在于列表数据每次变化时组件把整棵树都重新渲染了一遍。排查这种性能问题职业病不能只看表面。我通常先用性能分析工具记录一次更新看看是哪个组件耗时最高。没有工具的也可以在列表项组件里打印 render 日志观察它到底因为父组件 setState 被重渲染了多少次。定位到瓶颈以后优化思路就清晰了。React 里用 React.memo 拦截列表项子组件只要 props 没变就不重新渲染。Vue 里也可以使用 key 计算属性控制动态数据粒度。但如果后端每次都返回一个全新的数组列表项组件的引用还是会因为数据变化而全部更新。这时候要想办法做数据 diff只在真正变化的行上更新组件。极端场景下把实时变化的数据归到一个通道静态数据归到另一个通道动态数据来了只修改对应列表项的局部状态而不是整页推翻重来。但这里我一定要加一句项目里没有性能问题就不要提前套这套优化方案。我见过一个团队为了“统一优化体验”把一个数据总量只有 50 条、一天都不怎么变化的列表硬生生改造成虚拟列表加局部更新。代码复杂度翻了几倍线上效果却毫无区别。性能优化的前提是量化和定位不是凭感觉。6. 一些手写列表页的小习惯前面把列表页从接口到状态到优化都过了一遍最后再分享几个我平时写代码时养成的“肌肉记忆”。第一所有列表请求都带请求序号。这个习惯我已经刻进条件反射里了不管列表多简单我都会在请求层留好竞态保护的钩子。因为产品永远会加搜索、加筛选、加切换 tab这些交互叠加起来没有竞态保护的列表迟早出问题。第二把接口返回结构先定义成 TypeScript 类型再写组件。我看到很多代码是组件写完了接口字段还没定义渲染时全靠点运算符硬写。等后端字段变了报错一大片排查极其痛苦。先定接口类型再写组件尤其是在多人协作的场景里这个习惯能减少一半低级 bug。第三空数据、加载错误、加载成功这三个分支在写渲染逻辑时同时考虑清楚。我也不知道该怎么更强调这件事——列表页百分之九十的线上事故都出在这三个分支没写全。如果你现在正在做一个列表页面打开编辑器之前先把这四个问题过一遍数据量级到底多大交互是纯展示还是复杂操作筛选状态要不要同步 URL接口的返回结构定了没有这四个问题想清楚了动手写代码就是水到渠成的事。列表页从来都不是一个“难”的功能但它确实是一个“细”的功能细节做到位它就能稳稳支撑起整个产品的一小半体验。