你被告知“不要阻塞主线程”和“分解你的长任务”,但是做这些事情意味着什么?
如果您阅读了很多关于 Web 性能的文章,那么保持 JavaScript 应用程序快速运行的建议往往涉及以下一些花絮:
-
“不要阻塞主线程。”
-
“分解你的长任务。”
这是什么意思? 更少的 JavaScript 是好的,但这是否自动等同于整个页面生命周期中更快速的用户界面? 也许,但也许不是。
要理解为什么在 JavaScript 中优化任务很重要,您需要了解任务的作用以及浏览器如何处理它们——首先要了解什么是任务。
What is a task?
任务是浏览器执行的任何离散工作。 任务涉及渲染、解析 HTML 和 CSS、运行您编写的 JavaScript 代码以及您可能无法直接控制的其他事情。 在所有这一切中,您编写并部署到 Web 的 JavaScript 是任务的主要来源。
对 Chrome DevTools 性能分析器中 click event 处理程序启动的任务的描述。
任务以多种方式影响性能。 例如,当浏览器在启动期间下载 JavaScript 文件时,它会将任务排队以解析和编译该 JavaScript,以便执行。 在页面生命周期的后期,任务会在您的 JavaScript 正常工作时启动,例如通过事件处理程序驱动交互、JavaScript 驱动的动画和分析收集等后台活动。 所有这些东西——除了 web workers 和类似的 API——都发生在主线程上。
What is the main thread?
主线程是大多数任务在浏览器中运行的地方。 它被称为主线程是有原因的:它是您编写的几乎所有 JavaScript 工作的唯一线程。
主线程一次只能处理一个任务。 当任务超过某个点时——准确地说是 50 毫秒——它们被归类为长任务。 如果用户在运行较长的任务时尝试与页面交互——或者如果需要进行重要的渲染更新——浏览器将延迟处理该工作。 这会导致交互或渲染延迟。
Chrome 性能分析器中描述的一项长期任务。 长任务由任务角落的红色三角形表示,任务的阻塞部分用对角线红色条纹图案填充。
你需要分解任务。 这意味着将一项长任务分解为更小的任务,这些任务单独运行所需的时间更少。
单个长任务与分解为五个较短任务的相同任务的可视化。
当任务太长且浏览器无法足够快地响应交互时交互发生的情况的可视化,以及当较长的任务被分解成较小的任务时的情况。
在上图的顶部,由用户交互排队的事件处理程序必须等待一个长任务才能运行,这会延迟交互的发生。 在底部,事件处理程序有机会更快地运行。 因为事件处理程序有机会在较小的任务之间运行,所以它比必须等待较长任务完成的情况运行得更快。 在上面的示例中,用户可能已经注意到延迟; 在底部,交互可能是即时的。
但是,问题是“分解你的长期任务”和“不要阻塞主线程”的建议不够具体,除非你已经知道如何做这些事情。 这就是本指南将解释的内容。
Task management strategies
软件架构中的一个常见建议是将您的工作分解为更小的功能。 这为您带来了更好的代码可读性和项目可维护性的好处。 这也使得测试更容易编写。
function saveSettings () {
validateForm();
showSpinner();
saveToDatabase();
updateUI();
sendAnalytics();
}
在此示例中,有一个名为 saveSettings() 的函数调用其中的五个函数来完成工作,例如验证表单、显示微调器、发送数据等。 从概念上讲,这是精心设计的。 如果您需要调试其中一个函数,您可以遍历项目树来找出每个函数的作用。
然而,问题是 JavaScript 不会将这些函数中的每一个都作为单独的任务运行,因为它们是在 saveSettings() 函数中执行的。 这意味着所有五个功能都作为单个任务运行。
Important: JavaScript以这种方式工作,因为它使用了任务执行的run-to-completion模型。这意味着无论阻塞主线程多长时间,每个任务都将一直运行到完成为止。
调用五个函数的单个函数 saveSettings()。 这项工作作为一项长期整体任务的一部分运行。
在最好的情况下,即使只是其中一个函数也可以为任务的总长度贡献 50 毫秒或更多时间。 在最坏的情况下,更多的这些任务可以运行更长的时间——尤其是在资源受限的设备上。 接下来是一组策略,您可以使用这些策略来分解任务和确定任务的优先级。
Manually defer code execution
开发人员用来将任务分解为更小任务的一种方法涉及 setTimeout()。 使用这种技术,您可以将函数传递给 setTimeout()。 这会将回调的执行推迟到一个单独的任务中,即使您将超时指定为 0。
function saveSettings () {
// 做用户可见的关键工作:
validateForm();
showSpinner();
updateUI();
// 将用户不可见的工作推迟到单独的任务:
setTimeout(() => {
saveToDatabase();
sendAnalytics();
}, 0);
}
如果您有一系列需要按顺序运行的函数,这种方法很有效,但您的代码可能并不总是以这种方式组织。 例如,您可能有大量数据需要在循环中处理,如果您有数百万个项目,该任务可能需要很长时间。
function processData () {
for (const item of largeDataArray) {
// Process the individual item here.
}
}
在这里使用 setTimeout() 是有问题的,因为它的功效使其难以实现,并且整个数据数组可能需要很长时间才能处理,即使每个项目都可以处理得非常快。 所有这些加在一起,setTimeout() 不是完成这项工作的正确工具——至少在以这种方式使用时不是。
除了 setTimeout() 之外,还有一些其他 API 允许您将代码执行推迟到后续任务。 一种涉及使用 postMessage() 来实现更快的超时。 您也可以使用 requestIdleCallback() 分解工作——但要小心!——requestIdleCallback() 以尽可能低的优先级安排任务,并且只在浏览器空闲时间。 当主线程拥塞时,使用 requestIdleCallback() 安排的任务可能永远无法运行。
使用 async/await 创建让位点
您将在本指南的其余部分看到的一个短语是“屈服于主线程”——但这是什么意思? 你为什么要这样做? 你应该什么时候做?
Important: 当你让位于主线程时,你就给了它一个机会去处理比当前正在排队的任务更重要的任务。理想情况下,当您有一些关键的面向用户的工作需要更快地执行时,您应该让位于主线程。屈服于主线程为关键工作的更快运行创造了机会。
当任务被分解时,其他任务可以通过浏览器的内部优先级方案更好地排列优先级。 屈服于主线程的一种方法涉及使用通过调用 setTimeout() 解析的 Promise 的组合:
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}