ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

十万条数据渲染优化:Web Worker + 虚拟滚动实战

十万条数据渲染优化:Web Worker + 虚拟滚动实战 十万条数据一次性渲染到页面上浏览器会怎样答案是直接卡成 PPT运气差一点直接白屏崩溃。这不是夸张上个月我接了一个数据看板的需求接口一次返回十万行明细数据要求支持整表滚动查看、关键词搜索、列排序。第一版图省事把数据 for 循环拼成 DOM 往页面里一怼结果首屏渲染花了三四秒拖动滚动条的时候整个页面像被人按住了CPU 直接拉满风扇呼呼转。后来我把方案改成 Web Worker 虚拟滚动同样十万条数据首屏渲染降到几十毫秒滚动稳定在 60 帧搜索和排序也不会卡住界面了。这篇就把完整思路、核心代码和踩坑过程写下来给后面遇到同类需求的同学一个可以直接参考的样本。1. 先拆需求十万条数据到底难在哪里看到十万条这个数字很多人的第一反应是分页。但实际业务里产品要的是像 Excel 一样往下滚动看所有数据还要在当前全量数据里搜索、排序、做聚合。分页会把交互切碎用户每次翻页都难受所以这条路从一开始就被排除了。既然要在前端一次性拿到十万条数据就要先搞清楚它到底卡在哪个环节。1.1 三个层面的性能瓶颈第一个瓶颈是 DOM 节点数量。十万条数据哪怕只用最简单的 div也会生成十万个元素。浏览器渲染引擎对节点数量是有压力的布局(layout)和样式计算(style recalc)的复杂度会随节点数线性往上走。十个节点没关系一百个没事一万个开始变慢十万个就是灾难。我在第一版测试时光是把十万个节点插进 DOM 就花了两秒多页面滚动时的重排更是拖都拖不动。第二个瓶颈是内存。每个 DOM 节点不光是元素本身还有绑在上面的样式、事件、解析结构平均一个节点在内存里可能占几百字节到几 KB。十万个节点光 DOM 部分就有几十 MB 的开销。移动端上这个数字会更夸张很多中低端安卓机直接撑不住。第三个瓶颈是主线程的计算。十万条数据不只是渲染还要支持搜索和排序。用 JavaScript 在主线程里做一次 filter遍历十万条可能需要几十毫秒排序更贵可能上百毫秒。这个过程中用户点任何按钮都没反应因为主线程被占着事件循环转不动。1.2 为什么分页和懒加载解决不了这个问题分页类组件在表格场景里很常见但它的缺陷是产品视角下用户要的是连续滚动查看不是一页一页翻。”下一页按钮在数据探索类需求里效率很低用户根本不知道目标数据在第几页。懒加载则是滚动到底再加载下一批纯前端懒加载的问题在于数据始终会在 DOM 里累积滚到后面几万条的时候前面的节点早就把渲染性能拖垮了。真正合理的思路是把渲染窗口和数据总量彻底解耦数据量再大页面上同时只存在视口附近的那几十个节点而计算量再大也不堵在主线程上。这也是 Web Worker 虚拟滚动这对组合能成立的前提。1.3 结论从渲染和计算两头同时动手我在排障时习惯先分层渲染层的问题就用渲染层的手段解决计算层的问题就用计算层的方案解决。虚拟滚动负责把 DOM 数量从十万压到几十Web Worker 负责把过滤、排序这类计算从主线程挪到后台线程。两条线同时动十万条数据才真正变得可交互。如果你只做虚拟滚动搜索引擎输入关键词时主线程还是会卡顿如果你只上 Worker渲染节点还是十万个照样卡。两者缺一不可。2. 方案设计Web Worker 和虚拟滚动各管哪一段2.1 虚拟滚动解决的是渲染瓶颈虚拟滚动的核心思想非常朴素用户的眼睛一次只能看到窗口那么大一块区域我只需要把那一个区域里的行渲染出来。容器高度固定为 800px每行高 40px那么同时可见的也就是 20 行左右。加上上下预留的缓冲行总共渲染 40 个节点就够了。十万个节点和四十个节点对浏览器来说是完全不同的压力等级。你可以把它类比成看一幅百米长卷你站在某一处眼前能看清的只有一截画布没必要把整幅长卷都展开铺在地上。虚拟滚动做的事情就是只把你当前能看到的那一截铺开你往前走一步它立刻换上新的一截但地上始终只有一小块布。这个类比帮我跟产品和后端解释了很久他们都秒懂。2.2 Web Worker 解决的是计算瓶颈Web Worker 是浏览器提供的一个后台线程它跟主线程并行运行各有一套独立的 JavaScript 执行环境。主线程负责 DOM 操作和用户交互Worker 里可以放心大胆地做过滤、排序、聚合这些耗时计算算完再把结果通过 postMessage 传回主线程。还是拿刚才的看长卷类比虚拟滚动解决了眼前这一截怎么展示Web Worker 则解决这一整幅画怎么快速整理。数据从接口回来后先丢给 Worker 做清洗、排序、建立索引主线程这个时候可以专心渲染首屏。用户输入搜索关键词、点排序按钮请求发到 Worker界面不会卡住用户可以继续滚动等结果返回后一秒钟更新列表体感非常顺滑。2.3 什么时候别硬上 WorkerWorker 不是银弹它也有成本。每次 postMessage主线程和 Worker 之间传递数据都存在序列化拷贝的开销默认走结构化克隆 structured clone。如果数据集只有几百条处理逻辑只是简单的 map直接在主线程算可能只需要 1ms丢进 Worker 加上通信开销反而变成 5ms纯属脱裤子放屁。所以我一般给自己定了条线数据量上万、处理复杂度在 O(n) 以上、且用户操作期望即时响应的时候才上 Worker。如果是几千条数据的简单展示直接虚拟滚动就够了别为了炫技引入不必要的复杂度。十万条这个量级才是 Worker 的价值区间。3. 虚拟滚动核心参数计算与完整实现3.1 核心原理只渲染看得见的那几行实现虚拟滚动时页面上需要有这样几层结构外层容器固定高度overflow-y: auto负责产生滚动条占位层把高度撑成总行数 × 行高让滚动条看起来像真的有十万行内容层绝对定位在容器顶部往上挪到当前可视区域对应的位置里面只放可视区的行。内容层用 transform: translateY() 来移动而不是修改 top 值是因为 transform 不触发重排只触发合成性能消耗小得多。这一层细节等你在实测中看到滚动流畅度的差距就很明显了。3.2 四个关键参数的计算公式做虚拟滚动前先把这几个参数算明白参数公式说明可视行数 visibleCountMath.ceil(容器高度 / 行高)一屏内完整能放下的行数起始索引 startIndexMath.floor(scrollTop / 行高) - buffer从哪一行开始渲染结束索引 endIndexstartIndex visibleCount buffer * 2渲染到哪一行结束内容层偏移startIndex * 行高translateY 的位移值buffer 就是缓冲行数我习惯取可视行数的 0.5~1 倍。它的作用是防止用户快速滚动时还没渲染出来的行露出白屏。比如可视区 20 行buffer 取 10上下各预留 10 行实际渲染 40 行滚动时浏览器有时间提前渲染新行体验就不会断层。行高的选择也有讲究。固定行高是最简单的计算不用猜性能最好。现在主流组件库的列表项行高通常在 32~48px 之间太矮了影响点击太高了浪费屏幕。我这边统一设计成 40px视觉和性能都比较平衡。3.3 可运行的完整实现先看 HTML 和 CSS 的结构div classlist idlist !-- 占位层撑起滚动条 -- div classplaceholder idplaceholder/div !-- 内容层绝对定位只放可视区内的行 -- div classcontent idcontent/div /div.list { position: relative; height: 800px; overflow-y: auto; border: 1px solid #d9d9d9; } .placeholder { height: 4000000px; /* 100000 * 40 */ } .content { position: absolute; top: 0; left: 0; right: 0; } .row { height: 40px; line-height: 40px; padding: 0 12px; box-sizing: border-box; border-bottom: 1px solid #eee; font-size: 13px; color: #333; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }占位层高度 4,000,000px 看着夸张但它是纯计算值不会真占内存只是让滚动条长度符合十万行的预期。接下来是核心逻辑const list document.getElementById(list); const content document.getElementById(content); const rowHeight 40; const total 100000; const buffer 10; function update() { const scrollTop list.scrollTop; const viewportRows Math.ceil(list.clientHeight / rowHeight); let start Math.floor(scrollTop / rowHeight) - buffer; start Math.max(0, start); let end start viewportRows buffer * 2; end Math.min(total, end); render(start, end); } function render(start, end) { const fragment document.createDocumentFragment(); for (let i start; i end; i) { const row document.createElement(div); row.className row; row.textContent 第 ${i} 行item-${i}; fragment.appendChild(row); } content.style.transform translateY(${start * rowHeight}px); content.replaceChildren(fragment); } list.addEventListener(scroll, update, { passive: true }); update();这段代码有几个小细节值得说。滚动监听一定要加 { passive: true }告诉浏览器我不会在这个事件里阻止默认行为这样滚动才能直接走合成器线程不阻塞主线程。用 document.createDocumentFragment() 批量构建节点比在循环里逐个 appendChild 到 DOM 少触发多次重排。最后用 replaceChildren 一次性替换内容层它比 innerHTML 再 append 的性能更稳旧节点会被整体回收。3.4 边界情况处理与白屏防护实际跑起来之后最容易踩的坑是快速滚动白屏。用户按住滚动条猛拖scrollTop 一帧能跳几百像素如果 buffer 太小新区域还没渲染可视区就露馅了。我的经验是 buffer 宁可多留一点渲染 60 个节点和渲染 40 个节点性能差异几乎感觉不到但滚动手感差很多。另一个坑是索引越界。startIndex 减 buffer 之后可能变负数endIndex 加 buffer 之后可能超出总量所以代码里的 Math.max 和 Math.min 一个都不能少。还有用户拖动浏览器窗口导致容器高度变化的问题这个要配合 ResizeObserver 重新计算可视行数不然缩放窗口后窗口内容会出现空白或者重叠。动态行高是虚拟滚动最大的敌人。如果每一行高度不确定startIndex、translateY 的计算全部要改成基于累积高度的估算实现复杂度会翻好几倍。我的建议是设计阶段就把行高固定下来如果内容可能换行优先用文字截断或 Tooltip 方案而不是放任行高自由生长。十万行的性能优化很多时候是靠设计约束换来的。4. Web Worker 接入把十万条数据的计算搬下主线程4.1 哪些计算值得扔给 Worker十万条数据在内存里用户的操作场景无外乎三种关键词过滤、按列排序、分组统计。这三种都是典型的 O(n) 或 O(n log n) 操作放在主线程上执行时哪怕只有一两百毫秒的耗时用户也会明显感觉到界面卡顿因为 JavaScript 主线程是单线程计算期间无法处理任何点击和滚动事件。把这些计算搬进 Worker 之后主线程只负责接收结果和更新视图整个过程用户无感知。实测里十万条数据做一次关键词过滤Worker 里大概 20ms排序大概 80ms这些耗时虽然不算特别低但不会再阻塞渲染用户该滚滚动条继续滚体验是完全不同的。4.2 一个最小可用的 Worker 通信链路Worker 的用法不复杂先建一个独立脚本文件// worker.js self.onmessage function (e) { const { type, data, keyword } e.data; if (type filter) { const result data.filter(function (item) { return item.name.includes(keyword); }); self.postMessage({ type: filterDone, total: result.length, data: result }); } if (type sort) { const result data.slice().sort(function (a, b) { return a.value - b.value; }); self.postMessage({ type: sortDone, data: result }); } };主线程这边用 new Worker 创建实例发消息和收消息const worker new Worker(./worker.js); worker.onmessage function (e) { const { type, data } e.data; if (type filterDone) { // 拿到过滤后的结果交给虚拟滚动重新渲染 updateVirtualList(data); } }; function startFilter(keyword) { worker.postMessage({ type: filter, data: sourceData, keyword: keyword }); }主线程和 Worker 之间不共享变量只能通过消息传递。数据传过去处理完再传回来。这套模型很干净只要任务粒度合适代码解耦起来也顺手。如果你是 Vite 项目可以直接这样导入 Worker不用手动维护路径import MyWorker from ./worker.js?worker; const worker new MyWorker();Webpack 也有 worker-loader 或者 new Worker(new URL(./worker.js, import.meta.url)) 的写法选哪种取决于工程配置原理都一样。4.3 大数据回传的传输优化Worker 通信最需要注意的还是那 10 万条数据本身的传输开销。默认的 postMessage 会做结构化克隆数据量大时克隆本身也要花几十毫秒。如果来回传的都是完整对象数组这个开销会很可观。我从项目里总结的几条经验发送给 Worker 之前先裁剪字段。接口返回的原始对象往往有几十个字段但渲染和排序只用到三四个提前 map 成瘦身对象传输量直接少一个数量级。有条件的话用 ArrayBuffer 作为传输载体。postMessage 支持第二参数 transferables把 ArrayBuffer 转移出去不会发生拷贝性能最好worker.postMessage(buffer, [buffer]);不过要注意transferable 的本质是把数据移交给 Worker主线程这边就不能再用了适合一次性数据。普通 JS 对象数组不能直接转移只能克隆所以裁剪字段的方案更通用。任务要加版本号防止旧任务覆盖新结果。用户连续输入搜索词时上一次 Worker 任务可能还在跑结果后返回的反而更早会把界面更新成旧数据。我在每次发任务时维护一个递增 ID回传时带上 taskId主线程只接受最新一次的 ID其余的丢弃let taskId 0; function requestFilter(keyword) { const id taskId; worker.postMessage({ type: filter, data: sourceData, keyword, taskId: id }); } worker.onmessage function (e) { if (e.data.taskId ! taskId) return; updateVirtualList(e.data.result); };这个细节救了我很多次强烈建议写进你的模板里。4.4 实测优化前后的数据对比我在公司测试机上用控制台做了简单对比机器配置是普通的 i5 笔记本Chrome 最新版。数据量统一是 10 万行每行 6 个字段。结果如下维度直接渲染虚拟滚动 Worker页面 DOM 节点数约 100000约 40首屏渲染时间3.2s86ms滚动帧率5~8 fps稳定 60 fps关键词搜索界面卡死约 400msWorker 异步返回界面无感按列排序界面卡死约 800msWorker 异步返回界面无感数字会因设备和数据量有浮动但量级差距不会变。这已经足够说明问题渲染瓶颈和计算瓶颈分开治理之后十万条数据完全可以在前端做到流畅交互。5. 常见问题与排查技巧实录5.1 高频问题速查表现象原因解决办法快速滚动时中间出现白屏buffer 太小新行来不及渲染增大 buffer或改为渲染后强制同步一次页面缩放后内容错位容器高度变化未重新计算用 ResizeObserver 监听容器尺寸滚动时列表抖动行高估算不准或浮点误差固定行高索引计算用 Math.floor 取整数据更新后内容没变Worker 旧任务覆盖新结果任务 id 版本控制只认最新结果postMessage 后主线程仍卡数据量大结构化克隆开销高裁剪字段优先传瘦身对象移动端滚动掉帧滚动回调里做了重计算回调里只算索引渲染交给 rAF 节流5.2 一个看起来相关其实不同的报错Service Worker 注册失败开发过程中有同学把控制台的报错发给我报错信息是加载 web 视图时出错: error: could not register service worker: invalidstatee。乍一看带 worker 字样以为跟 Web Worker 有关其实它指的是 Service Worker这俩是两回事。Service Worker 主要用于 PWA 的离线缓存和后台同步Web Worker 才是我们做性能优化的计算线程。这个 InvalidStateError 最常见的触发原因有以下几种页面不是安全上下文比如通过 http 访问或者被嵌在跨域 iframe 里register() 传入的脚本地址跟页面不同源脚本本身返回了 404 或者语法错误某些 WebView 版本对 Service Worker 支持不完整。排查思路也很直接确认页面是 https 或 localhost打开 DevTools 的 Application 面板看 Service Workers 注册状态再给 register 加上 catch 把真实的错误对象打印出来。这里想提醒大家的是排错的时候先区分是哪一种 Worker别被名字混淆带偏了方向。5.3 三条独家避坑经验第一滚动监听一定要用 passive。Chrome 从很早的版本开始就会在控制台警告Unable to preventDefault inside passive event listener如果滚动回调里有重计算这个警告点开能看到完整的堆栈。加 { passive: true } 不只是消除警告是真的能让滚动事件不阻塞主线程建议形成肌肉记忆。第二渲染逻辑要短平快。虚拟滚动里的 render 函数每次滚动都会触发里面只应该做创建节点、填内容、替换内容层这三件事。任何跟过滤、排序、格式化有关的工作都不要放进来。如果数据需要格式化提前在 Worker 里把 textContent 用的字符串算好渲染时直接赋值避免每次滚动都重复计算。第三固定行高的小陷阱别忘了 box-sizing: border-box。如果行有 padding 或者 border不加 border-box 会让实际占位高度超过设定的 40px累积起来整体偏移越来越明显。我遇到过几回滚动到中间位置行错位了的 bug排查到最后都是这个 CSS 细节。这个组合方案从第一版写到现在我前后迭代了三个版本最大的体会是性能优化不是靠某一条银弹而是把每个环节的浪费都挤掉。虚拟滚动告诉浏览器你只需要画这一小块Worker 告诉主线程你别干重活两件事合在一起十万条数据才真正变得可交互。如果你的项目也卡在同类需求上别急着堆机器或者砍需求从渲染和计算这两条线一起动手大概率能稳住。
返回列表