4、JavaScript执行瓶颈:长任务(Long Task)对页面切换的影响

页面切换卡顿,很多时候不是渲染的锅,而是JS在背后「占着茅坑不拉屎」。说白了,浏览器的主线程一次只能干一件事。如果你的JS代码一口气执行了超过50毫秒,浏览器就没法去处理下一帧的渲染,页面切换自然就卡住了。

我刚开始做性能优化时,总觉得「不就几行JS吗,能有多慢?」直到有一次,一个列表页切换到详情页,白屏了将近一秒钟。查了半天,发现是一个JSON解析加数据处理的循环,把主线程堵死了。嗯,从那以后,我再也不敢小看任何一段JS。

4.1 什么是长任务(Long Task)?

长任务的定义其实很简单:任何在主线程上执行时间超过50毫秒的任务,都算长任务。为什么是50毫秒?因为浏览器需要保证在50ms内响应用户输入,才能让用户感觉「流畅」。超过这个阈值,用户就会觉得卡。

核心指标:Long Task 阈值 = 50ms。超过即视为阻塞用户交互的「罪魁祸首」。

你可以用 Performance Observer 来捕获长任务:

const observer = new PerformanceObserver((list) => {
  list.getEntries().forEach((entry) => {
    console.log(`长任务耗时:${entry.duration}ms`);
    console.log(`开始时间:${entry.startTime}ms`);
  });
});
observer.observe({ entryTypes: ['longtask'] });

我个人习惯在开发环境就打开这个监听,提前发现潜在问题。别等到线上用户投诉了再查,那时候成本就高了。

4.2 长任务如何影响页面切换?

页面切换的典型流程是这样的:

  1. 用户点击链接或按钮
  2. 浏览器触发 click 事件
  3. 执行路由切换逻辑(JS)
  4. 请求新页面数据(网络 + JS 处理)
  5. 渲染新页面(布局 + 绘制)

如果第3步或第4步里,有一段长任务卡住了主线程,那第5步就得排队等着。用户看到的,就是点击后没反应,过了几百毫秒才突然跳转——也就是我们常说的「点击无反馈」或「跳转延迟」。

注意:长任务不仅影响切换的「开始」,还会影响切换的「结束」。如果新页面的初始化JS也是长任务,那页面即使跳过去了,也会白屏很久。

4.3 长任务的常见来源

我在项目中遇到过最多的长任务来源,基本就这几类:

来源类型 典型场景 影响程度
大数据循环/遍历 处理数千条列表数据、深拷贝大对象 高
复杂计算 加密、压缩、坐标转换、排序 高
DOM 操作 批量插入节点、频繁重排 中
JSON 解析 解析超大的 API 响应体 中
同步网络请求 XMLHttpRequest 同步模式(几乎绝迹但仍有) 极高

你想想看,一个页面切换时,如果同时触发数据请求、解析、渲染,再加上一些第三方SDK的初始化,很容易就堆出一个几百毫秒的长任务。

4.4 如何识别和定位长任务?

光知道概念不够,得能抓到它。我常用的方法有三个:

4.4.1 使用 Performance 面板

打开 Chrome DevTools → Performance 面板,点击录制,然后触发页面切换。看火焰图里有没有超过50ms的「长条」。如果有,点进去看是哪段代码。

4.4.2 使用 Long Tasks API

前面给的代码示例,可以直接在控制台跑。它会实时打印出所有长任务,包括耗时和开始时间。

4.4.3 使用 Web Vitals 库

Google 的 web-vitals 库可以帮你收集 TTI(交互时间)和 FID(首次输入延迟),这两个指标跟长任务直接相关。

import { onFID, onTTI } from 'web-vitals';

onFID(console.log);
onTTI(console.log);

小技巧:我习惯在开发环境把 Long Tasks API 的日志输出到页面上一个浮动面板里,这样每次操作都能看到有没有「漏网之鱼」。

4.5 长任务对页面切换的「连锁反应」

长任务不只是让当前切换变慢,它还会引发一系列连锁问题。我画了一张图,帮你理清这个关系:

长任务对页面切换的连锁影响 长任务对页面切换的连锁影响 长任务(>50ms) 主线程被占用,无法处理下一帧 点击无反馈 跳转延迟 白屏时间变长 用户重复点击 路由堆栈异常 FID 指标恶化 用户体验严重下降 一个长任务可能引发多个连锁问题,最终导致用户流失

你看,一个长任务就像多米诺骨牌的第一张。它不只是让当前操作变慢,还会引发用户重复点击、路由状态错乱,甚至导致整个页面的 FID 指标飘红。

4.6 解决长任务的思路

知道了问题在哪,接下来就是怎么治。我总结了四个方向:

4.6.1 拆分任务

把一个大循环拆成多个小任务,用 setTimeout 或 requestIdleCallback 分散执行。

// 坏写法:一次性处理 10000 条数据
data.forEach(item => process(item));

// 好写法:分批处理,每批 100 条
function processInBatches(data, batchSize = 100) {
  let index = 0;
  function nextBatch() {
    const batch = data.slice(index, index + batchSize);
    batch.forEach(process);
    index += batchSize;
    if (index < data.length) {
      setTimeout(nextBatch, 0); // 让出主线程
    }
  }
  nextBatch();
}

4.6.2 使用 Web Worker

把纯计算任务扔到 Worker 线程里,完全不阻塞主线程。我曾在项目中用 Worker 处理一个 5MB 的 JSON 解析,主线程全程无感。

// main.js
const worker = new Worker('worker.js');
worker.postMessage(largeData);
worker.onmessage = (e) => {
  console.log('处理完成', e.data);
};

// worker.js
self.onmessage = (e) => {
  const result = heavyProcess(e.data);
  self.postMessage(result);
};

4.6.3 延迟非关键任务

页面切换时,优先保证渲染。那些统计上报、日志记录、预加载等非关键任务,可以放到 requestIdleCallback 里执行。

requestIdleCallback(() => {
  // 在浏览器空闲时执行
  sendAnalytics();
  logUserBehavior();
}, { timeout: 2000 });

4.6.4 使用 Scheduler API(实验性)

Chrome 95+ 支持 scheduler.postTask(),可以给任务设置优先级。我个人觉得这个 API 未来会很有用,但目前兼容性还不够,生产环境慎用。

// 高优先级任务
scheduler.postTask(() => renderPage(), { priority: 'user-blocking' });

// 低优先级任务
scheduler.postTask(() => preloadImages(), { priority: 'background' });

避坑指南:我曾经在项目里用 setTimeout 拆分任务,但没控制好间隔,导致任务堆积反而更慢。后来加了一个「任务队列长度」的监控,才把问题解决。记住:拆分不是目的,让主线程有空闲才是。

4.7 总结一下

长任务对页面切换的影响,说白了就是「占着主线程不放」。你想想看,用户点击了,浏览器想渲染下一帧,但主线程还在跑你的JS,那用户就只能等着。

解决思路其实不复杂:能拆就拆,能异步就异步,能扔 Worker 就扔 Worker。关键是你要有意识地去发现它、测量它。我建议你在每个项目的性能优化清单里,都加上一条:「检查页面切换路径上是否有长任务」。

嗯,这一章就到这里。记住:50ms 是红线,跨过去就是卡顿。