优化耗时较长的任务

2024年11月18日 • ☕️ 4 min read

将长任务拆分成多个短任务

在任何长度的任务运行期间,浏览器都会阻止发生互动,但只要任务运行时间不太长,用户就不会察觉到这一点。如果有许多耗时任务正在运行,当用户尝试与网页互动时,界面会感觉无响应,如果主线程被阻塞很长时间,界面甚至可能会崩溃。

根据 Google 的 RAIL (Response, Animation, Idel, Load ) 模型,为了不让用户感受到卡顿,我们应该把每个任务控制在 50 ms。用时超过 50ms 的任务被称为长任务,任务的总时间减去 50 毫秒称为任务的阻塞时段

image

Chrome 性能分析器中显示的耗时较长的任务。长任务的角落会显示一个红色三角形,任务的阻塞部分会填充红色对角线条纹图案。

为了防止主线阻塞时间过长,导致出现用户能明显感知的卡顿现象,我们需要考虑将长任务拆分成多个较小的短任务。

image

一个长任务与将该任务拆分为五个较短任务的可视化效果

怎么做

demo 请看 这个仓库

假设有一个这样的任务:

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

里面的每个小任务都需要花费 30ms,所以 saveSettings 总耗时 150ms,是一个长任务

Performance 面板火焰图:

image

它会在一个长任务内执行这五个子任务

setTimeout

通过 setTimeout 执行的任务虽然也是宏任务,但是使用 setTimeout 可以将任务延迟到下一个任务中执行

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

Performance 面板火焰图:

image

可以看到通过 setTimeout 将一个长任务分成了两个任务(忽略这两个任务还是长任务哈哈)

仔细看上面的两个火焰图可以发现,我使用黄色框标注了一个 otherTask 任务。这个任务在 saveSettings 任务后面执行,并且预期希望在 saveSettings 的所有子任务执行结束之后才执行 otherTask。

然后再第二个图里可以发现使用 setTimeout 拆分任务的弊端:任务的执行顺序会受到影响

通过嵌套 setTimeout 可以解决这个问题,或者使用更优雅的 Promise await:

async function yieldToMain() {
	return new Promise(setTimeout)
}

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

	for (const task of tasks) {
		task()
		await yieldToMain();
	}
}

Performance 面板火焰图:

image

可以看到 otherTask 已经符合预期的执行顺序了,并且所有子任务都被拆分到了单独的任务中(这次的所有任务都不是长任务了)

scheduler

接下来是真正推荐的做法

scheduler.postTask

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

调度器 API 提供 postTask() 函数,用于更精细地调度任务,是帮助浏览器确定工作优先级、将低优先级任务让给主线程的一种方法。postTask() 使用 promise,并接受以下三种 priority 设置之一:

  • background(最低优先级任务)。
  • user-visible 表示中等优先级任务。如果未设置 priority,则这是默认值。
  • user-blocking - 适用于需要以高优先级运行的关键任务。

通过 scheduler.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'});
};

对于 saveSettings 的五个子任务,saveToDatabasesendAnalytics 是用户无需感知的任务,所以优先级应该是最低的。

Performance 面板火焰图:

image

可以看到,长任务被拆分成了五个短任务,并且被设置为低优先级的 saveToDatabase 任务被放到了 updateUI 任务后面执行

scheduler.yield()

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

scheduler.yield() 是一个专门用于让出浏览器中主线程的 API。它的用法类似于前面演示的 yieldToMain()

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

	for (const task of tasks) {
		task()
		await scheduler.yield();
	}
}

Performance 面板火焰图:

image

scheduler.yield 和前面的 yieldToMain 有什么区别?

(web.dev 上关于这部分的描述)[https://web.dev/articles/optimize-long-tasks?hl=zh-cn#scheduler-dot-yield] 我暂时还没理解,所以通过以下方式感受一下两者的区别:

浏览器开发者工具 > 设置 > 实验 > 开启 Performance panel: show all events 之后,scheduler.yield 和 yieldToMain Performance 上的火焰图分别如下:

scheduler.yield: image

yieldToMain: image

观察火焰图可以发现,使用 scheduler.yield 时,otherTask 和前面的五个子任务是在同一个 ThreadController 中的,而使用 yieldToMain 时 otherTask 是在另一个 ThreadController 中执行的。

这似乎也能帮助理解 web.dev 中的说法:

scheduler.yield() 的好处在于能够继续执行,这意味着,如果您在一组任务中途让出,其他已安排的任务将在让出点之后按相同的顺序继续执行。这样可以避免来自第三方脚本的代码中断代码的执行顺序。

兼容性问题

Scheduler API 目前兼容性还不是很好,可以通过 https://github.com/GoogleChromeLabs/scheduler-polyfill 库实现在不支持的浏览器上使用该 API

通过 npm 安装

npm install scheduler-polyfill
import 'scheduler-polyfill'

通过 script 引入

<script src="https://unpkg.com/scheduler-polyfill"></script>

附录

浏览器每一帧的渲染过程顺序

  1. 用户事件。
  2. 一个宏任务。
  3. 队列中全部微任务。
  4. requestAnimationFrame。
  5. 浏览器重排/重绘。
  6. requestIdleCallback。

image

https://web.dev/articles/optimize-long-tasks?hl=zh-cn#scheduler-api

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

前端性能优化——让你的长任务保持在50ms 内

腾讯文档在线表格卡顿指标探索之路

https://github.com/GoogleChromeLabs/scheduler-polyfill