深入解析Long Task及其对前端性能的影响与优化策略
又出问题了,页面卡成PPT
今天上线前做最后的性能检查,Lighthouse一跑,直接给我整不会了——强制重排阻塞主线程 3.2 秒。啥概念?用户点个按钮,页面直接卡住三秒没反应,这谁顶得住。更离谱的是,在中低端安卓机上滑动都掉帧,手指都抬起来了,列表还在那儿慢半拍地回弹。
一开始以为是某个大组件渲染太重,拆了几个 useEffect、加了防抖节流都没用。后来打开 Chrome DevTools 的 Performance 面板录了一段,放大一看,好家伙,一条红色长条横贯整个时间轴,标着 Long Task,持续超过 100ms,甚至有接近 400ms 的。这玩意儿一旦出现,浏览器就没法响应用户输入、没法处理事件、连 RAF 都被拖住——页面等于“假死”。
排查过程:从怀疑人生到定位真凶
我先去查了哪些函数执行时间最长。按惯例翻 call tree,发现一个叫 processBatchedItems 的函数占了大头。这是我自己写的批量处理逻辑,用来把接口拉回来的一堆数据(大概 5000 条)逐条解析并生成 UI 元素。本来想着一次性搞完省事,结果直接在主线程上干了快四百毫秒……这里我踩了个坑:以为 JS 执行快,没考虑实际设备差异。高端机可能感觉不明显,但千元机直接裂开。
试过几种方案:
- 方案一:Promise.then 微任务拆分 —— 结果无效。微任务还是在同一 event loop 中连续执行,Long Task 判断是基于 task 而不是 microtask,所以根本没打断。
- 方案二:setTimeout 分片 —— 有效!但控制粒度麻烦,容易出现闪烁或进度跳跃。
- 方案三:requestIdleCallback —— 理想很丰满,现实很骨感。这 API 在移动端兼容性和调度优先级都不稳定,某些场景下根本不触发。
折腾了半天发现,最靠谱的反而是 requestAnimationFrame + queueMicrotask 组合拳。不过后来还是改成了 MessageChannel,因为它的 postMessage 能创建新的 task,完美符合 Long Task 拆分需求。
核心代码就这几行
最终方案是把原来的大块同步操作拆成每帧最多执行 16ms(约等于 60fps 下的帧预算),剩下的丢到下一个 task 去处理。这样既不会阻塞渲染,又能充分利用空闲时间完成工作。
下面是改造后的通用分片处理器:
const createTaskSplitter = (callback) => {
const channel = new MessageChannel();
let isRunning = false;
let tasks = [];
let batch = [];
// 每次消息触发一个新的 task
channel.port1.onmessage = () => {
const startTime = performance.now();
while (batch.length > 0 && performance.now() - startTime < 16) {
const item = batch.shift();
callback(item);
}
if (batch.length > 0) {
// 继续投递消息,形成链式调用
channel.port2.postMessage(null);
} else {
isRunning = false;
}
};
return (items) => {
batch = [...items]; // 浅拷贝避免外部修改影响
if (!isRunning) {
isRunning = true;
channel.port2.postMessage(null);
}
};
};
然后替换原来的暴力遍历:
// 原来的写法(Bad)
function processBatchedItems(items) {
items.forEach(item => {
// 各种计算、DOM 创建、事件绑定...
const el = document.createElement('div');
el.textContent = formatItem(item);
someContainer.appendChild(el);
});
}
// 改造后(Good)
const processBatchedItems = createTaskSplitter((item) => {
const el = document.createElement('div');
el.textContent = formatItem(item);
someContainer.appendChild(el);
});
注意这里不能直接传 document.createElement 这种 DOM 操作进去,因为每个 task 都要保证能在当前上下文执行。另外,如果你的操作涉及状态更新(比如 React setState),也要小心批次被打散导致频繁 rerender,可以考虑配合 useDeferredValue 或者手动 collect changes 再统一提交。
踩坑提醒:这三点一定注意
第一,别以为用了 async/await 就自动异步了。如果整个函数体都在一个同步调用链里,它照样会累积成一个 Long Task。必须主动插入异步断点。
第二,像 Array.prototype.sort() 这种方法,对大数据量来说也可能超时。之前有个排序 3000+ 对象的逻辑,V8 引擎内部实现是快速排序,最坏情况 O(n²),实测卡了 180ms。后来换成分块归并 + 异步 yield,才压到可接受范围。
第三,fetch 后的数据处理最容易被忽略。你以为请求完了就结束了?错,解析 JSON 字符串这一步才是重头戏。特别是当 response.data 是个巨长数组时,JSON.parse() 会同步阻塞主线程。后来我在 worker 里做 parse,主进程只接收结构化克隆后的对象,效果立竿见影。
// 在 Web Worker 中处理重型 JSON 解析
self.onmessage = function(e) {
const { rawJson, id } = e.data;
try {
const parsed = JSON.parse(rawJson);
self.postMessage({ id, result: parsed });
} catch (err) {
self.postMessage({ id, error: err.message });
}
};
主进程中这样用:
const worker = new Worker('/json-parser.js');
const safeParse = (raw) => {
return new Promise((resolve, reject) => {
const id = Date.now() + Math.random();
worker.onmessage = function(e) {
if (e.data.id === id) {
if (e.data.error) reject(new Error(e.data.error));
else resolve(e.data.result);
}
};
worker.postMessage({ id, rawJson: raw });
});
};
// 使用
fetch('https://jztheme.com/api/large-data')
.then(r => r.text())
.then(text => safeParse(text))
.then(data => {
// 此时 data 已准备好,且主线程未被阻塞
processBatchedItems(data);
});
改完之后还有一点小毛刺
虽然 Long Task 消失了,但因为 DOM 插入是分批进行的,视觉上有轻微的“渐现”效果。这个其实用户无感,反而觉得加载更流畅了。但如果追求完全一致的呈现节奏,可以在所有 task 完成后再一次性 attach 到容器上,比如先把元素缓存在 DocumentFragment 里。
还有一点没解决:Safari 对 MessageChannel 的调度比 Chrome 更保守,偶尔会出现处理速度跟不上预期的情况。目前暂时用 fallback 到 setTimeout(, 0) 应对,虽不完美但可用。
总体来看,这次优化让页面的最大连续阻塞时间从 3.2s 降到 47ms,Lighthouse 性能评分从 41 提升到 79。不算完美,但至少不会再被产品指着鼻子说“你这破页面卡得像老年机”了。
以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

暂无评论