找回密码
立即注册
搜索
发回帖 发新帖

5018

积分

0

好友

634

主题
发表于 1 小时前 | 查看: 3| 回复: 0

经常听人说“不要阻塞主线程”,或者“减少长耗时”,到底该如何落地?聊网站性能的文章很多,通常为了提高 JavaScript 性能,绕不开这两点:

  • 不要阻塞主线程
  • 减少长耗时

该怎么做呢?精简 JavaScript 代码当然有好处,但更少的代码量是否就一定意味着用户界面的体验更顺畅?可能会,但也可能恰恰相反。

要弄懂优化 JavaScript 任务的重要性,首先需要了解什么是任务、任务的角色,以及浏览器的任务处理机制。

浏览器中的任务

浏览器执行的任务之间相互独立,像页面渲染、HTML 和 CSS 的解析,以及执行 JavaScript 代码,都属于任务的范畴。虽然开发者不能直接控制这些任务,但毫无疑问,浏览器中的任务主要源自开发者编写和部署的代码。

长任务性能剖析时间线图

上图中的任务就是 Chrome DevTools 性能剖析中点击事件触发的。从图中能看到,任务在顶端,任务下面列出了点击事件、调用的函数,此外还调用了很多其他方法。

任务影响性能的方式很多。比如打开网站时下载 JavaScript 代码,浏览器会把任务放到队列中暂不执行,而是准备解析和编译 JavaScript 以防止阻塞。之后网站上的任务才会因为用户交互驱动事件处理器、JavaScript 动画以及分析收集的后台活动等 JavaScript 活动而触发——Web Worker 例外。

什么是主线程?

浏览器绝大多数任务都发生在主线程,名称的由来也主要是因为:几乎所有 JavaScript 都在主线程上运行。

主线程每次只能处理一个任务。当任务耗时超过特定时间,比如 50ms,就会被归类为长耗时。如果发生长耗时期间存在用户交互或关键渲染更新,浏览器就会延后处理用户交互,直接导致交互延迟或渲染卡顿。

谷歌性能剖析中的长耗时一般会在任务角上标出红色三角形,其中被阻塞的任务部分用红色细斜条纹标识。优化长耗时,意味着将单个长耗时任务拆解成几个耗时相对短的小任务,可以查看下图。

长任务拆分前后对比图

如上图所示,单个长任务被拆分成了 5 个短任务。

为什么拆分任务如此重要?因为拆分长任务后,浏览器就有了更多机会去处理优先级别更高的工作,其中就包括用户交互行为。

事件循环优化前后长任务与交互处理对比图

如果任务非常长,浏览器对用户交互的展示如上图左侧——浏览器没法快速处理用户交互;而拆分长任务之后,效果就完全不同了。

因为长任务的缘故,用户交互产生的事件处理必须排队,等待长任务执行完才能执行,这会导致交互延迟。而拆分成较短的任务后,事件处理器就有机会更快触发。因为事件处理器能够在短任务之间得以执行,用户可能就感觉体验很流畅。

那么问题来了:减少长耗时到底该怎么做?“不要阻塞主线程”写得也不够明确。这篇文章就为你解开这些神秘的面纱。

任务管理策略

软件架构中有时会将一个任务拆分成多个函数,这不仅能增强代码可读性,也让项目更容易维护,当然也更容易写测试。

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

在上面的例子中,函数 saveSettings 调用了另外 5 个函数,包括验证表单、展示加载动画、发送数据到后端等。理论上讲,这是很合理的架构。如果需要调试这些功能,只需要在项目中查找每个函数即可。

然而这样也有问题:JavaScript 并不会为每个方法开辟一个单独的任务。因为这些方法都包含在 saveSetting 这个函数中,也就是说这五个方法在一个任务中执行。

重点提示:JavaScript 遵循执行才编译的原理,也就是说,只有一个任务结束才会执行下一个任务,而且不论这个任务会阻塞主线程多久。

saveSettings函数调用链的长任务示意图

saveSetting 调用 5 个函数,这个函数的执行看起来就像一个特别特别长的任务。

在很多场景中,单个函数耗时可能会超过 50ms,从而使得整体任务耗时更长。如果测试环境不够理想,尤其是在“资源受限”设备上,每个函数的耗时可能都会更久。接下来分享几个优化策略。

使用代码延迟任务执行

为了拆分长任务,开发者经常使用定时器 setTimeout。通过把方法传递给 setTimeout,也就等同于重新创建了一个新的任务,延迟了回调的执行。而且使用该方法,即便是将 delay 值设定成 0,也是有效的。

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);
}

如果需执行的函数先后关系很明确,这个方法会非常有效。然而并不是所有场景都能使用这个方法。比如,需要在循环中处理大数据量数据时,任务的耗时可能就会非常长——假设有数百万条数据:

function processData() {
  for (const item of largeDataArray) {
    // Process the individual item here.
  }
}

此时使用 setTimeout 就会出问题,因为效率上无法实行。虽然单独处理每个数据耗时很短,但整个数组可能花费特别长的时间。综合来看,setTimeout 在这种场景下就不能算是特别有效的工具。

除了 setTimeout,确实有一些 API 能够允许延迟代码到随后的任务中执行。其中一个方式是使用 postMessage 替代定时器;也可以使用 requestIdleCallback,但需要注意这个 API 编排的任务优先级最低,只会在浏览器空闲时执行。当主线程繁忙时,通过 requestIdleCallback 编排的任务可能永远不会执行。

使用 async、await 来创造让步点

这里先解释一个新词——“让步”。当让步于主线程后,浏览器就有机会处理那些更重要的任务,而不是让它们继续在队列中排队。理想状态下,一旦出现用户界面级别的任务,就应该让步给主线程,让任务更快执行完。让步于主线程让更重要的工作能更快完成。

分解任务后,按照浏览器内部的优先级别划分,其他任务的优先级可能被调整得更高。一种让步于主线程的方式是配合用了 setTimeout 的 promise:

function yieldToMain() {
  return new Promise(resolve => {
    setTimeout(resolve, 0);
  });
}

注意:尽管这个例子在返回的 promise 中通过 setTimeout 来调用 resolve,但这并不是新开一个任务让 promise 执行后续代码,而是通过 setTimeout 调用。因为 promise 的回调属于微任务,因此不会让步于主线程。

在 saveSettings 的函数中,可以在每次 await yieldToMain() 后让步于主线程:

async function saveSettings() {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread:
    await yieldToMain();
  }
}

重要提示:并不是所有函数调用都要让步于主线程。如果两个函数的结果在用户界面上有重要更新,最好就不要拆分。可以的话,让关键任务先执行,然后考虑在那些不重要的函数或者能在后台运行的函数之间让步。

这样的好处是,单个大的长任务被拆分成了多个独立的任务。

通过让步拆分的任务执行时间线

现在能看到,saveSetting 函数内的函数现在成为了单独的任务。

通过使用 promise 这种方式,和手动写 setTimeout 相比,在工程上有更多好处。让步的时间点变成了声明式,代码写起来更容易,阅读和理解也更轻松。

只在必要时让步

假如有一堆任务,但只想在用户交互的时候才让步,该怎么办?正好有这样一个 API——isInputPending。

isInputPending 这个函数可以在任何时候调用,它能判断用户是否要与页面元素进行交互。调用 isInputPending 会返回布尔值,true 代表要与页面元素交互,false 则不交互。

比如说,任务队列中有很多任务,但不想阻挡用户输入,使用 isInputPending 和自定义的 yieldToMain 方法,就能够保证用户交互时的 input 不会延迟。

async function saveSettings() {
  // 函数队列
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];

  while (tasks.length > 0) {
    // 让步于用户输入
    if (navigator.scheduling.isInputPending()) {
      // 如果有用户输入在等待,则让步
      await yieldToMain();
    } else {
      // Shift the task out of the queue:
      const task = tasks.shift();

      // Run the task:
      task();
    }
  }
}

在 saveSetting 执行过程中,会逐个循环队列中的任务。如果循环时 isInputPending 返回 true,saveSetting 就会调用 yieldToMain 函数,这样就能处理用户输入的事件;反之,则继续执行下一个任务,直到队列执行完毕。

isInputPending让步给用户交互的性能分析图

saveSetting 任务队列中有 5 个任务,但如果正在执行第二个任务时用户想打开某个菜单,于是点击了这个菜单,isInputPending 就会让步,让主线程处理交互事件,同时稍后继续执行剩余的任务。

用户输入后 isInputPending 的返回值不一定总是 true,这是因为操作系统需要时间来通知浏览器交互结束,也就是说其他代码可能已经开始执行了。比如截图例子中的 saveToDatabase 这个方法可能已经在执行了。即便使用 isInputPending,仍然需要限制每个方法中任务的数量。

使用 isInputPending 配合让步的策略,能让浏览器有机会响应用户的重要交互。这在很多执行大量任务的场景下,能够显著提高页面对用户的响应能力。

另一种使用 isInputPending 的方式,特别是担心浏览器不支持该策略时,可以结合时间来判断:

async function saveSettings() {
  // A task queue of functions
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];

  let deadline = performance.now() + 50;

  while (tasks.length > 0) {
    // Optional chaining operator used here helps to avoid
    // errors in browsers that don't support `isInputPending`:
    if (navigator.scheduling?.isInputPending() || performance.now() >= deadline) {
      // There's a pending user input, or the
      // deadline has been reached. Yield here:
      await yieldToMain();

      // Extend the deadline:
      deadline += 50;

      // Stop the execution of the current loop and
      // move onto the next iteration:
      continue;
    }

    // Shift the task out of the queue:
    const task = tasks.shift();

    // Run the task:
    task();
  }
}

使用这种方式,通过结合时间来兼容不支持 isInputPending 的浏览器。特别是使用截止时间,在特定时间点让工作能在适当时候中断,不论是通过让步于用户输入还是在特定时间节点。

几个 API 的差异

目前提到的 API 对于拆解任务都有帮助,但也有弊端:让步于主线程意味着延迟代码稍后执行,即该部分代码被添加到稍后的事件队列中。

如果能控制页面中所有的代码,就可以编排各个任务的优先级,但第三方 JavaScript 脚本可能不会服从安排。实际上,也不可能真正意义上给所有的任务排优先级,只能让它们成堆,或者让步于特定的用户交互行为。

幸运的是,有一个专门编排优先级的 API 正在开发中,相信能够解决这些问题。

专门编排优先级的 API

目前在书写本文时,该 API 提供 postTask 的功能,对于所有 Chromium 内核的浏览器和 Firefox 均可使用。

postTask 允许更细粒度地编排任务,该方法能让浏览器编排任务的优先级,以便低优先级别的任务能够让步于主线程。目前 postTask 使用 promise,接受优先级参数设定。

postTask 方法有三个优先级别:

  • background 级:适用于优先级别最低的任务
  • user-visible 级:适用于优先级别中等的任务,也是该函数的默认参数(无入参时)
  • user-blocking 级:适用于优先级别最高的任务

拿下面的代码举例,三个关键任务都用最高优先级别调度,另外两个任务则使用最低优先级别:

function saveSettings() {
  // Validate the form at high priority
  scheduler.postTask(validateForm, { priority: 'user-blocking' });

  // Show the spinner at high priority:
  scheduler.postTask(showSpinner, { priority: 'user-blocking' });

  // Update the database in the background:
  scheduler.postTask(saveToDatabase, { priority: 'background' });

  // Update the user interface at high priority:
  scheduler.postTask(updateUI, { priority: 'user-blocking' });

  // Send analytics data in the background:
  scheduler.postTask(sendAnalytics, { priority: 'background' });
}

在上面例子中,通过这种优先级编排方式,能让浏览器的高优先级别任务——比如用户交互——得以及时触发。

使用postTask编排任务优先级的执行时间线

当 saveSettings 方法执行时,会使用 postTask 来编排每个方法。关键的用户侧任务优先级别高,用户无感知的任务按照 background 级别执行,这就可以提高整体响应能力。

这是使用 postTask 的一个非常简单的例子。你可以用不同的 TaskController 对象来区分,这样能在不同任务之间共享优先级别,也能为不同的 TaskController 实例变更优先级。

重点提示:postTask 并不是所有浏览器都支持。可以检测是否可用,或者考虑使用 polyfill。

内置不中断的让步方法

还有一个编排 API 目前还在提议阶段,还没有内置到任何浏览器中。它的用法和本文一开始讲到的 yieldToMain 方法类似。

async function saveSettings() {
  // Create an array of functions to run:
  const tasks = [
    validateForm,
    showSpinner,
    saveToDatabase,
    updateUI,
    sendAnalytics
  ];

  // Loop over the tasks:
  while (tasks.length > 0) {
    // Shift the first task off the tasks array:
    const task = tasks.shift();

    // Run the task:
    task();

    // Yield to the main thread with the scheduler
    // API's own yielding mechanism:
    await scheduler.yield();
  }
}

代码和之前大部分相似,但区别在于没有使用 yieldToMain,而是使用了 await scheduler.yield() 方法。

三种JavaScript执行模型对比图

下面三幅图分别对应:不使用 yield、使用 yield、以及使用 yield 且不中断。

  • 不使用 yield:出现了长耗时任务。
  • 使用 yield:短任务数量变多了,而且还能被其他不相关的任务打断。
  • 使用 yield 且不中断:短任务更多,但执行顺序是固定的。

使用 scheduler.yield 的好处是“不中断”。也就是说,如果在一连串任务中 yield,那么从 yield 的时间点开始,其他编排好的任务会继续执行。这就能避免第三方 JavaScript 脚本代码阻塞代码的执行。

结语

虽然管理任务富有挑战,但收益也颇多——网站能获得更快的用户交互体验。管理和调优没有万灵药,但确有一系列不同的技巧。最后总结重申一下,管理任务时主要需要考虑以下几点:

  • 遇到关键任务和用户侧任务时,需要让步于主线程
  • 使用 isInputPending 来让步主线程,让用户可以与页面交互
  • 使用 postTask 来调整任务的优先级
  • 每个函数尽可能地减少活动

使用以上一个或多个方法,就能够将应用中的任务进行管理,根据用户需要调整优先级,同时保证相对不那么重要的工作得以继续执行。这样能创造更好的用户体验,网站响应更快,使用更令人愉悦。

如果你想继续深挖前端性能优化与算法架构的系统方法,欢迎访问云栈社区,与更多开发者一起探讨技术话题。




上一篇:Jeff Naughton 加入 Snowflake:AI Agent 数据底座开启系统研究决战
下一篇:同事用 insert into select 迁移数据,一条大事务炸掉生产库
您需要登录后才可以回帖 登录 | 立即注册

手机版|小黑屋|网站地图|云栈社区 ( 苏ICP备2022046150号-2 )

GMT+8, 2026-10-14 07:33 , Processed in 0.086811 second(s), 41 queries , Gzip On.

Powered by Discuz! X3.5

© 2025-2026 云栈社区.

快速回复 返回顶部 返回列表