Published on
3208

在 JavaScript 中,分解长任务的多种方法

Authors
  • avatar
    Name
    小辉辉
    Twitter

这是一篇翻译文章,原文地址:

There are a lot of ways to break up long tasks in JavaScript.

让耗时长、成本高的任务占用主线程很容易破坏网站的用户体验。无论应用程序变得多么复杂,事件循环一次只能做一件事。如果您的任何代码占用了它,其他一切都处于待命状态,并且您的用户通常很快就会注意到。

这是一个虚构的例子:我们在屏幕上有一个按钮用于增加计数,旁边有一个大循环在做一些耗时的工作。它只是运行一个同步暂停,但假装这是一些有意义的事情,无论出于什么原因,你需要在主线程上按顺序执行。

<button id="button">count</button>
<div>Click count: <span id="clickCount">0</span></div>
<div>Loop count: <span id="loopCount">0</span></div>

<script>
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  button.addEventListener("click", () => {
    clickCount.innerText = Number(clickCount.innerText) + 1;
  });

  const items = new Array(100).fill(null);

  for (const i of items) {
    loopCount.innerText = Number(loopCount.innerText) + 1;
    waitSync(50);
  }
</script>

运行此程序时,没有任何视觉更新 - 甚至循环计数也没有。这是因为浏览器根本没有机会绘制到屏幕上。无论您多么用力地点击,您得到的都是同样的结果,只有当循环完全结束时,您才会得到任何反馈。

冻结点击

开发工具火焰图证实了这一点。事件循环中的单个任务需要五秒钟才能完成。太可怕了。

火焰图

如果您以前遇到过类似的情况,您就会知道解决方案是定期将大任务分解为多个事件循环。这让浏览器的其他部分有机会使用主线程来处理其他重要的事情,例如处理按钮点击和重新绘制。我们希望从以下方面着手:

长任务

对此:

短任务

实际上,实现这一点的方法多得令人吃惊。我们将探索其中的一些方法,从最经典的方法开始:递归。

1:setTimeout()递归

如果您在原生 Promise 出现之前编写了 JavaScript,那么您无疑会看到类似这样的情况:一个函数从延时回调中递归调用自身。

function processItems(items, index) {
  index = index || 0;
  var currentItem = items[index];

  console.log("processing item:", currentItem);

  if (index + 1 < items.length) {
    setTimeout(function () {
      processItems(items, index + 1);
    }, 0);
  }
}

processItems(["a", "b", "c", "d", "e", "f", "g", "h", "i", "j"]);

即便在今天,这也没什么问题。毕竟,目标已经实现——每项任务都在不同的 tick 上处理,分散了工作。看看这个 400ms 部分的火焰图。我们得到的不是一个大任务,而是一堆小任务:

火焰图2

这样 UI 交互就变得很棒且响应迅速了。点击处理程序可以工作,并且浏览器可以将更新绘制到屏幕上:

响应式

但是 ES6 已经问世十年了,浏览器提供了多种方式来完成同一件事,而所有这些方式都通过Promise变得更加符合人们的交互习惯。

2:Async/Await 和超时

这种组合使我们能够放弃递归并稍微简化一些事情:

<button id="button">count</button>
<div>Click count: <span id="clickCount">0</span></div>
<div>Loop count: <span id="loopCount">0</span></div>

<script>
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  button.addEventListener("click", () => {
    clickCount.innerText = Number(clickCount.innerText) + 1;
  });

  (async () => {
    const items = new Array(100).fill(null);

    for (const i of items) {
      loopCount.innerText = Number(loopCount.innerText) + 1;

      await new Promise((resolve) => setTimeout(resolve, 0));
          
      waitSync(50);
    }
  })();
</script>

好多了。只是一个简单的for循环并等待承诺解决。事件循环的节奏非常相似,只有一个关键变化,用红色勾勒出来:

火焰图3

Promise 的.then()方法总是在微任务队列中执行,在调用堆栈上的所有其他操作完成后。这几乎总是一个无关紧要的差异,但仍然值得注意。

3:scheduler.postTask()

Scheduler API对于Chromium 浏览器来说相对较新,旨在成为一流的任务调度工具,具有更多的控制和效率。它基本上是我们setTimeout()几十年来一直依赖的更好的版本。

const items = new Array(100).fill(null);

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;

  await new Promise((resolve) => scheduler.postTask(resolve));

  waitSync(50);
}

运行循环时,有趣的postTask()是计划任务之间的时间间隔。下面是 400 毫秒内的火焰图片段。请注意,每个新任务在前一个任务之后执行得有多紧密。

火焰图4

postTask()的默认优先级为“用户可见”,这似乎与setTimeout(() => , 0)的优先级相当。输出似乎总是反映它们在代码中运行的顺序:

setTimeout(() => console.log("setTimeout"));
scheduler.postTask(() => console.log("postTask"));

// setTimeout
// postTask
scheduler.postTask(() => console.log("postTask"));
setTimeout(() => console.log("setTimeout"));

// postTask
// setTimeout

但与 setTimeout() 不同的是,postTask()它是为调度而构建的,不受超时的限制。它调度的所有内容也都放在任务队列的前面,防止其他项目挤到前面并延迟执行,尤其是在以如此快速的方式排队时。

我不能肯定地说,但我认为,由于它postTask()是一台运转良好的机器,只有一个目的,火焰图反映了这一点。也就是说,postTask()甚至更进一步,可以最大限度地提高计划任务的优先级:

scheduler.postTask(() => {
  console.log("postTask");
}, { priority: "user-blocking" });

“用户阻止”优先级适用于对用户在页面上的体验至关重要的任务(例如响应用户输入)。因此,它可能不值得仅仅为了分解大工作负载而使用。毕竟,我们正试图礼貌地让位于事件循环,以便完成其他工作。事实上,通过使用“后台”将该优先级设置得更低甚至可能是值得的:

scheduler.postTask(() => {
  console.log("postTask - background");
}, { priority: "background" });

setTimeout(() => console.log("setTimeout"));

scheduler.postTask(() => console.log("postTask - default"));

// setTimeout
// postTask - default
// postTask - background

不幸的是,整个 Scheduler API有一个缺点:它尚未在所有浏览器上得到很好的支持。但它很容易用现有的异步 API 进行兜底。因此,至少很大一部分用户会从中受益。

requestIdleCallback()怎么样?

如果像这样放弃优先级是好事的话,我可能会想到requestIdleCallback()。它被设计为在“空闲”期间执行回调。它的问题是,没有技术保证它何时或是否会运行。您可以设置何时调用它,但即使这样,您仍然需要考虑Safari 仍然根本不支持该 API 的timeout事实。

最重要的是, MDN 鼓励对requestIdleCallback()所需工作进行超时处理,因此我可能会完全避免出于这个目的这样做。

4:scheduler.yield()

Scheduler 接口上的yield()方法比我们介绍过的其他方法稍微特殊一点,因为它是为这种场景而设计的。摘自 MDN:

yield() 该接口的方法 用于 Scheduler 在任务期间让位于主线程并稍后继续执行,并将后续任务安排为优先任务......这允许分解长时间运行的工作,从而使浏览器保持响应。

当你开始用的时候,这个说明会变得更加清晰。不再需要返回并解决我们自己的Promise。只需等待提供的Promise:

const items = new Array(100).fill(null);

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;
  
  await scheduler.yield();
  
  waitSync(50);
}

它也稍微清理了一下火焰图。请注意,堆栈中需要识别的项目少了一个。

火焰图5

这个 API 非常好,你忍不住开始看到到处使用它的机会。考虑一个复选框,它会触发一个昂贵的任务change:

document
  .querySelector('input[type="checkbox"]')
  .addEventListener("change", function (e) {
    waitSync(1000);
});

事实上,单击复选框会导致 UI 冻结一秒钟。

点击事件

但是现在,让我们立即将控制权交给浏览器,让它有机会在点击后更新该 UI。

document
  .querySelector('input[type="checkbox"]')
  .addEventListener("change", async function (e) {
+    await scheduler.yield();

    waitSync(1000);
});

看看这个。漂亮又活泼。

点击事件2

与 Scheduler 接口的其余部分一样,该接口缺乏可靠的浏览器支持,但仍然很容易进行 polyfill:

globalThis.scheduler = globalThis.scheduler || {};
globalThis.scheduler.yield = 
  globalThis.scheduler.yield || 
  (() => new Promise((r) => setTimeout(r, 0)));

5:requestAnimationFrame()

该requestAnimationFrame()API 旨在根据浏览器的重绘周期来安排工作。正因为如此,它在安排回调方面非常精确。它总是在下一次绘制之前,这可能解释了为什么这个火焰图的任务如此紧密地结合在一起。动画帧回调实际上有自己的“队列”,它在渲染阶段的特定时间运行,这意味着其他任务很难挡住它们,将它们推到队列的后面。

火焰图6

但是,在重绘方面进行昂贵的工作似乎也会损害渲染。查看同一时间段内的帧。黄色/带线部分表示“部分呈现的帧”:

火焰图7

使用其他任务中断策略时不会发生这种情况。考虑到这一点以及动画帧回调通常不会执行(除非选项卡处于活动状态),我可能也会避免使用此选项。

6:MessageChannel()

您不会看到这种方式被大量使用,但是当您看到时,它通常被选为零延迟超时的更轻松的替代方案。 无需要求浏览器排队计时器并安排回调,而是实例化一个通道并立即向其发送消息。

for (const i of items) {
  loopCount.innerText = Number(loopCount.innerText) + 1;

  await new Promise((resolve) => {
    const channel = new MessageChannel();
    channel.port1.onmessage = resolve;
    channel.port2.postMessage(null);
  });

  waitSync(50);
}

从火焰图来看,性能可能有些问题。每个计划任务之间的延迟并不大:

火焰图8

但这种方法的(作者主观)缺点是代码实现太复杂。很明显,这不是它的设计初衷。

7. Web Workers

我们之前说过,如果你可以脱离主线程执行工作,那么 Web Worker 无疑是你的首选。从技术上讲,你甚至不需要单独的文件来存放 Worker 代码:

const items = new Array(100).fill(null);

const workerScript = `
  function waitSync(milliseconds) {
    const start = Date.now();
    while (Date.now() - start < milliseconds) {}
  }

  self.onmessage = function(e) {
    waitSync(50);
    self.postMessage('Process complete!');
  }
`;

const blob = new Blob([workerScript], { type: "text/javascipt" });
const worker = new Worker(window.URL.createObjectURL(blob));

for (const i of items) {
  worker.postMessage(items);

  await new Promise((resolve) => {
    worker.onmessage = function (e) {
      loopCount.innerText = Number(loopCount.innerText) + 1;
      resolve();
    };
  });
}

看看主线程有多清晰,当单个项目的工作在其他地方执行时。相反,它全部被推到“Worker”部分下,为活动留下了很大的空间。

火焰图9

我们一直使用的场景要求进度反映在 UI 中,因此我们仍然将单个项目传递给工作器并等待响应。但如果我们可以一次性将整个项目列表传递给工作器,我们当然应该这样做。这会进一步减少开销。

我该如何选择?

我们这里介绍的方法并不详尽,但我认为它们很好地代表了在分解长任务时应该考虑的各种权衡。不过,根据需要,我自己可能只会采用其中的一部分。

如果我可以从主线程中隔离开工作,我会毫不犹豫地选择 Web Worker。它们在各个浏览器上都得到了很好的支持,它们的全部目的就是从主线程中卸载工作。唯一的缺点是它们笨重的 API,但 Workerize 和Vite 的内置 Worker 导入等工具可以解决这个问题。

如果我需要一种非常简单的方式来分解任务,我会选择scheduler.yield()。我不喜欢我需要为非 Chromium 用户进行填充,但大多数人都会从中受益,所以我愿意承受额外的负担。

如果我需要非常精细地控制分块工作的优先级,scheduler.postTask()那么 就是我的选择。令人印象深刻的是,您可以根据自己的需求进行深度定制。优先级控制、延迟、取消任务等都包含在此 API 中,即使.yield()它现在需要进行 polyfill。

如果浏览器支持和可靠性至关重要,我会选择setTimeout()。这是一个永不消逝的传奇,即使有耀眼的替代品出现。

我还少了什么?

我承认我从未在实际应用中使用过其中的一些方法,因此您在此处阅读的内容很可能存在一些盲点。如果您可以进一步讨论该主题,即使只是对其中一种具体方法的见解,我们也非常欢迎您这样做。