先别急着上虚拟列表,也别一股脑把loading都换成骨架屏。直接说结论:只要你的页面同时出现“一个全局loading转圈”和“一次性拉回几千行数据”这两件事,主线程卡顿几乎是必然的,而且根源往往不在你最先怀疑的那个环节。
这不是我故弄玄虚。做了十多年前端,性能问题排查到最后,十有八九不是单一原因,而是“异步请求回调 + 全局状态联动 + 大量DOM一次性进场”这三件事叠加在一起,把主线程的时间片吃光了。我接下来要说的是我实际踩坑后沉淀下来的一套解决思路,覆盖请求层、状态层和渲染层,每一层都有对应代码和可量化的取舍逻辑,希望对正在被这类问题折磨的人有帮助。
1. 卡顿的本质:主线程时间片被谁吃掉了
先给基础偏薄的朋友补一段背景。浏览器里的JavaScript是单线程运行的,这个线程同时要做的事包括执行JS代码、解析DOM、计算样式、处理布局(Layout)、绘制(Paint),以及跑GPU合成和响应事件。一个页面能保持60帧流畅体验,意味着每一帧只有大约16.67毫秒的时间预算,凡是JS执行或渲染超过这个预算,肉眼就能感觉到掉帧,超过50毫秒甚至100毫秒,就会出现明显的“点着没反应”或“滚动像幻灯片”。
1.1 主线程时间片消耗的计算方式
排查卡顿不能靠感觉,先算账。假设计时器记录了整段流程的总耗时,实际卡顿时间 ≈ JS脚本执行时间 + 样式重算时间 + 布局时间 + 绘制时间,再加上垃圾回收(GC)停顿。其中前端团队能优化的,主要是JS执行和布局重算两个大头。
拿一个非常典型的场景举例:接口一次性返回5000条数据,每条数据格式化出6个展示字段,渲染在表格里。等数据到达回调里,光是把原始JSON字段映射成页面字段,再做时间格式化、状态枚举转换,就差不多要占用5~8毫秒每千条。这个算下来单次数据处理就是25~40毫秒,紧接着是创建真实DOM节点,5000行表格大概需要创建3万多个节点,每1000行的DOM创建加插入耗时稳定在40~60毫秒。两端相加,主线程被一次性占用的时间轻松超过300毫秒,这还不包括框架虚拟DOM的diff和响应式依赖触发的额外工作。体验上就是:点击查询后loading转了几圈,等网络返回,画面一卡,再突然全量出现。
1.2 异步请求本身不是瓶颈,回调才是
很多人觉得http请求慢,其实是错觉。真正的网络传输本身就发生在浏览器内核的非主线程里,XHR和fetch并不会占用主线程去等待数据。卡顿点全部集中在请求完成之后:
- 回调函数里的数据处理与状态更新
- 响应式系统对依赖项的收集与触发重新渲染
- 渲染层一次性创建和插入大量DOM
所以在整体方案里,请求层可以做的不是让网络更快,而是尽量让“回调引发的计算和渲染”被拆散、被转移、被降级。
1.3 全局loading的隐蔽陷阱
前面说到的三件事里,全局loading是最容易被低估的一环。很多团队把loading状态放在一个全局响应式store里,页面组件通过useSelector或者computed去订阅它,这样当loading从true变成false时,所有订阅了这个状态的组件同步重新求值,这个动作本身也是主线程开销。更要命的是,如果loading状态又通过对象形式管理,比如一个loadingMap里同时管理十多个请求的状态,每次setState都触发一次对象遍历和依赖派发,叠加请求并发时,主线程还没开始渲染数据就先空转了一波。
2. 全局 loading 管理的优化:把状态联动压到最小
我的第一个切入点是先治loading。不是把loading去掉,而是让它的副作用最小化。
2.1 拆分 loading 粒度:全屏遮罩和局部状态分开
全局loading和局部loading各自承担的任务完全不同。全屏遮罩用于“页面初始化数据加载”或者“关键操作完成后需要整页刷新”的场景,局部loading用于“页面内某个区块独立加载”的场景。很多项目混乱的根源,是把一个本应局部的loading放进了全局状态,导致整个页面跟着重渲染。
优化手段很简单:局部loading直接放在组件内部,不放进全局store;只有页面级的加载状态才走全局。组件内部loading建议用独立的布尔状态,或者用一个很小的状态库(比如单一store里的一个字段)而不是大对象。这样loading变化的触发范围只有本组件自己,依赖收集的点和派发的点都小了一个数量级。
2.2 用计数器加防抖控制全局loading
如果把所有请求都交给一个全局计数器来管,就会出现一个高频场景:请求A和请求B并发,A先返回,B还在等,计数减到零的瞬间loading关闭,可B还没回来,用户看到页面缺一块。解决方式我试过几种,最终使用的是“计数加最短展示时长”策略:
- 每个全局请求发起时计数器加一,完成时减一
- 计数器从0变到1时,记录当前时间
- 只有当计数器归零,并且距离开启时间超过300毫秒,才真正关闭loading
这样既避免了loading一闪而过,也保证了页面在关键数据未就绪前不会被过早展示。如果业务里存在高频轮询接口,还应该把轮询请求单独分组,不参与全局loading计数,否则页面会一直顶着遮罩转圈。
2.3 重复请求合并与竞态控制
全局loading只解决了状态展示,请求层另外还有两个隐蔽问题:同一时间点多个组件发起相同参数的请求,以及用户快速切换查询条件导致旧请求晚于新请求返回。
重复请求的解决方案直接在请求层拦截。我通常在封装的请求函数里维护一个Map,key由请求的baseURL+接口路径+参数序列化值拼成,如果短时间内(比如1秒内)已经存在相同请求,就直接复用那个pending Promise,而不是真正再发一次。顺带在实例销毁时catch住回调,阻止未完成的请求进入then。竞态控制的方案是用请求序号或者AbortController。个人更推荐AbortController,因为它在数据流量层面把旧请求真正取消,而不是仅仅忽略响应。
一段简化示意代码:
const pendingMap = new Map(); function requestWithMerge(config) { const key = [ config.url, config.method || 'get', JSON.stringify(config.params || config.data || '') ].join('&'); if (pendingMap.has(key)) { return pendingMap.get(key).promise; } const abortController = new AbortController(); const promise = axios({ ...config, signal: abortController.signal }).then(res => res.data); pendingMap.set(key, { promise, controller: abortController }); promise.finally(() => { pendingMap.delete(key); }); return promise; }提示:AbortController在取消请求时会抛出CanceledError,建议统一在拦截器里过滤这类异常,别把取消误判为网络错误抛给业务层。
3. 大数据渲染的三条出路:分片、虚拟化、异步化
loading只是状态层的优化,真正的主力战场是大数据渲染。渲染大数据的思路归结下来无非三条路:把时间分片、只渲染可见区域、把不依赖DOM的活转移到后台线程。我按优先级来说。
3.1 分片渲染:先用时间片保住响应能力
分片渲染适用于“数据总量在几千条到一两万条、DOM节点不太复杂”的场景。核心思想是:不在一个事件循环里创建所有DOM,而把数据切成长度较小的批,每批在requestAnimationFrame之间让出主线程,给浏览器留出处理交互和重绘的时间。
一个基础实现思路:
function renderChunked(list, container, chunkSize = 50, callback) { let index = 0; function runBatch() { const nextIndex = Math.min(index + chunkSize, list.length); for (let i = index; i < nextIndex; i++) { const item = list[i]; const node = document.createElement('div'); node.textContent = item.label; container.appendChild(node); } index = nextIndex; if (index < list.length) { requestAnimationFrame(runBatch); } else if (typeof callback === 'function') { callback(); } } requestAnimationFrame(runBatch); }这段代码里,chunkSize的选择有讲究。设太大,一批处理太久还是卡;设太小,总批次数过多,帧与帧之间反而产生大量间隙导致渲染反而更慢。经验值供参考:普通文本节点每批50~80个,有复杂内嵌样式的节点每批30个左右。这里的“一批”根据实际硬件可以调节,运行中利用performance.now()测量上一批耗时,动态调整当前批次大小,是更稳的做法。
分片渲染的代价是什么?用户看到的是分段逐条出现,如果批量过大或每批之间间隔过长,视觉上会有明显的闪现感。所以这个方案更推荐用在一开始先铺首屏,再慢慢补全剩余部分的场景。
3.2 虚拟列表:只把DOM给看得见的区域
当数据量大到几万甚至几十万条时,分片渲染也扛不住,因为最终创建的节点数量没有减少。虚拟列表才是正解。它的逻辑可以粗解为:无论总数据量多大,用户实际能看到的视口区域就那么大,只创建视口内以及上下缓冲区的元素,同时给滚动容器一个撑高的占位元素来模拟完整滚动条。
实现虚拟列表有两个坑我要重点提醒:
- 固定行高意味着可以简单计算起始索引和终止索引,一旦内容高度可变(比如富文本、多行文本),就需要测量和缓存每行高度,复杂度直接上一个台阶。
- 滚动容器要用单一滚动条,不要让分页块嵌套各自滚动,否则原生滚动惯性会丢失,操作体验变差。
如果一个组件库自带的虚拟表格不满足业务需求,我不建议自己从零造轮子,追求太强的定制性往往得不偿失。先在库的基础上把数据形态调整好,比如把每一行的渲染成本降到最低,定死高度,再考虑动态高度。
3.3 把计算转移到后台线程
数据处理中有一个容易忽略的性能盲区:请求到的原始数据可能是10000行平铺结构,但页面需要的是树形结构、层级聚合或复杂排序。这类计算如果在主线程跑,每次数据刷新都要卡顿一次。把这类纯计算的步骤放到Web Worker,主线程只负责接收结果并提交给渲染层,是一个高性价比的方法。
上代码:
// worker.js self.onmessage = function (e) { const { rawData, sortRule, filterRule } = e.data; const filtered = rawData.filter(filterRule); const sorted = filtered.slice().sort(sortRule); self.postMessage(sorted); }; // 主线程 const worker = new Worker('./worker.js'); worker.postMessage({ rawData: response.data, sortRule: (a, b) => a.createTime - b.createTime, filterRule: item => item.status !== 'deleted' }); worker.onmessage = (e) => { setRenderData(e.data); };需要留意的是结构化克隆的限制。postMessage可以传递可序列化数据,但如果你的数据里有Date对象、Map、Set、TypedArray,克隆和传递时会有额外的转换开销。数据量特别大的时候,建议在请求响应层就把数据序列化成纯对象格式再post到worker,减少克隆耗时。
3.4 骨架屏与渐进式展示的价值
很多卡顿场景发生在loading结束后的瞬间:loading遮罩一关,立刻全量渲染。为什么不做成“先渲染骨架屏或首屏内容,再异步补全剩余”?这不只是视觉效果的问题,更是性能策略。用户第一眼看到的是页面结构,而不是空白,加上分片渲染,会显著降低“长时间无反馈”的感觉。
在设计接口时,可以让首屏请求和后通胀请求分离。比如列表页先请求当前页20条,第二屏再按需加载更多,这种“缩小单次渲染规模”的思路在数据量大的中后台系统里往往是最容易落地、也最容易被低估收益的方案。
4. 一套可落地的完整组合方案
前面讲的是单体策略。下面说说我实际在一个模拟项目中整合起来的完整流程:请求层做好去重取消和优先级,数据层做好结构和尺寸瘦身,渲染层按需求从容选择分片或虚拟化。
4.1 请求层的统一封装
封装的核心目标是:统一loading投递、统一请求去重、统一竞态取消、统一错误处理。这里给出一个带优先级的请求队列设计思路:
- 页面初始加载的请求并入第一批次,loading为全屏遮罩模式。
- 下拉刷新或滚动加载的请求并入交互批次,使用局部loading,不遮挡全屏。
- 轮询请求独立分组,不参与全局loading计数,每小时甚至每几分钟的轮询请求,加防抖和失败退避。
封装代码里我习惯把请求队列定义为一个数组,每个元素带有priority字段,发送前按优先级排序,再配合并发上限控制(比如同时最多触发6个请求)。这么做的原因很直接:避免几十个请求同时back,主线程一瞬间要被几十个回调冲击。
4.2 数据处理层的瘦身和结构优化
请求到的数据不一定都要交给前端处理。在接口层可以做几步瘦身:
- 服务端只返回展示需要的字段,聚合统计和排序在服务端完成。
- 前端只做字段映射,不做重排序。
- 如果必须前端排序,数据量超过5000条时,明确走worker方案。
另外,注意JSON中字段名的冗余。上千条数据每条携带几十个字段,解析到JSON对象的内存和时间都不小。后端如果能把某些高频字段名压缩成短key,响应体体积能降低10%到30%,前端遍历字段的字符串匹配开销也能跟着降。
4.3 渲染层按场景选择载体
给一个我自己的选型表,供直接参考:
| 数据场景 | 推荐方案 | 不推荐的原因 |
|---|---|---|
| 100条以内 | 直接渲染 | 无必要优化 |
| 1000条以内,表格 | 分页或分片渲染 | 虚拟列表收益低 |
| 1000~10000条,长列表 | 虚拟列表 | 全量渲染节点过多 |
| 10000条以上,需要排序/过滤 | worker + 虚拟列表 | 主线程计算吃不消 |
| 高频率更新的列表 | 优先虚拟化 + 请求防抖 | 渲染频率高于帧率没意义 |
台面上的方案选型一定要结合真实数据规模和交互频率,不要为了“炫技”引入复杂度。渲染10条数据的页面硬套虚拟列表,反而容易因为滚动监听和计算逻辑降低稳定性。
4.4 性能监控和回归预防
方案上线后需要验证,更要有防退化的手段。推荐两种轻量级监控:
一种是Performance Observer监听Long Task。长任务即主线程持续占用超过50毫秒的任务,能直接量化卡顿时间:
const observer = new PerformanceObserver((list) => { const entries = list.getEntries(); for (const entry of entries) { // 上报到监控系统,附带entry.duration值 console.warn('Long Task:', entry.duration); } }); observer.observe({ entryTypes: ['longtask'] });另一种是启动帧率统计。在页面主循环里用requestAnimationFrame打点,计算每秒的帧数,低于45帧的区间自动上报。这个方案会多消耗少量性能,通常在beta和灰度环境开启,正式环境关闭。
5. 实测排查典型问题与避坑指南
单独把踩过的几个典型问题拿出来说。这些坑不是业务逻辑写错了,而是方案边界没把握好。
5.1 现象一:loading关闭后页面白屏闪烁几秒
原因:loading关闭和全量渲染被安排在同一个宏任务里,关闭遮罩后紧接着创建了两万个DOM节点,主线程被占用期间,页面绘制不出新内容,看起来就是白屏闪动。
排查思路:用Performance面板看长任务的时间线,基本能定位到数据回调后的那一段超过几百毫秒的任务。优化方式是把loading关闭这个动作放到requestAnimationFrame回调里,然后把渲染任务切成多个批次,保证“遮罩关闭”和“可见内容出现”优先发生。
5.2 现象二:并发请求时loading闪烁或怎么都关不掉
原因:计数器增减逻辑没处理好,出现归零后又加一的抖动,或者某个请求被异常吞掉,finally没有正确执行。还有一类隐藏原因是请求失败也被当成完成,但错误处理时没有把计数减掉。
排查方法:在计数器增减位置打点日志,检查请求生命周期里promise的resolve和reject是否都走到了finally。给请求增加超时保护,默认5秒没有返回就判定失败并降级提示,别让一个异常请求把loading拖死。
5.3 现象三:虚拟列表滚动时频繁出现空白块
原因:渲染窗口的缓冲设置太小,滚动速度又高,帧率跟不上时新视口的元素来不及创建。还有可能是行高固定值设置错误,导致滚动距离计算和实际内容高度偏差,定位位置不准确。
解决方法:一是调大上下缓冲区,常见做法是把缓冲区设为一屏高度的1.5到2倍。二是动态测量行高时,要给未测量的条目一个初始估计值,滚动稳定后再用真实高度修正。三是滚动手势期间不进行数据请求,等滚动停止几十毫秒后再请求下一页,减少滚动中的网络回调冲击。
5.4 现象四:数据量不算大但页面就是卡
排查后经常发现卡顿不在渲染,而在响应式更新。某个组件监听了整个列表的状态对象,列表里每一条数据的字段变化都会触发整个组件重新渲染。解法是这个数据的依赖收集要下沉,只让真正依赖的组件收到通知。常见手段是用selector库按字段粒度订阅,比如只订阅列表长度和更新版本号,而不是订阅整个数组对象。
6. 优化之外的一些体会
聊几个方案之外的判断。不是所有页面都值得做全套优化,如果数据结构简单、数据量常年不超过200条,那最正确的做法是什么都不做。优化本身也是成本,虚拟列表的滚动监听的复杂度、worker的传输数据序列化费时、分片渲染的视觉闪烁和逻辑复杂度,都会让后期的维护成本上升。如果一个页面的数据量和用户点击频度确实不高,把这些时间花在业务逻辑的可维护性上更划算。
反过来,如果你的页面确实卡到影响操作了,优先做两件事:一是把大数据渲染任务从主线程挪走,不管是分批还是虚拟化;二是把全局loading的粒度降下来,别让一个页面级遮罩跟着所有小请求抖动。做完这两步,大部分卡顿场景能缓解百分之七八十。剩下那百分之二三十,再去考虑更细的帧率优化和图表降采样。
最后一个小技巧分享一下:任何性能优化方案上线前,先让后端配合提供一个数据量翻三倍的mock接口。把数据量放到你预期的峰值两到三倍来测,这样不是说让线上服务真的承受那么大压力,而是为了留足优化余量。很多方案在标准数据量下看不出问题,一翻倍就原形毕露。这个测试习惯,帮我提前处理过不少隐患。