深入解析Long Task及其对前端性能的影响与优化策略

宇文小菊 优化 阅读 1,912
赞 13 收藏
二维码
手机扫码查看
反馈

又出问题了,页面卡成PPT

今天上线前做最后的性能检查,Lighthouse一跑,直接给我整不会了——强制重排阻塞主线程 3.2 秒。啥概念?用户点个按钮,页面直接卡住三秒没反应,这谁顶得住。更离谱的是,在中低端安卓机上滑动都掉帧,手指都抬起来了,列表还在那儿慢半拍地回弹。

深入解析Long Task及其对前端性能的影响与优化策略

一开始以为是某个大组件渲染太重,拆了几个 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。不算完美,但至少不会再被产品指着鼻子说“你这破页面卡得像老年机”了。

以上是我踩坑后的总结,如果你有更好的方案欢迎评论区交流。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论