likes
comments
collection
share

【性能优化篇】迎接新的api,让我们更好控制渲染行为(scheduler.yield)

作者站长头像
站长
· 阅读数 9

前言

快速的用户行为反馈一直都是每个开发人员面对的挑战之一,在已知的范围内,面对用户性能方面的问题挑战在逐渐增多,过硬的硬件设备和优秀的网络环境并不能细致的解决掉我们项目当中存在的实际问题

【性能优化篇】迎接新的api,让我们更好控制渲染行为(scheduler.yield)

新的开始

在上个月 29 号, Chrome 团队公布了新的 api: scheduler.yield,目前正在测试阶段,从 Chrome 115 开始将正式投入使用,scheduler.yield 的到来,允许我们可以通过更加简单的方式将页面渲染的过程实现更高程度的控制

任务切割中

Javascript 使用 run-to-completion 模型来处理任务,这意味着,当一个任务在主线程上运行时,该任务要运行多久才能完成。任务完成后控制权被交还给主线程,这使得主线程可以处理队列中的下一个任务

除了任务永远不会完成的特殊情况(死循环)之外,例如:任务切割是 Javascript 任务调度逻辑中不可避免的情况,它发生只是时间问题,越早越好,当任务运行时间过长(>= 50ms)时,它们被认为是长任务(long-task

长任务是页面响应差的一个原因,因为它们延迟了浏览器响应用户行为的速度。长任务出现的频率越高,它运行的时间越长,用户体验就越差,甚至无法正常使用

然而,仅仅因为代码在浏览器中启动了一个任务,但这并不意味必须在该任务完成后才将控制权交还给主线程。通过在明确对任务进行切割,可以提高对页面用户输入的反馈速度,这样任务分解为在下一个可用机会时完成。这允许其他任务在主线程上获得时间,而不是等待较长的任务完成

【性能优化篇】迎接新的api,让我们更好控制渲染行为(scheduler.yield) 上图描述了如何分解任务可以更好的实现用户行为反馈,优化前长任务将阻止事件处理程序运行,直到任务完成。优化后简短的任务允许时间处理程序比正常情况下更快的运行

优化前,任务切割只发生在任务完成之后,这意味着任务在将控制权返回给主线程之前可能需要更长的时间才能完成。优化后任务切割是明确完成的,将一个长任务拆分成 N 个较小的任务,这样用户能更快的接受到行为反馈,从而提高响应速度和 INP

当你明确要进行 任务切割时,你会告诉浏览器 我要做一些事情,但不希望在反馈用户行为或者其他可能重要的任务之前必须完成所有这些工作 。它是开发人员工具中一个非常有价值的工具,可以很大程度上改进用户体验

收益问题

如果对于大家对于之前的任务切割方法比较熟悉的话(setTimeout)那么可以直接跳到下方关于 scheduler.yield 的部分

目前已知的方案有以下两种:

针对第二种情况,大家感兴趣可以点击链接查看,第一种情况,下方我给出了一个 demo

<!-- 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>

// 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 当中点击 setTimeoutlog:

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.yieldChrome 115 版本开始, scheduler。yield 就就作为一个实验性的 web 平台特性隐藏起来了,这个时候您可能会有疑问:“为什么我要使用一个新的函数 scheduler.yield,而去做 setTimeout 已经做了”

这里有一个比较需要注意的点,任务切割并不是 setTimeout 的设计目标,而是调度毁掉在将来某个时间点运行时的一个很好的副作用——即使指定超时值为 0

setTimeout 的任务切割将剩余的工作送到任务队列的后,默认情况是调度器。它将剩余的工作放到队列的最前面,这意味着交付后立即恢复的工作不会让位给其他来源的任务(用户交互例外)

scheduler.yield 是一个向主线程进行任务切割并在调用时返回 Promise 的函数:

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),然后重启浏览器
  • 在上述代码中添加以下片段
<!-- index.html -->
<button id="schedulerdotyield" tabindex="0">
Run loop, yielding with <code>scheduler.yield</code> on each iteration
</button>
// 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 如下所示:

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 做些优化:

// 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 之前我们也能做到类似的事情,但是做的不够彻底

但是这次,我们可以让用户体验行为做的更流畅!我们对于任务的切割渲染可以做的更加可控

因为官方文档是纯英文的,除了自己的认识也参考了官方文档的内容,如有理解不对而导致的翻译有误,希望大家能指出,谢谢🙏