
1. 一次真实的卡顿现场loading 转着转着就“冻住”了1.1 现象表现与复现路径先还原一下我当时遇到的问题。项目是一个典型的管理后台报表页接口返回一张大列表大概 8000 到 15000 行每行有七八个字段还要带状态标签、操作按钮这类子节点。页面交互并不复杂用户点击“查询”前端发异步请求请求期间显示全局 loading数据回来后把表格数据交给表格组件渲染。问题很明确loading 动画转个一两圈突然卡住不动了页面点哪里都没反应浏览器标签页直接变成“无响应”状态过个 3 到 5 秒才恢复正常。更糟的是很多用户会在这段时间里重复点击查询按钮等恢复后请求已经重复发了好几次后端被白白打了好多遍。我第一次排查时还以为是接口慢后来看 Network 面板发现接口响应只要 400ms 左右问题出在数据回来之后的渲染阶段。用 Chrome Performance 面板录了一段真相一目了然从数据赋值到表格渲染完成中间主线程被一段长达 3.8 秒的 Long Task 占满loading 的关闭逻辑、动画更新、事件响应全部排在后面排队。所谓“冻结”其实就是主线程根本没空处理新任务。这个场景其实非常常见凡是做过管理后台、数据大屏、报表导出预览的人大概率都撞过。根子在于异步请求只是把数据从“服务器”搬到了“内存”但把数据变成真实 DOM 这件事依然全部压在主线程上。数据量一旦上去主线程就变成单车道什么车都堵在里面。1.2 不是“网络慢”而是“渲染线程窒息”要理解这个现象得先搞清楚浏览器渲染的基本流程。我在浏览器控制台里跑过一段性能采样把问题拆成了三块请求等待、数据处理、DOM 渲染。其中请求等待只占很小一部分真正吃掉时间的是第三段。页面里所有 JavaScript 执行、样式计算、布局Layout、绘制Paint都在主线程上。Global loading 本质上是一个依赖主线程持续调度的动画按钮点击事件也需要主线程响应。当大数据渲染一次性同步执行时JavaScript 引擎会在一个任务里连续完成先把几千行数据的对象数组推入表格再触发组件重新渲染然后计算布局、绘制样式。浏览器虽然尝试把样式计算和布局拆成多个帧但同步逻辑一次执行太久主线程根本没机会插入新的任务于是动画掉帧、点击监听无法触发、页面看起来就是“死了”。我当时的项目里首屏表格用的是第三方数据表格组件内部对数据做了深度响应式处理。8000 行数据传进去光是响应式代理劫持和重渲染前的数据变更监听就花掉了不少时间。这部分工作在 Performance 面板里表现为一个巨大的紫色脚本块后面跟着更长的紫色样式重算。所以别急着甩锅给表格组件先自己测一下同样数量的数据用最简单的原生 Table 渲染会不会卡1.3 复现实验数据量到多少开始“冻屏”为了把这个临界点找出来我特意做了个小实验。同一台开发机、同一个空页面分别渲染 1000、3000、5000、8000、10000 行表格记录主线程最长任务时间和大致的阻塞时长。结果如下1000 行几乎无感Long Task 约 120ms3000 行能感觉到轻微卡顿约 400ms5000 行loading 动画出现可察觉掉帧约 900ms8000 行动画明显卡顿约 1800ms10000 行页面出现短暂“无响应”约 3000ms 以上真正恶化的不是渲染本身而是 DOM 数量增长之后样式计算和布局的复杂度非线性上涨。表格每行都是十几个节点1 万行就是 10 万 节点浏览器在做样式匹配时要把整棵 DOM 树遍历一遍数据一变布局可能整体重排。所以这不是“稍微慢一点”而是会跨过体验断崖直接卡到用户怀疑人生。2. 全局 loading 管理的真相拦截器救不了渲染时序2.1 常见实现方式计数器 拦截器 v-show说回全局 loading 本身。我当时用的是很常规的方案axios 请求拦截器里计数器加一显示全屏遮罩层响应拦截器里计数器减一减到零就隐藏。这个思路本身没毛病问题出在它只控制“请求阶段”的状态对“渲染阶段”完全放任不管。具体来说正常流程是这样的点击查询 → 拦截器 show loading → 请求发出 → 响应到达 → 拦截器把数据交给页面 → 页面同步 setState → 表格渲染 → 渲染完成后拦截器里的 hide loading 才执行。但实际上响应拦截器里 hide loading 是在数据返回的同一时刻调用的如果页面代码在数据回调里做了耗时的同步渲染那 hide loading 的逻辑会被堵在后面loading 遮罩层就会一直停在那里。更麻烦的是很多全局 loading 遮罩层是带 click 事件拦截的本意是防止用户重复操作。结果主线程一卡用户的点击事件全部积压等主线程空闲后这些点击事件被依次派发于是出现了“卡顿结束后自动发了一堆请求”的灵异现象。这根本不是网络问题而是任务调度的直接后果。2.2 核心矛盾loading 的隐藏与渲染任务在抢主线程我后来理了一下可以把所有任务按优先级排个队用户输入事件、动画帧回调、布局绘制、定时器、Promise 回调、网络回调。浏览器有一套自己的优先级规则但对开发者来说代码层面无法直接指定某个任务的优先级。也就是说你没法说“先让 loading 关掉再慢慢渲染表格”你只能在代码结构上主动把渲染任务拆开给 loading 留出喘息的空间。因此解决全局 loading 卡死的第一板斧不是换一个更炫酷的 loading 组件而是让 loading 尽量早点退场别让它跟着渲染任务一起排队。两种思路要么把渲染延迟到 loading 关闭之后用两段式任务要么把渲染拆成多个小任务让浏览器在任务间隙有机会处理 loading 的关闭和动画。我在项目里最初用的是后一种把 hide loading 放在一个 setTimeout 0 里先执行然后等一帧再渲染。简单有效但治标不治本。数据稍微再涨一点后续渲染照样卡。所以这一步只是铺垫真正的重头戏在下面的数据加工和渲染调度上。2.3 具体踩过的坑遮罩层点击穿透与重复请求这里值得展开说一下重复请求的坑。我的全局 loading 遮罩层是用 fixed 定位盖在整个页面上正常情况下鼠标点击被遮罩挡住不会穿透到底下的按钮。但主线程卡住时浏览器并不会因为卡顿就把点击事件吞掉它会先把事件放到队列里等空闲了再派发。如果遮罩层因为在某些时序下提前被隐藏那么积压的点击事件就会穿透到一个还没有完全解除绑定的按钮上于是查询接口被连续触发。后来我加了一道保险查询按钮在点击后立即进入 disabled 状态并且设了一个 1 秒的防重复标记只接受第一次请求的结果后面的重复响应一律丢弃。这个方案不依赖 loading 遮罩的拦截彻底断了重复请求的源头。老实说这比依赖 loading 层防误触可靠得多项目上线到现在没再出现过重复提交的情况。3. 把数据处理移出主线程Worker 不是只能用来算哈希3.1 不止排序和过滤数据加工才是大头很多人提到 Web Worker第一反应是“可以用它做加密计算、复杂排序”。但在我这个场景里数据加工逻辑比这重得多接口返回的是扁平列表页面需要把它构造成树形结构加上状态码到文本的映射、标签的 className 生成、若干字段的拼接与格式化、日期转换、权限字段过滤。这一套走下来几毫秒到几十毫秒不等看着不吓人但它和渲染连在一起就变成了压垮主线程的最后一根稻草。更关键的是所有这些加工纯属“纯函数式操作”输入是接口 JSON输出是渲染所需的数组。它不碰 DOM不依赖 window 上某些状态非常适合放 Worker。我当时把表格数据加工的整段逻辑搬进 Worker 后主线程只剩两件事发数据过去、等结果回来。从代码结构上也清爽了很多。3.2 实战示例主线程只负责“发”和“收”以我当时项目里的写法为例先看主线程部分// 主线程 const worker new Worker(new URL(./tableData.worker.js, import.meta.url), { type: module }); let resolveTableData null; worker.onmessage (event) { const processedList event.data; if (resolveTableData) { resolveTableData(processedList); resolveTableData null; } }; async function buildTableData(rawList) { return new Promise((resolve) { resolveTableData resolve; worker.postMessage(rawList); }); } // 业务层使用 const rawData await fetchReportData(params); const processedData await buildTableData(rawData); renderTable(processedData);Worker 内部则是这样的// tableData.worker.js import { formatDate, mapStatusToText, buildTree } from ./processUtils.js; self.onmessage async (event) { const rawList event.data; const result rawList.map((item) ({ ...item, statusText: mapStatusToText(item.status), treePath: buildTree(item), dateText: formatDate(item.createTime), rowClass: getRowClassByStatus(item.status) })); self.postMessage(result); };这段代码里有一个值得注意的细节不要在 Worker 里做和 DOM 渲染强相关的封装。比如我给每行生成一个“包含了完整 DOM 结构的模板字符串”这种做法看着省事实际上把渲染逻辑完全绑定到了字符串上后续要改表格列、调整样式都得去 Worker 里改反而难维护。Worker 只负责产出“更干净的 JS 数据”DOM 结构还是交给主线程表格组件去生成。3.3 序列化的代价postMessage 不是免费的别以为把数据处理丢给 Worker 就万事大吉了。主线程和 Worker 之间的数据传递需要结构化克隆简单说就是数据要“复制一份”穿过线程边界。1 万行对象数组全量拷贝一次大概也要几十毫秒。如果数据更大、对象嵌套更深这个开销会非常明显。我当时实测1 万行数据 postMessage 过去大约耗时 35ms处理完 postMessage 回来大约 25ms整体多花 60ms 左右但换来的是主线程从“卡死 3 秒”变成“稳定输出动画 60ms”。这个交换非常划算。如果数据量继续涨到 5 万、10 万行可以考虑给 Worker 传 ArrayBuffer 或 TypedArray它们可以通过 Transferable Objects 转移所有权不用复制内存。但前提是数据能用类型数组表示——表格这个场景里字段杂、带字符串对象硬拆成类型数组会让代码复杂很多。我的建议是2 万行以下用结构化克隆就行别过度优化真到 10 万行级别再考虑用 ArrayBuffer 做列式存储那通常是另一个量级的问题了。4. 给渲染任务让路时间切片与调度队列4.1 为什么一次性渲染 1 万行不行撕成片段就顺滑了说实话就算数据加工放进 Worker主线程最后还是要做一件事把几十 MB 的 JS 对象变成几千行真实 DOM。这是没法逃避的。问题只在于能否把它从“一个 3 秒的大任务”拆成“好多 20ms 的小任务”。浏览器的渲染机制决定了一个任务如果执行时间超过 50ms用户就会感觉到卡顿超过 100ms动画开始掉帧超过 300ms基本就是“死屏”。所以目标只有一个单次同步任务别超过 50ms理想值控制在 20 到 30ms 以内。方式其实很朴素。你可以把数据切成块每次在事件循环间隙渲染一小块渲染完一块就让出主线程浏览器趁这个间隙处理动画、响应点击、更新 loading 状态。就像一卡车货物你不该把它一股脑推进门里而是一箱一箱搬每一箱之间喘口气。4.2 基础方案requestIdleCallback 分批渲染我最早实现了一个最简单的切片渲染器核心逻辑就是把数据按指定块大小切分每渲染一块后使用 requestIdleCallback 调度剩下部分让浏览器在“空闲时段”继续。const CHUNK_SIZE 200; const tbody document.querySelector(#table-body); function renderChunk(list, startIndex) { const endIndex Math.min(startIndex CHUNK_SIZE, list.length); const fragment document.createDocumentFragment(); for (let i startIndex; i endIndex; i) { const tr document.createElement(tr); tr.innerHTML td${list[i].id}/td td${list[i].name}/td td${list[i].statusText}/td ; fragment.appendChild(tr); } tbody.appendChild(fragment); if (endIndex list.length) { requestIdleCallback(() renderChunk(list, endIndex), { timeout: 1000 }); } } // 启动 renderChunk(processedData, 0);这个方案在大多数浏览器里表现良好但我很快遇到了三个实际问题。第一个问题是 requestIdleCallback 的触发时机。页面如果一直有交互、有动画浏览器可能长时间认为“不空闲”导致渲染迟迟不继续。虽然 timeout 参数可以作为兜底但 timeout 单位是毫秒触发时任务执行频率可能很猛。第二个问题是兼容性一些 webview 环境不支持这个方法需要降级到 setTimeout。第三个问题最隐蔽如果用户滚动表格浏览器又要布局又要绘画而 requestIdleCallback 又插进来渲染新行两者会打架。所以后来我换成了更可控的 setTimeout 方案自己管理队列保证同一时间只有一批渲染任务在执行。4.3 更可控的方案自研一个简单的 renderTaskQueue核心思路是用 setTimeout 模拟任务调度既不用依赖 requestIdleCallback 的“空不空闲”判断又能把任务切得足够碎。每次只渲染一批然后等一帧大约 16ms再继续。为了不让用户看到表格“一行一行长出来”可以一次渲染 300 到 500 行间隔控制在 20ms 左右肉眼基本察觉不到渐次加载。class RenderTaskQueue { constructor(options {}) { this.chunkSize options.chunkSize || 400; this.interval options.interval || 20; this.queues []; this.isRunning false; } add(task) { this.queues.push(task); if (!this.isRunning) { this.next(); } } next() { if (this.queues.length 0) { this.isRunning false; return; } this.isRunning true; const task this.queues.shift(); task(); setTimeout(() this.next(), this.interval); } } const renderQueue new RenderTaskQueue({ chunkSize: 400, interval: 20 }); renderQueue.add(() { renderChunk(processedData, 0); }); renderQueue.add(() { renderChunk(processedData, 400); });用的时候要配合分片函数一起function renderDataInChunks(data) { const total data.length; for (let i 0; i total; i 400) { const start i; renderQueue.add(() { renderChunk(data, start); }); } }这套方案的好处是调度完全可控如果想优先渲染表格前 50 行首屏可视区域可以先把前 50 行单独切出来立即渲染剩下的慢慢排队。如果用户滚动到底部还可以动态调整后续批次的大小比如滚到底部时把 chunkSize 调大让“追数据”的速度赶上来。4.4 给 loading 的“最后一口气”先关闭遮罩再开工渲染这里有个非常值得说的经验。我最初是等数据全部渲染完才关闭 loading结果时间切片后总渲染时间还是有个一两秒loading 遮罩就这么干巴巴地转一两秒体验依然差。后来我调整了顺序数据加工完成 → 立即关闭 loading → 等浏览器绘制一帧requestAnimationFrame→ 开始切片渲染。为什么这一步重要因为 loading 遮罩本身是覆盖在整个页面上的它的存在会强制浏览器在渲染表格时先处理遮罩层的合成和隐藏动画。如果遮罩还在页面在视觉上会经历“遮罩消失 → 表格出现”的跳变如果先把遮罩藏掉再渲染数据用户看到的就是“loading 消失 → 表格区域从空白到逐渐出现”衔接自然很多。实际执行时有个细节不能直接把 hideLoading 和渲染放在同一个同步代码块里因为两者还是同一个任务。我的做法是hideLoading(); requestAnimationFrame(() { requestAnimationFrame(() { renderDataInChunks(processedData); }); });双重的 requestAnimationFrame 是为了确保 loading 的隐藏动画至少渲染了一帧遮罩已经从视觉上消失然后再去碰表格 DOM。我用 Performance 面板验证过这个双帧等待只增加 32ms 左右的延迟非常值得。5. 结构性解法虚拟滚动和分页加载才是治本方案5.1 什么时候必须上虚拟滚动讲了这么多切片渲染我必须坦诚地说一句切片渲染只是“治标”它让页面不卡了但如果你一定要渲染 10 万行真实 DOM再怎么切片总量还是那么大内存占用、滚动性能、筛选速度都不可避免地下滑。就拿我刚做的实验来说1 万行表格渲染完成后的 DOM 节点数约 13 万浏览器内存占用从 40MB 涨到 140MB。能渲染出来不代表这就是合理方案。如果数据量预期超过 5000 行且用户需要频繁滚动浏览我的建议是直接上虚拟滚动。核心原理很直接只渲染可视区域内的那一小部分行比如视口里能放下 30 行那就只渲染 30 行左右的 DOM上下各留两行缓冲。滚动时动态替换这些行里的数据而不是新建 DOM。这样不管数据是 1 万行还是 10 万行DOM 数量恒定渲染成本恒定滚动性能完全可控。我项目里有一张卡片式列表每张卡片内容丰富一次性渲染 3000 张卡也会卡但用了虚拟列表后实际 DOM 一直维持在 40 个左右滚动再快也没卡过。5.2 接口分页 前端增量加载能不做大渲染就不做除了虚拟滚动还有一个更“偷懒”但非常有效的做法请求时分页前端做成“加载更多”或滚动触底加载。这个方法特别适合管理后台那种“用户其实看不完所有数据”的场景。用户真正关心的只是前几十条与其硬灌一万条不如先给一页 200 条滚动到底了再拉下一页。我当时在另一个项目里实践过这种方案后端接口支持 size 和 page 参数前端表格默认拉 200 条滚动到底部时再拉下一页追加到表格尾部。这种情况下根本不需要全局 loading局部区域的加载指示器就够了。数据是慢慢长大的用户感知不到卡顿网络压力也小很多。唯一的代价是交互变复杂了需要处理“追加加载中”状态和“全部加载完”状态。那么三种方案怎么选我给自己定了一个表每次新需求来直接套用数据量 2000 行且不会持续增长常规渲染即可预处理放 Worker。2000 到 5000 行或需要全部加载后做前端搜索排序预处理放 Worker 时间切片渲染。5000 行以上且用户要滚动浏览直接虚拟滚动不要犹豫。只是“查一批看一批”数据天然分页接口分页 触底加载永远别一次性全量渲染。5.3 “数据加工重”和“DOM 渲染重”要分开评估我后来发现性能瓶颈往往不是单一来源而是数据加工和 DOM 渲染叠加。同样的 1 万行数据如果加工逻辑里有一个 O(n²) 的循环哪怕只有 1000 行数据也可能卡反过来如果数据加工很轻但 DOM 结构特别复杂比如每行都嵌套折叠面板1000 行也可能卡。所以方案选型之前先分别评估两个环节的耗时打开 Performance 面板录制一段脚本执行块看“脚本”时长里是纯数据逻辑占比高还是 DOM 操作占比高。如果纯数据逻辑占了一半以上优先上 Worker 把数据处理挪出去。如果 DOM 操作占了大头优先上虚拟滚动或分页切片只是临时补救。我见过一种很常见的错误做法数据加工只有 50ms却为了这 50ms 引入了特别复杂的 Worker 通信层结果光通信就花了 80ms得不偿失。所以别一股脑全上先测量再决定。6. 实测结果与优化后的那些“新坑”6.1 优化前后的指标对比这套组合拳打完之后我在同样的开发机上重新录了一遍 Performance数据如下优化前主线程最长 Long Task 约 3.8 秒loading 卡死期间点击事件全部积压用 Chrome 自带的 FPS 大盘显示页面帧率跌到接近 0。优化后主线程最长同步任务降到 45ms没有超过 50ms 阈值的卡顿loading 正常完成“旋转 → 淡出”动画点击响应恢复正常。表格数据从“请求返回”到“渲染完成”总耗时大约从 3.8 秒变成了 2.1 秒单看总时长并没有缩短太多但体验完全不一样了——用户眼里这是“流畅地逐渐出现”而不是“死机后突然弹出来”。其中 Worker 数据加工大约省了 300ms 主线程时间切片渲染把剩余的大任务切碎交互响应始终保持在用户可感知的流畅范围。6.2 切片渲染带来的新坑用户看到表格“一行一行长出来”优化不是没有代价的。切片渲染最直接的副作用是如果数据量特别大且切块粒度太粗用户会看到表格“像输液一样”一行一行滴进来视觉上不够从容。这个问题在数据量 5000 行时尤其明显。我的解决办法是组合优化首屏可见区域大约前 50 行立即渲染无延迟剩余数据的分批渲染间隙故意留得短一点比如 12ms 而不是 20ms让表格长得足够快几乎无法被肉眼察觉。另外渲染期间给表格容器加一个最小高度避免因为内容未填满导致滚动条跳来跳去这点特别重要——滚动条一蹦用户就知道下面还在“长”观感很差。6.3 requestIdleCallback 的兼容性和后台标签问题上文提过 requestIdleCallback 的一个坑是后台标签页不执行。用户切到其他标签页再回来时requestIdleCallback可能因为浏览器判定“页面不可见不需要渲染”而迟迟不触发。我实测在部分浏览器后台标签里数据渲染会被无限挂起直到用户切回页面才继续。这在小数据量场景不明显一旦到了要切分三四十批的时候用户切走再回来表格可能只渲染了一半非常尴尬。我最终选择的方案是兼容性降级到 setTimeout 事件循环判断自己控制批量推进。这样不依赖浏览器“空闲不空闲”的判断后台照样能推进只是渲染速率慢一点回来时通常已经渲染完了。6.4 防重复请求永远要做别指望齐 loading 遮罩层最后再强调一次全局 loading 遮罩是“视觉辅助”不是“交互安全网”。只要存在异步请求 慢渲染的窗口就存在用户重复点击的可能。你可以在遮罩上写几百行防穿透逻辑都不如直接在按钮和请求层做幂等控制来得可靠。我在查询按钮上的最终做法是点击后立即置灰请求期间重新赋值为“查询中”请求完成后如果是用户主动触发的恢复按钮如果上一次请求还没结束就收到新的触发直接忽略。配合一个单调递增的请求序号后发请求的响应如果序号小于当前请求一律丢弃防止旧响覆盖新数据。这套“三保险”做完之后我几乎再没收到过“数据错乱”“重复提交”之类的线上反馈。写到这里这个问题的解法和背后思路基本都说清楚了。核心无非是三层数据加工出主线程、渲染任务切成片、数据量太大就直接上结构性方案。如果以后再遇到类似页面卡顿我的习惯是先开 Performance 面板录一段看 Long Task 到底落在哪再决定是搬 Worker 还是做切片还是上虚拟滚动——永远用数据说话而不是凭感觉堆方案。