前言
快速的用户行为反馈一直都是每个开发人员面对的挑战之一,在已知的范围内,面对用户性能方面的问题挑战在逐渐增多,过硬的硬件设备和优秀的网络环境并不能细致的解决掉我们项目当中存在的实际问题
页面渲染 一个老生常谈的话题,但是面对追求页面的极致渲染问题的条件下,面对的挑战也是不断的增加,在之前我通过实现一个 任务切片(task-slice) 的方式,尝试着对当前页面当中大量同时渲染而导致存在长任务 (long-task) 的情况进行了切片,这样可以极大程度的减少页面因存在同时渲染大量内容,而导致页面长时间白屏影响到用户体验,下面是通过 任务切片(task-slice) 实现前后的效果对比图:
新的开始
在上个月 29 号, Chrome
团队公布了新的 api
: scheduler.yield
,目前正在测试阶段,从 Chrome 115
开始将正式投入使用,scheduler.yield
的到来,允许我们可以通过更加简单的方式将页面渲染的过程实现更高程度的控制
任务切割中
Javascript
使用 run-to-completion
模型来处理任务,这意味着,当一个任务在主线程上运行时,该任务要运行多久才能完成。任务完成后控制权被交还给主线程,这使得主线程可以处理队列中的下一个任务
除了任务永远不会完成的特殊情况(死循环)之外,例如:任务切割是 Javascript
任务调度逻辑中不可避免的情况,它发生只是时间问题,越早越好,当任务运行时间过长(>= 50ms)时,它们被认为是长任务(long-task
)
长任务是页面响应差的一个原因,因为它们延迟了浏览器响应用户行为的速度。长任务出现的频率越高,它运行的时间越长,用户体验就越差,甚至无法正常使用
然而,仅仅因为代码在浏览器中启动了一个任务,但这并不意味必须在该任务完成后才将控制权交还给主线程。通过在明确对任务进行切割,可以提高对页面用户输入的反馈速度,这样任务分解为在下一个可用机会时完成。这允许其他任务在主线程上获得时间,而不是等待较长的任务完成
上图描述了如何分解任务可以更好的实现用户行为反馈,优化前长任务将阻止事件处理程序运行,直到任务完成。优化后简短的任务允许时间处理程序比正常情况下更快的运行
优化前,任务切割只发生在任务完成之后,这意味着任务在将控制权返回给主线程之前可能需要更长的时间才能完成。优化后任务切割是明确完成的,将一个长任务拆分成 N 个较小的任务,这样用户能更快的接受到行为反馈,从而提高响应速度和 INP
当你明确要进行 任务切割时,你会告诉浏览器 我要做一些事情,但不希望在反馈用户行为或者其他可能重要的任务之前必须完成所有这些工作 。它是开发人员工具中一个非常有价值的工具,可以很大程度上改进用户体验
收益问题
如果对于大家对于之前的任务切割方法比较熟悉的话(setTimeout)那么可以直接跳到下方关于
scheduler.yield
的部分
目前已知的方案有以下两种:
-
setTimeout
:把setTimeout
的延迟时间设置为0
,因为传递给setTimeout
的回调将把剩余的工作移动到一个单独的任务中,该任务将排队等待后续执行。而不是等待浏览器自己进行任务切割,这个时候我们就可以把长任务切割成一个个短小的任务但是使用
setTimeout
进行任务切割会有一个潜在的副作用:在任务切割之后进行的工作将赚到任务队列的后面 由用户交互调度的任务仍然会像它们应该的那样排在队列的前面,但在明确对任务进行切割之后,你想要做的剩余工作可能会因来自在其前面排队的竞争源的其他任务而进一步延迟 -
上方之前我提供的 任务切片(task-slice)
针对第二种情况,大家感兴趣可以点击链接查看,第一种情况,下方我给出了一个 demo
:
html
<!-- index.html -->
<!DOCTYPE html>
<html lang="en" dir="ltr">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>
Task chunking demo
</title>
<link rel="modulepreload" href="/js/scripts.js">
</head>
<body>
<h1>Yielding demo</h1>
<h2>Click the first button, then try the next two to see how different yielding strategies work.</h2>
<button id="setinterval" tabindex="0">
Run blocking tasks periodically (click me first)
</button>
<button id="settimeout" tabindex="0">
Run loop, yielding with <code>setTimeout</code> on each iteration
</button>
<button id="reload-demo" tabindex="0">
Reload demo
</button>
<div id="task-queue">
Task output will show up here.
</div>
<script src="/js/scripts.js" type="module"></script>
</body>
</html>
js
// script.js
const TASK_OUTPUT = document.getElementById("task-queue");
const MAX_TASK_OUTPUT_LINES = 10;
let taskOutputLines = 0;
let intervalId;
function blockingTask (ms = 200) {
let arr = [];
const blockingStart = performance.now();
console.log(`Synthetic task running for ${ms} ms`);
while (performance.now() < (blockingStart + ms)) {
arr.push(Math.random() * performance.now / blockingStart / ms);
}
}
function yieldToMain () {
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
function logTask (msg) {
if (taskOutputLines < MAX_TASK_OUTPUT_LINES) {
TASK_OUTPUT.innerHTML += `${msg}<br>`;
taskOutputLines++;
}
}
function clearTaskLog () {
TASK_OUTPUT.innerHTML = "";
taskOutputLines = 0;
}
async function runTaskQueueSetTimeout () {
if (typeof intervalId === "undefined") {
alert("Click the button to run blocking tasks periodically first.");
return;
}
clearTaskLog();
for (const item of [1, 2, 3, 4, 5]) {
blockingTask();
logTask(`Processing loop item ${item}`);
await yieldToMain();
}
}
document.getElementById("setinterval").addEventListener("click", ({ target }) => {
clearTaskLog();
intervalId = setInterval(() => {
if (taskOutputLines < MAX_TASK_OUTPUT_LINES) {
blockingTask();
logTask("Ran blocking task via setInterval");
}
});
target.setAttribute("disabled", true);
}, {
once: true
});
document.getElementById("settimeout").addEventListener("click", runTaskQueueSetTimeout);
document.getElementById("reload-demo").addEventListener("click", () => {
location.reload();
});
-
点击
Run blocking tasks periodically (click me first)
按钮,我们会打印出来一些内容,这些内容会读取到setInterval
运行阻塞任务 -
点击
Run loop, yielding with setTimeout on each iteration
按钮,在每次遍历中产生setTimeout
下方打印了 demo
当中点击 setTimeout
的 log
:
arduino
Processing loop item 1
Processing loop item 2
Ran blocking task via setInterval
Processing loop item 3
Ran blocking task via setInterval
Processing loop item 4
Ran blocking task via setInterval
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval
上方的输入说明了使用 setTimeout
返回时发生的 任务队列结束
行为,运行的循坏处理五个项目,并在处理完每个项目后产生 setTimeout
这说明了在 web
领域一个常见的问题:对于脚本(特别是第三方脚本)来说,注册一个以一定间隔运行工作的计时器函数很常见, setTimeout
带来的 任务队列结束
行为意味着来自其他任务源的工作可能会在循环在任务切割之后必须完成的剩余工作之前进入队列
这可能不是一个完美的结果,但是在许多情况下,这种行为就是开发同学不愿意如此轻易的放弃对主线程控制的原因。任务切割这一点就很好,因为用户可以更快的接受到行为反馈,而且它也允许其他非用户交互工作在主线程上获得时间
scheduler.yield
可以很好的解决这个问题
使用 scheduler.yield
scheduler.yield
从 Chrome 115
版本开始, scheduler。yield
就就作为一个实验性的 web
平台特性隐藏起来了,这个时候您可能会有疑问:"为什么我要使用一个新的函数 scheduler.yield
,而去做 setTimeout
已经做了"
这里有一个比较需要注意的点,任务切割并不是 setTimeout
的设计目标,而是调度毁掉在将来某个时间点运行时的一个很好的副作用------即使指定超时值为 0
setTimeout
的任务切割将剩余的工作送到任务队列的后,默认情况是调度器。它将剩余的工作放到队列的最前面,这意味着交付后立即恢复的工作不会让位给其他来源的任务(用户交互例外)
scheduler.yield
是一个向主线程进行任务切割并在调用时返回 Promise
的函数:
javascript
async function yieldy () {
// Do some work...
// ...
// Yield!
await scheduler.yield();
// Do some more work...
// ...
}
在 Chrome 115
之前想使用 scheduler.yield
,要做到以下几点:
- 浏览器地址输入
chrome://flags
- 启用 试验性 web 平台功能实验(Experimental Web Platform features),然后重启浏览器
- 在上述代码中添加以下片段
html
<!-- index.html -->
<button id="schedulerdotyield" tabindex="0">
Run loop, yielding with <code>scheduler.yield</code> on each iteration
</button>
javascript
// script.js
async function runTaskQueueSchedulerDotYield () {
if (typeof intervalId === "undefined") {
alert("Click the button to run blocking tasks periodically first.");
return;
}
if ("scheduler" in window && "yield" in scheduler) {
clearTaskLog();
for (const item of [1, 2, 3, 4, 5]) {
blockingTask();
logTask(`Processing loop item ${item}`);
await scheduler.yield();
}
} else {
alert("scheduler.yield isn't available in this browser :(");
}
}
document.getElementById("schedulerdotyield").addEventListener("click", runTaskQueueSchedulerDotYield);
- 先点击
Run blocking tasks periodically (click me first)
按钮 - 再点击
Run loop, yielding with scheduler.yield on each iteration
按钮,生成调度功能,每次遍历产生
对应的 log
如下所示:
arduino
Processing loop item 1
Processing loop item 2
Processing loop item 3
Processing loop item 4
Processing loop item 5
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
Ran blocking task via setInterval
上面的结果和 setTimeout
完全不同,可以看到循环(尽管它在每次遍历后生成)没有将剩余的工作发送到队列的后面,而是发送到队列的前面,这一点对于我们实际业务来说非常有用:你可以让用户快速的得到更多的反馈,也确保你后面的渲染不会延迟
基于 scheduler.yield
我们可以对上面的 yieldToMain
做些优化:
javascript
// A function for shimming scheduler.yield and setTimeout:
function yieldToMain () {
// Use scheduler.yield if it exists:
if ('scheduler' in window && 'yield' in scheduler) {
return scheduler.yield();
}
// Fall back to setTimeout:
return new Promise(resolve => {
setTimeout(resolve, 0);
});
}
// Example usage:
async function doWork () {
// Do some work:
// ...
await yieldToMain();
// Do some other work:
// ...
}
这是 scheduler.yield
的基础入门,用于说明它在默认情况下的优点,但是有一些高级的方法使用它,包括与 scheduler.postTask
以及具有明确优先级的能力
尾声
该 api
在8月底的时候由 Chrome
官方人员宣布推出,对于用户体验这条路上的选择一直都是我们每个开发人员需要注意和值得研究一生的课题,之前没有 scheduler.yield
之前我们也能做到类似的事情,但是做的不够彻底
但是这次,我们可以让用户体验行为做的更流畅!我们对于任务的切割渲染可以做的更加可控
因为官方文档是纯英文的,除了自己的认识也参考了官方文档的内容,如有理解不对而导致的翻译有误,希望大家能指出,谢谢🙏