- Published on
优化长任务
我们知道 渲染线程
和 js线程
是互斥的,两者没办法同时执行,所以 长时间的占有 js 线程
会导致挂起
主线程,引发用户体验卡顿
会想到两种优化方向:
不阻塞主线程
分解长任务
那要如何优化js线程
中任务的执行效率,首先要弄懂浏览器处理任务的机制
浏览器中的任务
主线程一次只能处理一个任务,当任务执行时间超过50ms
时,会被标记为长任务
如果正在运行长任务时,尝试与页面进行交互,或者说需要进行优先级更高的渲染,浏览器将延迟执行,会导致交互或渲染出现延迟
谷歌性能剖析中的长耗时如图所示,一般会在任务角上用红色三角形标出来,其中被阻塞的任务部分用红色细斜条纹标出来
优化长任务,可以想到分解为几个耗时相对较短的任务
拆分长任务后,浏览器就有了更多的机会,可以去处理优先级别更高的工作,其中就包括用户交互行为.
如果任务非常长,这时浏览器就没法快速处理用户交互,但拆分长任务后的从图中能看到效果就不一样.
因为长任务
的缘故,用户交互产生的事件处理就必须排队,等待长任务执行完后才能执行.这个时候就会导致用户交互的延迟
.当拆分成较短的任务后,事件处理器就有机会更快的触发. 因为事件处理器能够在短任务之间得以执行,也就比长任务耗时更短.在长耗时的图片中,用户可能就会感到卡顿;长任务拆分后,用户可能就感觉体验很流畅.
任务管理策略
可以尝试把一个任务拆分成更细维度
const saveSettings = () => {
validateForm()
showSpinner()
saveToDatabase()
updateUI()
sendAnalytics()
}
然而,这样也有问题,就是 js 并不是为每个方法开辟一个单独的任务,因为这些方法都包含在 saveSetting
这个函数中,也就是说这五个方法在一个任务中执行
在 js 中,只有一个任务结束才会执行下一个任务,而且不论这个任务会阻塞主线程多久
saveSetting
这个函数调用 5 个函数,这个函数的执行看起来就像一个特别长的长的任务.
使用 async、await 来创造让步点
分解任务后,按照浏览器内部的优先级别划分,其他的任务可能优先级别调整的会更高.一种让步于主线程的方式是配合用了 setTimeout
的 promise
const yieldToMain = () => new Promise((resolve) => void setTimeout(resolve, 0))
尽管这个例子在返回 promise
中通过 setimeout
来调用 resolve
,但此时并不是新开一个任务让 promise
执行后续代码,而是通过 setTimeout
调用.因为 promise
的回调属于微任务,因此不会让步于主线程.
在 saveSettings
的函数中,可以在每次 await
函数 yieldToMain
后让步于主线程:
const saveSettings = async () => {
// 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()
}
}
并不是所有函数调用都要让步于主线程.如果两个函数的结果在用户界面上有重要的更新,最好就不要这样做.如果可以,可以想让任务执行,然后考虑在那些不重要的函数或者能在后台运行的函数之间让步.
saveSettings
函数现在将其子函数作为单独的任务执行
只在必要时让步
假如有一堆的任务,但是只想在用户交互的时候才让步,该怎么办?正好有这种 api isInputPending
isInputPending
这个函数可以在任何时候调用,它能判断用户是否要与页面元素进行交互.调用 isInputPending
会返回布尔值,true 代表要与页面元素交互,false 则不交互.
const saveSettings = async () => {
// 函数队列
const tasks = [validateForm, showSpinner, saveToDatabase, updateUI, sendAnalytics]
while (tasks.length > 0) {
// 让步于用户输入
if (navigator.scheduling.isInputPending()) {
// 如果有用户输入在等待,则让步
await yieldToMain()
} else {
// Shift the the task out of the queue:
const task = tasks.shift()
// Run the task:
task()
}
}
}
在 saveSetting
执行过程中,会逐个循环队列中的任务.如果循环时 isInputPending
结果返回真,saveSetting
就会调用 yieldToMain
函数,这样就能处理用户输入的事件,反之,就会走到队列继续执行下一个,直到队列执行完.
saveSetting
这个任务队列中有 5 个任务,但此时如果正在执行第二个任务而用户想打开某个菜单,于是点击了这个菜单,isInputPending
就会让步,让主线程处理交互事件,同时也会稍后执行后面剩余的任务. 用户输入后 isInputPending
的返回值不一定总是 true,这是因为操作系统需要时间来通知浏览器交互结束,也就是说其他代码可能已经开始执行,比如截图例子中的 saveToDatabase
这个方法可能已经在执行了.即便使用 isInputPending
,还是需要在每个方法限制任务中的方法数量. 使用 isInputPending
配合让步的策略,能让浏览器有机会响应用户的重要交互,这在很多情况下,尤其是很多执行很多任务时,能够提高页面对用户的响应能力.
另一种使用 isInputPending
的方式,特别是担心浏览器不支持该策略,就可以使用另一种结合时间的方式.
const saveSettings = async () => {
// 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 提供 postTask
的功能,对于所有的 chromium
浏览器和 firefox 均可使用.postTask
允许更细粒度的编排任务,该方法能让浏览器编排任务的优先级,以便地优先级别的任务能够让步于主线程.目前 postTask
使用 promise
,接受优先级这个参数设定. postTask
方法有三个优先级别:
background
级,适用于优先级别最低的任务user-visible
级,适用于优先级别中等的任务,如果没有入参,也是该函数的默认参数.user-blocking
级,适用于优先级别最高的任务.
拿下面的代码来举例,postTask
在三处分别都是最高优先级别,其他的另外两个任务优先级别都是最低.
拿下面的代码来举例,postTask
在三处分别都是最高优先级别,其他的另外两个任务优先级别都是最低.
const 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 这个方法类似.
const saveSettings = async () => {
// 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
,而是使用了 scheduler.yield
方法.
下面三幅图分别是
- 不使用 yield
- 使用 yield
- 使用 yield 且不中断
不使用 yield,出现了长耗时任务。使用 yield,短任务数量变多了,而且还能被其他不相关的任务打断。使用 yield 且不中断,里面的短任务更多,但是执行顺序是固定的。 上面便是三种情况的效果图。使用 scheduler.yield
方法时,任务能在每次让步停止后重新开始。 使用 scheduler.yield
的好处是不中断,也就意味着如果是在一连串任务中 yield,那么从 yield 的时间点开始,其他编排好的任务的执行会继续。这就能避免第三方 js 脚本代码阻塞代码的执行
总结
- 遇到关键任务和用户侧的任务需要让步于主线程
- 使用
isInputPending
来让步主线程让用户可以与页面交互 - 适应
postTask
来调整任务的优先级 - 最后,每个函数尽可能地减少活动