登录
推荐 文章 Go 技术 课程 下载 专题 AI
首页 >  文章 >  前端

前端大列表滚动为什么会掉帧:虚拟列表、批量渲染与 60 FPS 验收

来源:17golang原创

时间:2026-08-24 17:06:48 458浏览 收藏

不少人维护的商品后台订单列表,数据量从800行涨到12000行之后,最先出问题的往往不是接口响应变慢,而是滚动鼠标滚轮的时候,整个表格仿佛被按住了一样卡顿。打开Performance面板一看,满屏的长任务和频繁布局,真正拖慢交互的原因其实很简单:一口气把所有行全部交给DOM渲染,以及筛选结果变化时连续触发无间隔的渲染操作。

这类问题通常要同时收缩两个优化方向:屏幕可视区域附近只保留少量行,数据变化时按批次把任务逐步交给浏览器执行。虚拟列表用来压缩常驻DOM的总数量,批量渲染用来避免主线程被一次性占满;只改其中任意一处,想稳定守住60 FPS往往很难实现。

要点速览
  • 先用Performance面板录制滚动和筛选的两条基线,不要凭感觉盲目修改组件逻辑。
  • 固定行高的列表优先使用可精确计算的可视窗口,保留上下缓冲区域避免快速滚动时露白。
  • 大批量更新要拆分成小批次,每批执行完之后主动让出主线程控制权。
  • 最终验收要同时看长任务占比、掉帧比例和输入延迟三个维度,不能只参考平均渲染时间。

前端虚拟列表只渲染可视窗口并用上下缓冲减少 DOM 数量

先把掉帧拆成两个可测问题

滚动掉帧和筛选卡顿经常同时出现,但二者走的不是同一条处理链路。滚动场景下主要看每帧耗时有没有超过约16.7ms,筛选场景下则要看一次状态更新会不会生成超长任务。如果把两种场景混在一起调试,优化完之后很容易得到“滚动流畅了一点,搜索还是很卡”的错误判断。

场景重点指标先查什么
连续滚动掉帧、帧耗时、输入延迟可见 DOM 数量与布局次数
筛选刷新长任务、响应延迟单次更新行数与脚本耗时
快速跳页空白、抖动、内存缓冲区与节点回收

先在同一台机器、同一个数据集下录制10秒连续滚动操作。把“能流畅滚动”这种模糊感受改成可以横向对比的基线:比如可视区域显示30条、总数据12000条、滚动期间长任务出现次数和超过16.7ms的帧占比。这个结果只能对应当前实现的表现,不代表换浏览器后效果也完全一致,所以后续要固定验收环境。

虚拟列表如何把 DOM 数量压到可控范围

固定行高场景下,滚动容器的顶部偏移量可以直接换算出当前需要渲染的起始数据索引:

const rowHeight = 36;
const overscan = 6;
const start = Math.max(0, Math.floor(scrollTop / rowHeight) - overscan);
const visibleCount = Math.ceil(viewportHeight / rowHeight) + overscan * 2;
const end = Math.min(rows.length, start + visibleCount);

const visibleRows = rows.slice(start, end);
const offsetTop = start * rowHeight;

外层容器仍然要保留总高度,否则滚动条会因为只剩几十个节点而失去真实比例。常见做法是用一个占位层撑起 rows.length * rowHeight 的高度,再把当前窗口放到 offsetTop 处。上下各留几行缓冲,能覆盖滚轮事件和触控惯性之间的短暂空档。

这里别急着把行高写死成固定值。如果每行的高度会因为文本换行、图片加载或者展开面板动态变化,索引换算出来的结果会持续失真,最终表现为列表跳动、行重叠或者滚动还没到底就提前触发触底事件。遇到这种情况,应该先限制内容区域的布局规则,或者改成行高测量缓存机制,没有稳定的高度模型支撑,虚拟化只会把表面问题藏得更深,后续调试更麻烦。

筛选和刷新为什么还需要批量渲染

虚拟列表只是减少了最终需要留在页面上的节点总数,筛选操作本身仍有可能在主线程里同步处理12000条全量数据,接着一次性同步创建整批行节点。更稳妥的处理方案是先把筛选结果计算完成,再把显示更新拆分成多个小批次,主动让出浏览器处理输入事件和绘制的机会:

function appendInBatches(items, render, batchSize = 80) {
  let cursor = 0;
  function flush() {
    const next = Math.min(cursor + batchSize, items.length);
    render(items.slice(cursor, next));
    cursor = next;
    if (cursor 

批次大小也不是越小越好。同样的80行数据,在办公本上运行刚好在预算内,但如果单元格内部逻辑复杂,执行耗时还是可能超过单帧阈值。实际调试时可以在Performance面板里观察每批脚本的执行耗时,再把批次调整到16.7ms预算以内,同时预留出足够的绘制余量。如果数据过滤逻辑本身计算量很大,可以把纯计算逻辑移到Worker里处理,主线程只负责接收最终结果和维护可视窗口。

前端大列表筛选后分批更新主线程并在每批之间恢复响应

一次验收要同时看滚动、筛选和回退

  1. 固定12000行测试数据和浏览器窗口尺寸,录制连续滚动10秒的表现。
  2. 重复执行关键词筛选、清空筛选和快速拖动滚动条跳转到末尾的操作,记录过程中的长任务和输入延迟。
  3. 检查列表顶部、底部、快速拖动滚动条时会不会出现空白、抖动或者重复渲染行的问题。
  4. 临时关闭虚拟化功能做对照测试,确认优化收益来自节点数量精简和批量渲染策略,而不是测试数据集偶然变小带来的偏差。

不少开发者会把“滚动时超过16.7ms的帧占比明显下降、筛选过程中没有连续长任务、快速滚动全程无空白”作为第一道验收门槛,之后再去低端设备上做兼容测试。如果列表行高不稳定、用户频繁展开详情面板,或者列表总数据量并不大,用普通分页方案实现反而会比虚拟列表更容易维护。性能优化的终点从来不是代码写得更复杂,而是交互时延预算能被稳定复现和保障。

常见问题:虚拟列表上线前还要确认什么

虚拟列表一定比分页快吗?

不一定。需要连续浏览大量内容、频繁快速滚动的大列表场景更适合做虚拟化;用户按条件查询、单页只展示几十条的后台表格,用普通分页实现往往更简单稳定。

为什么虚拟列表滚动时会出现白块?

通常是缓冲行数量太少、渲染批次太大或者行高估算错误导致的。可以先适当增加上下缓冲的预渲染行数,再检查实际行高有没有被异步图片加载和内容换行操作改变。

批量渲染能不能直接用 setTimeout?

可以用来做粗粒度的主线程让出操作,但它的执行时机不会和下一帧绘制同步。如果需要紧贴浏览器的绘制节奏调整渲染逻辑,优先使用requestAnimationFrame,并重新评估每批任务的工作量。

怎么判断该上 Worker?

当筛选、排序或者数据转换本身占用大量CPU时间,而整个计算任务又不依赖DOM操作的时候,再考虑把逻辑放到Worker里运行;所有DOM更新操作仍然必须回到主线程执行。

把优化结果留成可复测的记录

最后要保存好测试时的数据规模、设备型号、浏览器版本、窗口尺寸、虚拟窗口参数、批次大小和三组核心指标。下次组件升级或者单元格内部逻辑变更时,直接用同一套测试流程回放验证,就能明确感知到性能变化是来自数据量改动、样式逻辑调整,还是渲染策略本身出现了退化。

声明:本文转载于:17golang原创 如有侵犯,请联系study_golang@163.com删除
相关阅读
更多>
最新阅读
更多>
课程推荐
更多>