彻底搞懂JavaScript事件循环机制与实战应用

IT人之芳 前端 阅读 1,259
赞 20 收藏
二维码
手机扫码查看
反馈

优化前:卡得不行

我接手这个项目的时候,页面交互已经烂到没法看了。一个简单的列表页,滑动一下能掉帧,点击按钮要等半秒才有反应。用户反馈最多的就是“点不动”“卡死了”。我自己测了一下,长列表滚动时 FPS 直接掉到 20 多,主线程被占得死死的,几乎没空闲时间。

彻底搞懂JavaScript事件循环机制与实战应用

最离谱的是有个数据实时更新的功能,每秒要处理几百条消息,用的还是 setInterval 往 DOM 上怼内容。结果就是页面每隔几秒就卡一下,用户体验差到想砸键盘。这种问题光靠“优化 CSS 动画”“减少重排”根本没用——根子在事件循环里堵死了。

找到瓶颈了!

我先上了 Chrome DevTools 的 Performance 面板跑了一段操作录屏。一看 timeline 就明白了:一大坨黄色的 Scripting 占满了主线程,中间几乎没有空隙。Task Duration 经常超过 100ms,最长的一次直接干到了 400ms+,这哪是网页,这是幻灯片。

接着我用 performance.mark() 在关键逻辑前后打点,发现数据解析和 DOM 更新这一块耗时特别夸张。比如收到一批新数据后,会同步执行过滤、计算、生成 HTML 字符串、插入节点……一连串操作全塞在一个调用栈里,完全阻塞了 UI 线程。

这时候我就意识到:不是代码写得烂(虽然也挺烂),而是事件循环的调度方式出了问题。JavaScript 是单线程的,所有任务都在一个队列里排队,前面不走完,后面的交互事件、渲染帧统统等着。必须把大任务拆开,让出执行权给浏览器做渲染。

试了几种方案

第一反应是改用 setTimeout 拆任务。简单粗暴:

function processLargeArray(arr, callback) {
  const chunkSize = 10;
  let index = 0;

  function processChunk() {
    const end = Math.min(index + chunkSize, arr.length);
    for (let i = index; i < end; i++) {
      // 处理单个元素
      doExpensiveWork(arr[i]);
    }
    index = end;

    if (index < arr.length) {
      setTimeout(processChunk, 0); // 让出控制权
    } else {
      callback();
    }
  }

  processChunk();
}

效果有,但不够稳。setTimeout(fn, 0) 实际延迟可能高达 4ms 甚至更多,而且它进的是宏任务队列,优先级低,有时候动画帧都错过了才执行下一 chunk。我看着 FPS 波动还是大,知道这不是最优解。

然后我试了 requestAnimationFrame,但它只适合动画相关的更新,不适合后台数据处理。你总不能让用户停下来才能继续处理数据吧?

最后盯上了 queueMicrotask。这玩意儿进的是微任务队列,在每次事件循环末尾立刻执行,比 setTimeout 更及时,又不会像同步代码那样一口气跑死。亲测有效。

核心优化:用 queueMicrotask 拆分任务

我把原来那个暴力更新的函数彻底重构了。原本是这样:

// 优化前:一把梭
function handleIncomingData(dataList) {
  const htmlStrings = dataList.map(item => renderToHTML(item));
  container.innerHTML += htmlStrings.join('');
  updateStats(); // 更新统计信息
}

现在改成渐进式处理:

function createAsyncProcessor(items, processor, batchSize = 5) {
  let index = 0;

  return new Promise((resolve) => {
    function processNext() {
      const start = index;
      const end = Math.min(index + batchSize, items.length);

      if (start >= items.length) {
        resolve();
        return;
      }

      // 同步处理一小块
      for (let i = start; i < end; i++) {
        processor(items[i]);
      }
      index = end;

      // 关键:用 queueMicrotask 接续
      queueMicrotask(processNext);
    }

    processNext(); // 立即启动第一块
  });
}

// 使用示例
async function handleIncomingData(dataList) {
  await createAsyncProcessor(
    dataList,
    (item) => {
      const el = document.createElement('div');
      el.innerHTML = renderToHTML(item);
      container.appendChild(el);
    },
    8 // 每批处理8条
  );
  updateStats();
}

这里注意我踩过好几次坑:一开始用了 Promise.resolve().then() 来模拟微任务调度,结果和其他 Promise 回调混在一起,顺序乱了。换成 queueMicrotask 之后才真正可控。MDN 文档说它是专门用来“异步调度微任务”的,没错,就是干这个的。

还有一点要注意:batchSize 不能太小也不能太大。我试了 1、3、5、8、16 几个值,最终选了 8。太小会导致调度开销占比高;太大又容易造成单次占用时间过长。实测下来 8 是个平衡点,既能保证流畅,又不至于频繁中断。

更狠的招:结合 requestIdleCallback 做后台处理

对于非紧急的数据处理,比如日志上报、缓存预加载这些,我加了 requestIdleCallback 包一层:

function scheduleBackgroundTask(task) {
  if ('requestIdleCallback' in window) {
    requestIdleCallback(() => task(), { timeout: 2000 });
  } else {
    // 降级方案
    setTimeout(task, 0);
  }
}

// 用法
scheduleBackgroundTask(() => {
  precomputeSomeHeavyData();
});

这个配合 queueMicrotask 一起用,能让主线程在空闲时自动捡活干,用户体验提升非常明显。尤其是在低端机上,以前一滚动就卡死,现在至少能滑动,只是数据更新慢半拍——这完全可以接受。

优化后:流畅多了

改完之后重新跑性能测试。FPS 从平均 22 提升到 56,最高跌到也没低于 48。Long Task 数量从每分钟 10+ 条降到 1~2 条。Lighthouse 分数直接从 38 干到了 79,Accessibility 和 Best Practices 都涨了一大截。

最关键的是用户感知变好了。同样的数据量下,以前页面卡 3 秒,现在是“渐进显示”,虽然总量一样,但感觉快了很多。产品经理都没提需求变更,反而夸最近稳定性提高了……看来“不卡”本身就是最大的功能升级。

性能数据对比

  • 主线程阻塞时间:从平均 120ms/次 → 降至 15ms/次
  • FPS:从 20~25 → 提升至 48~58
  • 首屏可交互时间:从 5.2s → 降到 1.8s
  • 内存占用峰值:从 480MB → 降到 310MB(减少了大量中间字符串)

这些数字是连续一周监控取的平均值,不是理想情况下的单次测试。真实环境里依然会有波动,特别是弱网或低端设备上,但整体已经稳定在可接受范围。

踩坑提醒:这三点一定注意

1. 不要用 Promise.then() 替代 queueMicrotask。它们虽然都是微任务,但执行时机可能受其他 Promise 影响,导致调度不准。

2. 拆分任务时别忘了保留上下文。之前有一次我拆得太碎,结果 updateStats() 在中间就被触发了,数据对不上。后来改成全部处理完再统一回调。

3. 谨慎使用 requestIdleCallbacktimeout。设得太短会强制执行,失去“空闲”意义;太长又可能导致任务迟迟不运行。2000ms 是比较稳妥的选择。

以上是我的优化经验

这个方案不是最优的,但最简单,改起来快,见效也快。后续还可以考虑 Web Worker,但那需要更大的架构调整,通信成本也不低。目前这套基于事件循环调度的方案,已经能满足大多数场景。

如果你有更好的实现方式,比如用 scheduler polyfill 或者 React 的并发模式思路,欢迎评论区交流。我也在看这块,毕竟性能优化是个长期活,今天刚上线,明天可能又崩了。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论
Zz香利
Zz香利 Lv1
想问下,这个代码实现的方式,有没有可能存在潜在的性能瓶颈?
点赞
2026-03-18 08:25