原文链接:原文链接
系统网站应用出现过卡顿,但却不知道如何优化。国内第一篇讲如何减少卡顿的代码级别详细文章,也是性能优化系列文章中的一篇,欢迎点赞、关注,也欢迎对其中的内容进行评论。
经常听人说,“不要阻塞主线程”,或者“减少长耗时”,该如何做呢?
聊网站性能的文章有很多,通常为了提高js性能,避不开这两点:
- 不要阻塞主线程
- 减少长耗时
该怎么做呢?很明显,精简js代码有好处,但更少的代码量是否就一定意味着用户界面的体验会更顺畅?可能会,但也可能恰恰相反。
要弄懂优化js中任务的重要性,首先需要了解什么是任务、任务的角色以及浏览器的任务处理机制。
浏览器中的任务
浏览器执行的任务之间是相互独立的,像页面渲染,html和css的解析,以及执行js代码都属于任务的范畴。虽然开发者不能直接控制这些任务,但毫无疑问的是,浏览器中的任务主要源自开发者编写和部署的代码。
什么是主线程?
浏览器绝大多的任务都发生在主线程,其主线程名称的由来也主要是因为:几乎所有js都在主线程运行。
主线程每次只能处理一个任务,当任务耗时超过特定时间,比如50ms就会被归类为长耗时。如果发生长耗时时存在用户交互,或者关键渲染更新时,浏览器就会延后再处理用户交互,这会直接导致用户交互或者渲染出现延迟。
在上图中能看到单个长任务和被拆分成了5个短任务的对比图。
为什么需要拆分任务非常重要?因为拆分长任务后,浏览器就有了更多的机会,可以去处理优先级别更高的工作,其中就包括用户交互行为。
长任务的缘故,用户交互产生的事件处理就必须排队,等待长任务执行完后才能执行。这个时候就会导致用户交互的延迟。当拆分成较短的任务后,事件处理器就有机会更快的触发。 因为事件处理器能够在短任务之间得以执行,也就比长任务耗时更短。在长耗时的图片中,用户可能就会感到卡顿;长任务拆分后,用户可能就感觉体验很流畅。
然而问题来了,那就是减少长耗时到底该怎么做,不要阻塞主线程写的也不够明确。这篇文章便为你解开这些神秘的面纱。
任务管理策略
软件架构最通用的意见就是将任务拆分成多个函数,这不仅能增强代码可读性,也让项目更容易维护,当然这样也更容易写测试。
function saveSettings () { validateForm(); showSpinner(); saveToDatabase(); updateUI(); sendAnalytics();}
在上面的例子中,该函数saveSettings
调用了另外5个函数,包括验证表单、展示加载的动画、发送数据等。理论上讲,这是很合理的架构。如果需调试这些功能,也只需要在项目中各自查找每个函数即可。
然而,这样也有问题,就是js并不是为每个方法开辟一个单独的任务,因为这些方法都包含在saveSetting
这个函数中,也就是说这五个方法在一个任务中执行
重点提示
js遵循执行才编译的原理,也就是说,只有一个任务结束才会执行下一个任务,而且不论这个任务会阻塞主线程多久。
使用代码延迟任务执行
为了拆分长任务,开发者经常使用定时器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
这个api编排的任务可能永远不会执行。
使用asycn、await来创造让步点
在本文会出现一个新词让步
,这个词的定义、用法和意义会在后面介绍。
重点提示
当让步于主线程后,浏览器就有机会处理那些更重要的任务,而不是放在队列中排队。理想状态下,一旦出现用户界面级别的任务,就应该让步给主线程,让任务更快的执行完。让步于主线程让更重要的工作能更快的完成
分解任务后,按照浏览器内部的优先级别划分,其他的任务可能优先级别调整的会更高。让步于主线程的一种方式配合是用了setTimeout的promise。
function yieldToMain () { return new Promise(resolve => { setTimeout(resolve, 0); });}
注意
上面这个例子会在promise中通过setimeout来调用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
这个任务队列中有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 the task out of the queue: const task = tasks.shift(); // Run the task: task(); }}
使用这种方式,通过结合时间来兼容不支持isInputPending
的浏览器,尤其是使用截止时间或者在特定时间点,让工作能在适当时候中断,不论是通过让步于用户输入还是在特定时间节点。
##当前API的差异
目前提到的api对于拆解任务都有帮助,但也有弊端:让步与主线程则意味着延迟代码稍后执行,即该部分代码被添加到稍后的事件队列中去了。
如果能控制页面中所有的代码,就可以编排各个任务的优先级,但是第三方js脚本可能不会服从安排。实际上,也不可能真正意义上给所有的任务排优先级,而是只能让他们成堆,或者是让步于特定的用户交互行为。
幸运的是,有一个专门编排优先级的api正在开发中,相信能够解决这些问题。
专门编排优先级的api
目前在书写本文时该api提供postTask
的功能,对于所有的chromium浏览器和firefox均可使用。postTask
允许更细粒度的编排任务,该方法能让浏览器编排任务的优先级,以便地优先级别的任务能够让步于主线程。目前postTask
使用promise,接受优先级这个参数设定。
postTask
方法有三个优先级别:
background
级,适用于优先级别最低的任务user-visible
级,适用于优先级别中等的任务,如果没有入参,也是该函数的默认参数。user-blocking
级,适用于优先级别最高的任务。
拿下面的代码来举例,postTask
在三处分别都是最高优先级别,其他的另外两个任务优先级别都是最低。
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'});};
在上面例子中,通过这些任务的优先级的编排方式,能让高浏览器级别的任务,比如用户交互等得以触发。
内置不中断的让步方法
还有一个编排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
方法。
前端可观测性的宣讲-1022
对前端性能优化的一些小看法
《网站性能优化技巧》巧用 “ 火焰图 ” 快速分析链路性能
《前端应用性能应该采集的数据》
《网站性能之单页面应用的杂谈》
《web应用简析》
《裸奔的前端绿皮车》
《快速搭建全链路平台》
《报错/卡顿是制约产品体验的关键因素》
《VIP客户用户体验-追踪方案草稿》
《四个简单例子教你提高用户体验》
参考链接
long task
promise
event loop
main thread
task controller
web performance