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

发布时间:2026/9/23 3:27:29

十万条数据渲染优化: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 告诉主线程你别干重活两件事合在一起十万条数据才真正变得可交互。如果你的项目也卡在同类需求上别急着堆机器或者砍需求从渲染和计算这两条线一起动手大概率能稳住。
延伸阅读

更多相关文章

2026/9/23 3:27:29

3个暗示效应坑点,助你从入门到精通避坑

3个暗示效应坑点,助你从入门到精通避坑 看了一堆教程还是不会写项目?别急着骂自己笨。很多时候,不是你不懂语法,而是被代码里的“暗示效应”坑了。那些看似正常的变量名、隐式的类型转换、或者框架里的默认行为,都在无声地“暗示”你:这行代码是对的。…

2026/9/23 4:17:31

基于Python的TCP入侵检测系统:端口扫描与SYN Flood防御实战

简介:基于Python构建的TCP入侵检测系统,面向毕业设计、课程设计及网络安全方向项目开发。系统围绕TCP请求频率、SYN/FIN/NULL等flag标志位比例、未开放端口请求比例三项核心指标,可识别端口扫描、Dos攻击及爬虫行为,并联动iptable…

2026/9/23 4:17:31

SSM铁艺家居商城系统设计与实现——从数据库到前端全解析

最近帮人调了一个SSM版本的铁艺家居商城项目,标题写的是java_ssm11特色铁艺家居家具商城销售系统的设计与实现_idea项目源码,说白了就是一个典型的前后台单体Web应用:Spring管理对象和事务、SpringMVC负责请求分发、MyBatis处理数据库操作&am…

2026/9/23 4:17:31

AI Coder现状与Qwen Coder Mac本地部署实战指南

看到“coder”这个标题,你多半不是来寻找身份认同的——虽然程序员群体确实经常用这个词自称。最近一段时间,后台和社群里被问得最多的一批搜索词,基本就是“qwen coder mac 部署”“ai coder 代码生成现状”“coder咋下载”“kh coder”。这…

2026/9/23 4:17:31

构建安全审计Skill:AI编程助手时代的代码安全自动化实践

前阵子在给项目做代码审计的时候,我突然意识到一个问题:现在AI编程助手已经能帮我们写大部分业务代码了,但在代码安全这块,它们的能力其实相当不均衡——很多模型默认生成的代码,SQL拼接、反序列化、越权接口&#xff…

2026/9/23 4:12:31

模糊人脸图像增强实战:从物理退化建模到Django部署

简介:本资源是一套高分毕业设计项目——基于Python深度学习的模糊人脸图像增强系统,面向计算机类专业本科生及初阶AI学习者,解决低质量监控或抓拍人脸图像的清晰度重建问题,适用于毕设、课程设计、项目演示与深度学习实践入门。压…

2026/9/22 10:02:42

GAMP 5 基于风险的计算机化系统验证:软件分类与审计追踪实践

简介:《A Risk-Based Approach to Compliant GxP Computerized Systems》即业内熟知的GAMP 5指南,面向制药企业质量与IT合规人员、验证工程师及计算机化系统管理者,用于解决GxP法规环境下系统合规性难以科学落地的问题。文档以风险管理为主线…

2026/9/22 9:07:39

安全托管MSSP实战:从静态防御到人机协同的攻防运营与应急响应

简介:这份PPT围绕互联网业务安全托管服务展开,面向企业安全负责人、IT运维人员及关注MSSP/MSS选型的读者,重点回应传统安全过度依赖人工、碎片化静态防御难以对抗产业化攻击等痛点。资源共1个pptx文件,包体约30.63MB,以…

2026/9/23 0:01:54

3个实战技巧搞定形式英语:从看教程到跑通性能优化

3个实战技巧搞定形式英语:从看教程到跑通性能优化 看了一堆教程还是不会写项目?别慌,这种“眼高手低”的困境在开发者圈子里太常见了。很多人以为卡点在语法,其实真正拦路虎是缺乏将知识点串联成完整链路的能力。今天咱们不聊虚的,直接拿【形式英语】这…

2026/9/22 16:34:32

USB Type-C PCB布局分区设计:电源、高速信号与PD协议全攻略

做硬件这行,Type-C接口算是典型的“看着简单,做起来全坑”的东西。光引脚就24个,高低速信号、电源、控制线全部塞在一个小小的连接器里,如果PCB布局不做规划,打样回来基本就是“插上没反应”、“高速掉线”、“静电一打…

2026/9/22 20:01:30

系统编程学习原型如何补齐稳定性边界

系统编程学习原型如何补齐稳定性边界预算有限时&#xff0c;我先优化明显多余的复制&#xff0c;而不是猜测性地换容器。用借用传递只读数据通常就能减少分配&#xff1a; fn parse(line: &str) -> Result<Item, Error> { /* ... */ }用基准确认热点确实在分配&am…

2026/9/22 13:25:41

雨花区哪家财务公司代理记账比较好?

在雨花区&#xff0c;企业处理财税事务常常面临诸多挑战&#xff0c;选择一家靠谱的财务公司至关重要。湖南巨勤财务管理咨询有限公司就是本地正规实体财税服务机构&#xff0c;深耕本地工商财税行业多年&#xff0c;熟悉当地工商局、税务局最新政策与申报流程。主营公司注册、…

还想了解更多?直接咨询顾问

免费诊断 + 免费方案 + 透明报价。

全国咨询热线400-8866-253
免费获取方案
咨询二维码