4、JavaScript 执行优化:减少长任务、拆分同步代码
页面切换卡顿,很多时候不是渲染慢,而是 JS 把主线程占死了。
我见过不少项目,一个点击事件触发了几百毫秒的计算,用户点完按钮页面直接“冻住”。说白了,浏览器的主线程既要跑 JS,又要处理布局、绘制、响应交互。你 JS 一直占着不放,页面自然就卡了。
这一章,我们就来聊聊怎么把那些“又长又臭”的同步任务拆开、打散、甚至扔到后台去。
4.1 长任务:卡顿的元凶
浏览器把超过 50ms 的 JS 执行称为“长任务”(Long Task)。为什么是 50ms?因为 60fps 的渲染间隔大约是 16.6ms,但浏览器需要留出余量来处理输入和绘制。一个任务超过 50ms,用户就能感知到卡顿。
我在项目中遇到过这样的情况:一个表格组件,渲染前要对 10000 条数据做复杂格式化。直接写个 for 循环,跑下来 200ms。用户点切换页面,白屏 200ms 才出来。这就是典型的长任务。
4.2 拆分同步代码的三种武器
要解决长任务,思路很简单:把一个大任务切成若干小片,每片执行时间控制在 10-20ms,片与片之间让浏览器有机会处理其他事情。
常用的有三种方式:setTimeout、requestIdleCallback、Web Workers。它们各有各的适用场景。
| 方法 | 执行时机 | 是否阻塞主线程 | 适用场景 |
|---|---|---|---|
| setTimeout(fn, 0) | 宏任务队列,尽快执行 | 是(但分片后不连续阻塞) | 拆分循环、分批渲染 |
| requestIdleCallback | 浏览器空闲时段 | 是(但只在空闲时执行) | 低优先级任务、后台分析 |
| Web Workers | 独立线程,并行执行 | 否 | CPU 密集型计算、数据处理 |
4.3 setTimeout:最朴素的拆分方案
setTimeout 的原理很简单:把一段代码放到下一个宏任务里执行。这样当前任务结束后,浏览器可以先处理渲染和交互,再回来执行下一段。
我习惯用它来拆分大数据量的循环。比如上面那个 10000 条数据的格式化,可以这样拆:
function processChunk(data, index, chunkSize = 100) {
const end = Math.min(index + chunkSize, data.length);
for (let i = index; i < end; i++) {
// 对 data[i] 做格式化
data[i].formatted = formatItem(data[i]);
}
if (end < data.length) {
// 还有剩余数据,放到下一个宏任务
setTimeout(() => processChunk(data, end, chunkSize), 0);
} else {
// 全部完成,触发渲染
renderTable(data);
}
}
// 启动
processChunk(rawData, 0, 100);
但要注意,setTimeout 有 4ms 的最小延迟(嵌套深度超过 5 层时)。如果你需要更精细的控制,可以考虑 requestIdleCallback。
4.4 requestIdleCallback:利用浏览器的“空闲时间”
requestIdleCallback 会在浏览器完成所有高优先级任务(渲染、交互、动画)后,有空闲时间才执行你的回调。它非常适合处理那些“不着急,但早晚要做”的任务。
我曾经在一个后台管理系统里,用 requestIdleCallback 来做埋点数据的批量上报。用户操作完,页面先响应,等空闲了再慢慢发请求。
function scheduleIdleTask(task, options = {timeout: 2000}) {
requestIdleCallback((deadline) => {
// deadline.timeRemaining() 返回当前帧剩余时间(ms)
while (deadline.timeRemaining() > 5 && task.hasNext()) {
task.processNext();
}
if (task.hasNext()) {
// 没做完,下一帧继续
scheduleIdleTask(task, options);
}
}, options);
}
// 使用示例
const task = {
data: largeArray,
index: 0,
hasNext() { return this.index < this.data.length; },
processNext() {
// 处理一条数据
this.index++;
}
};
scheduleIdleTask(task);
4.5 Web Workers:真正的“后台线程”
前面两种方法虽然能拆分任务,但本质上还是在主线程上跑。如果计算量特别大(比如图像处理、数据排序、加密解密),主线程还是会被拖慢。
Web Workers 就不一样了。它在一个独立的线程里运行,完全不阻塞主线程。主线程只管发消息、收结果。
我参与过一个报表项目,用户筛选条件后要对 50 万条数据做聚合计算。直接在主线程算,页面卡死 3 秒。用 Web Worker 后,主线程只负责显示 loading,计算完再更新视图,体验好了很多。
// worker.js
self.onmessage = function(e) {
const { data, config } = e.data;
// 执行复杂计算
const result = heavyComputation(data, config);
// 把结果发回主线程
self.postMessage(result);
};
// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ data: largeDataSet, config: options });
worker.onmessage = function(e) {
// 拿到结果,更新 UI
updateChart(e.data);
worker.terminate(); // 用完就关掉
};
4.6 如何选择?一张图看懂
下面这张图总结了三种方案的决策流程,你可以根据任务类型和紧急程度来选。
4.7 实战中的组合策略
在实际项目中,我很少只用一种方法。通常是这样组合的:
- UI 交互触发的计算(比如搜索过滤):用 setTimeout 分片,保证输入框能及时响应。
- 后台数据预处理(比如日志分析、数据聚合):用 requestIdleCallback,不抢用户操作的时间。
- 重度计算(比如图像处理、大量数据排序):直接上 Web Worker,彻底不占主线程。
scheduler 工具函数,自动判断当前环境支持哪种方案,降级处理。比如优先用 requestIdleCallback,不支持就用 setTimeout,再不行就同步执行(但会给出警告)。
4.8 测量与验证
优化做完了,怎么知道有没有效果?
我推荐用 Chrome DevTools 的 Performance 面板。录制一段操作,看 Main 线程的火焰图。如果之前有红色的长任务标记,优化后红色块消失了,变成一个个绿色的小短条,那就说明拆成功了。
另外,也可以看 Total Blocking Time (TBT) 这个指标。TBT 是 FCP 到 TTI 之间所有长任务的阻塞时间总和。优化后 TBT 应该明显下降。
嗯,这一章的内容就到这里。记住一个核心:别让 JS 独占主线程超过 50ms。拆开它,让浏览器喘口气,用户的手感自然就顺了。