请务必用 postTask/isInputPending 释放JS主线程!

大家好,很高兴又见面了,我是"高级前端进阶",由我带着大家一起关注前端前沿、深入前端底层技术,大家一起进步,也欢迎大家关注、点赞、收藏、转发!

今天给大家带来的主题是主线程阻塞相关话题,文章大部分内容来自 Jeremy Wagner 发布的《Optimize long tasks》。话不多说,直接进入主题。

1.什么是任务

任务是指浏览器执行的任何独立工作,包括:渲染、解析 HTML 和 CSS、运行 JavaScript 以及开发者无法直接控制的其他事情。对于普通的网页来说,开发者编写的业务 JavaScript 代码又是大头。

任务执行影响应用最终性能的时间包括:

  • 当浏览器在启动期间下载一个 JavaScript 文件时会将任务加入队列以解析、编译、执行 JavaScript。
  • 当 JavaScript 正常运行时,任务便会启动,例如:通过事件处理脚本、JavaScript 驱动的动画和后台活动(如埋点)驱动互动,所有这些操作(Web Worker 和类似 API 除外)都发生在主线程上。

2.什么是主线程

主线程是在浏览器中运行大多数任务的位置,本质上是一个独立线程,几乎所有 JavaScript 都会在这个线程中工作。

主线程一次只能处理一个任务,如果任务的延迟时间超过某一点,例如:50ms,则会被归类为耗时较长的任务。如果用户在运行耗时较长的任务时尝试与网页互动、或者需要进行重要的重新渲染,此时浏览器就会有延迟,从而导致终端用户交互延迟。

此时,常见的做法就是将任务拆分,即将一个较长的任务分成若干个耗时较少的任务。

当任务拆分后,浏览器有更多机会响应优先级较高的工作,包括用户互动。

代码拆分之前,由用户互动触发的事件处理脚本必须等待单个较长的任务执行完成后才能运行,从而导致延迟交互。代码拆分之后,事件处理脚本可以在一些小任务之间穿插执行,响应速度比必须等待较长的任务完成时要快的多。

3.三大任务管理策略

3.1 JavaScript 的运行到完成模型

在软件架构方面,通常建议是将任务分解为更小的函数,从而提高代码可读性和项目可维护性、可测试性。

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

上面代码示例有一个名为 saveSettings() 的函数,该函数会调用五个函数来完成某些工作,如:验证表单、显示 Loading 图标、发送埋点数据等,从软件架构上看结构设计也合理。如果需要调试其中一个函数,可以遍历项目树,了解每个函数的用途。

不过,问题在于 JavaScript 不会将每个函数作为单独的任务运行,即在 saveSettings() 函数内所有五个函数都将作为一个任务运行

这就是 JavaScript 任务执行的“运行到完成”模型,即无论任务阻塞主线程多久,都会一直运行到其完成。

3.2 setTimeout 手动推迟代码执行

这里就不得不提到 setTimeout(),该方法会将回调的执行推迟到单独的任务,即使延迟时间为 0 也是如此。

function saveSettings() {
  //用户可见的关键任务
  validateForm();
  showSpinner();
  updateUI();
  // 将不影响用户的工作推迟到单独的任务中
  setTimeout(() => {
    saveToDatabase();
    sendAnalytics();
  }, 0);
}

但是如果有一系列函数需要依序运行,例如:大量数据需要在一个循环中进行处理、数百万项数据。

function processData() {
  for (const item of largeDataArray) {
    // 单独处理一个元素
  }
}

此时 setTimeout() 会遇到瓶颈,因为其工效学设计使然,即使可以非常快速地处理每项数据,整个数据数组也可能需要很长时间进行处理。除了 setTimeout() 之外,还有几个其他 API 也可让代码执行推迟到后续任务,比如下面的 postMessage() 。

   // Only add setZeroTimeout to the window object, and hide everything
    // else in a closure.
    (function() {
        var timeouts = [];
        var messageName = "zero-timeout-message";
        // Like setTimeout, but only takes a function argument.  There's
        // no time argument (always zero) and no arguments (you have to
        // use a closure).
        function setZeroTimeout(fn) {
            timeouts.push(fn);
            window.postMessage(messageName, "*");
        }
        function handleMessage(event) {
            if (event.source == window && event.data == messageName) {
                event.stopPropagation();
                if (timeouts.length > 0) {
                    var fn = timeouts.shift();
                    fn();
                }
            }
        }
        window.addEventListener("message", handleMessage, true);
        // Add the one thing we want added to the window object.
        window.setZeroTimeout = setZeroTimeout;
    })();

开发者还可以使用 requestIdleCallback() 拆分工作,但其会将任务安排为尽可能低的优先级,并且仅在浏览器空闲时执行。但是需要注意:当主线程拥塞时,使用 requestIdleCallback() 调度的任务可能永远无法运行


window.requestIdleCallback() 方法插入一个函数在浏览器空闲时被调用,使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如:动画和输入响应。函数一般会按先进先调用的顺序执行,然而,如果回调函数指定了执行超时时间timeout,则有可能为了在超时前执行函数而打乱执行顺序。

3.3 async/await 让出主线程

当任务被分解后,浏览器的内部优先级方案可以更好地确定其他任务的优先级。让步于主线程的一种方式是组合使用通过调用 setTimeout() 进行 resolve 的 Promise:

function yieldToMain() {
  return new Promise((resolve) => {
    setTimeout(resolve, 0);
    // 注意:不是 Promise 负责在新task里面执行代码,而是 setTimeout()
    // 注意:Promise回调作为微任务执行而非任务
  });
}

在 saveSettings() 函数中,如果在每次函数调用后都 await yieldToMain() 函数,则可以在执行完每项工作后让出主线程。

async function saveSettings() {
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics,
  ];
  // 循环任务
  while (tasks.length > 0) {
    const task = tasks.shift();
    // 执行任务
    task();
    // 让出主线程
    await yieldToMain();
  }
}

结果是,曾经的单体式任务现在被分解成了单独的任务。

与手动使用 setTimeout() 相比,使用基于 Promise 的方法进行生成好处更多,更符合工效学要求。挂起点变为声明式,因此更易于写入、读取和理解

4.isInputPending 在影响用户时才让出主线程

如果有大量任务,但只想在用户尝试与网页互动时让出主线程怎么办?这里就不得不提到 isInputPending()。isInputPending() 是一个可运行的函数,用于确定用户是否尝试与页面元素互动,如果是则返回 true,否则 false。

重要声明:isInputPending() 方法允许检查事件队列中是否有待处理的输入事件,表明用户正在尝试与页面交互。如果有要运行的任务队列,并且希望定期让位于主线程以允许发生用户交互,从而使应用程序保持尽可能的响应能力和性能,则此功能非常有用。 isInputPending() 允许仅在有输入待处理时才让出,而不必以任意时间间隔执行此操作。

假设有一个需要运行的任务队列,但不希望因为运行而干扰用户任何输入。下面代码使用 isInputPending() 和自定义 yieldToMain() 函数确保在用户尝试与网页互动时输入不会延迟:

async function saveSettings() {
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics,
  ];

  while (tasks.length > 0) {
    if (navigator.scheduling.isInputPending()) {
      // 用户操作在pendding、让出主线程,通过navigator.scheduling.isInputPending调用
      await yieldToMain();
    } else {
      // 执行队列任务
      const task = tasks.shift();
      task();
    }
  }
}

上面的 saveSettings() 函数执行会循环遍历队列中的任务,如果 isInputPending() 返回 true,则调用 yieldToMain()以便处理用户输入。

将 isInputPending() 与 yeield 机制结合使用,是让浏览器停止其正在处理的任务的好方法,以便响应面向用户的关键互动,从而提高网页在多项任务同时进行时响应用户的能力。

使用 isInputPending() 的另一种方式是将基于时间的方法与可选链接运算符结合使用:

async function saveSettings() {
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics,
  ];
  let deadline = performance.now() + 50;
  while (tasks.length > 0) {
    // 使用?运算符兼容不支持isInputPending的浏览器
    if (
      navigator.scheduling?.isInputPending() ||
      performance.now() >= deadline
    ) {
      //   用户操作在pending或者时间到了则让出主线程
      await yieldToMain();
      // 超时时间设置
      deadline = performance.now() + 50;
      continue;
    }
    const task = tasks.shift();

    task();
  }
}

需要注意的是,isInputPending() 不一定会在用户输入后立即返回 true,因为操作系统需要一些时间才能告知浏览器发生了互动。此时,其他代码可能已经开始执行(比如:saveToDatabase)。因此,即使使用 isInputPending(),也务必限制在每个函数中执行的工作量。

5.当前 API 与诸多限制

以上提到的诸多技术方案有一个很大的缺点,即:如果通过推迟代码在后续任务中运行来让出主线程,该代码就会被添加到任务队列的最末端

如果能控制页面上的所有代码,则可以自行创建调度程序,并设定任务优先级,但第三方脚本不会使用当前页面的调度程序。事实上,在此类环境中无法真正优先处理工作,只能分多次进行讨论或者明确地由用户互动。

5.1 新的调度器 postTask

幸运的是,目前正在开发中的专用调度器 API 可以有效解决这些问题。调度程序 API 目前提供 postTask() 函数,该函数在 Chromium 浏览器和 Firefox 中均可用(启动标志)。postTask() 支持更精细的任务调度,可帮助浏览器确定工作优先级,使低优先级的任务让出主线程。postTask() 使用 promise,并接受 priority 设置。

可以使用 postTask() API 的三个优先级:

  • 'background':优先级最低的任务
  • 'user-visible':中优先级任务,如果未设置 priority 则为默认值。
  • 'user-blocking': 用于需要以高优先级运行的关键任务。

以下代码使用 postTask() API 以最高优先级运行三个任务,以尽可能低的优先级运行其余两个任务。

function saveSettings() {
  // 高优先级验证表单
  scheduler.postTask(validateForm, { priority: 'user-blocking' });
  // 高优先级展示加载器
  scheduler.postTask(showSpinner, { priority: 'user-blocking' });
  // 默默更新数据库
  scheduler.postTask(saveToDatabase, { priority: 'background' });
  // 高优先级更新界面
  scheduler.postTask(updateUI, { priority: 'user-blocking' });
  // 默默发送埋点
  scheduler.postTask(sendAnalytics, { priority: 'background' });
}

为了更好的保证postTask的浏览器兼容性,可以使用下面的浏览器行为检测判断支持情况:

// Check that feature is supported
if ("scheduler" in this) {
  console.log("Feature: Supported");
} else {
  console.error("Feature: NOT Supported");
}

当然还可以通过 prioritychange 事件监听优先级的变化,比如下面的示例:

// Create a TaskController, setting its signal priority to 'user-blocking'
const controller = new TaskController({ priority: "user-blocking" });
// Listen for 'prioritychange' events on the controller's signal.
controller.signal.addEventListener("prioritychange", (event) => {
  const previousPriority = event.previousPriority;
  const newPriority = event.target.priority;
  console.log(`Priority changed from ${previousPriority} to ${newPriority}.`);
});

开发者可以实例化不同的 TaskController 对象,这些对象可以在任务之间共享优先级,包括能够根据需要更改不同 TaskController 实例的优先级。

5.2 内置的 scheduler.yield

调度程序 API 中值得注意的是 scheduler.yield,该 API 专门设计用于让浏览器中的主线程让出,用法与前面的 yieldToMain() 函数类似。

async function saveSettings() {
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics,
  ];
  while (tasks.length > 0) {
    const task = tasks.shift();
    task();
    // 内置的让出主线程机制
    await scheduler.yield();
  }
}

下图展示了不使用 yeild、使用 yield、yield+连续的 task 执行方式:

scheduler.yield() 的好处是延续,也就是说,如果在一组任务的中间让出,其他已安排的任务将在 yeild 点之后按相同顺序继续执行,从而有效避免第三方脚本中的代码篡改代码的执行顺序。

6.使用 main-thread-scheduling 三方库调度优先级

main-thread-scheduling 库允许开发者在主线程上运行计算量非常大的任务,同时确保:

  • 应用程序的 UI 不会冻结。
  • 用户的计算机风扇不超负荷旋转
  • 可以轻松集成到现有的代码库中
  • 在 10k 个文件中搜索并立即获得结果的真实展示

main-thread-scheduling 库的典型用例包括:

  • 希望将同步函数转换为非阻塞异步函数,避免 UI 冻结。
  • 希望首先渲染重要的元素,然后渲染不太紧急的元素, 提高感知性能。
  • 想要运行一个长时间的后台任务,一段时间后不会让风扇高速旋转。
  • 希望运行多个后台任务,这些任务不会随着时间的推移而降低应用程序的性能。

main-thread-scheduling 的大致原理可以归结为以下几点:

  • 使用 MessageChannel.postMessage() 和 requestIdleCallback() 进行调度。
  • 当用户与 UI 交互时停止任务执行(如果 navigator.scheduling.isInputPending 可用)
  • 支持全局队列,多个任务被一一执行,因此增加任务数量不会线性降低性能。
  • 按重要性对任务进行排序,按优先级排序并优先处理稍后请求的任务。
  • 考虑现有的代码,具有后台优先级的任务最后执行,因此在后台任务完成后不会出现一些意外的工作减慢主线程的速度。

下面是 main-thread-scheduling 的示例用法:

// 这个方法的背后隐藏着整个库的复杂性
// 开发者可以通过调用单个方法来获得出色的应用程序性能。
async function findInFiles(query: string) {  
    for (const file of files) {
        await yieldOrContinue('user-visible')
        // 注意 yieldOrContinue 方法
        for (const line of file.lines) {
            fuzzySearchLine(line, query)
        }
    }
}

该库还有两个可用函数:

  • yieldControl(priority: 'background' | 'user-visible')
  • isTimeToYield(priority: 'background' | 'user-visible')

这两个函数一起使用来处理更高级的用例,比如:当想要在将控制权交还给浏览器以继续其工作之前渲染视图时,将需要这两个函数:

async function doHeavyWork() {
    for (const value of values) {
        if (isTimeToYield('user-visible')) {
            render()
            await yieldControl('user-visible')
        }
        computeHeavyWorkOnValue(value)
    }

关于 main-thread-scheduling 的更多用法可以参考文末资料。

参考资料

https://web.dev/articles/optimize-long-tasks

https://www.onely.com/blog/total-blocking-time/

https://github.com/astoilkov/main-thread-scheduling

https://developer.mozilla.org/zh-CN/docs/Web/API/Window/requestIdleCallback

https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/postTask

原文链接:,转发请注明来源!