关于优化浏览器的长任务

为了能让 Javascript 应用能够保持快速运行,我们通常要求:

  • 不要阻塞主线程
  • 拆分长任务

任务

任务是指浏览器执行的任何离散工作。这些工作包括渲染、解析 HTML 和 CSS、运行 JavaScript,以及您可能无法直接控制的其他类型的工作。在所有这些因素中,而编写的 JavaScript 可能是任务的最大来源。

主线程

主线程是浏览器中运行大多数任务的线程,也是执行您编写的几乎所有 JavaScript 的线程。

主线程一次只能处理一项任务。任何耗时超过 50 毫秒的任务都是长任务。对于超过 50 毫秒的任务,任务的总时间减去 50 毫秒即为任务的阻塞时间。

当任何长度的任务运行时,浏览器都会阻止互动发生,但只要任务运行时间不太长,用户就不会察觉到这一点。不过,当用户尝试与包含许多长时间任务的网页互动时,用户界面会感觉无响应,如果主线程被长时间阻塞,甚至可能会出现故障。


于是,我们可以利用setTimeout(func,0)来将长任务拆分,加入到事件循环的下一个周期,也就是事件循环中的宏任务队列中,由于setTimeout是异步的,这样就不会阻塞主线程了。 对于setTimeout,即使指定了0的超时时间,此方法也会将回调的执行推迟到单独的任务中。

从:

function saveSettings () {
  validateForm();
  showSpinner();
  saveToDatabase();
  updateUI();
  sendAnalytics();
}

到:

function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Defer work that isn't user-visible to a separate task:
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

这称为“让步”,最适合需要按顺序运行的一系列函数。

但是它也有一些缺点:

  • 由于setTimeout的回调被推迟到事件循环的下一个周期,会被加入到宏任务队列的末尾,这可能会导致后面的任务先于应该先执行的任务执行。
  • 如果是在循环中:
function processData () {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

在此处使用 setTimeout() 会出现问题,并且在嵌套了五轮 setTimeout() 后,浏览器将开始为每个额外的 setTimeout() 强制实施至少 5 毫秒的延迟。

于是乎要引入一个专用的浏览器的程序让步API:

scheduler.yield()

这个api属于浏览器的任务调度api,专门用来在浏览器中让出主线程。

它不是语言级语法或特殊构造;scheduler.yield() 只是一个返回 Promise 的函数,该 Promise 将在未来的任务中解析。链接到在 Promise 解析后运行的任何代码(无论是在显式 .then() 链中还是在异步函数中 await 后),都将在该未来的任务中运行。

在实践中:插入 await scheduler.yield(),函数将在该点暂停执行并让出主线程。该函数的其余部分(称为函数的延续)的执行将安排在新的事件循环任务中运行。当该任务开始时,被等待的 promise 将得到解析,并且函数将从上次停止的位置继续执行

async function saveSettings () {
  // Do critical work that is user-visible:
  validateForm();
  showSpinner();
  updateUI();

  // Yield to the main thread:
  await scheduler.yield()

  // Work that isn't user-visible, continued in a separate task:
  saveToDatabase();
  sendAnalytics();
}
after divided

函数 saveSettings() 的执行现在分为两个任务。这样一来,布局和绘制就可以在任务之间运行,从而为用户提供更快的视觉响应(如现在短得多的指针互动所衡量的那样)。

continuation

使用 scheduler.yield() 时,延续会从中断的地方继续执行,然后再继续执行其他任务。

不是所有的浏览器都支持这个api,需要回退操作:

function yieldToMain () {
  if (globalThis.scheduler?.yield) {
    return scheduler.yield();
  }

  // Fall back to yielding with setTimeout.
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

我们还可以利用requestIdleCallback()来让出主线程,requestIdleCallback() 允许您安排一个回调函数在浏览器空闲时运行。与 scheduler.yield() 不同,requestIdleCallback() 不保证回调函数会在特定时间运行;相反,它会在浏览器认为合适的时候运行回调函数。

不仅如此,还有一个requestAnimationFrame(),它允许您安排一个回调函数在浏览器下次重绘之前运行。与 scheduler.yield() 不同,requestAnimationFrame() 旨在用于动画,并且回调函数将在浏览器准备好进行下一次重绘时运行。虽然本意是用于动画,但是它也可以用来让出主线程,尤其是在需要在用户界面更新之前执行一些工作时。

友链 👉 如何优化长任务?

关于优化浏览器的长任务 | Tyuan