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

前言

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

页面渲染 一个老生常谈的话题,但是面对追求页面的极致渲染问题的条件下,面对的挑战也是不断的增加,在之前我通过实现一个 任务切片(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 当中点击 setTimeoutlog:

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

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

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

相关推荐
蟾宫曲4 小时前
在 Vue3 项目中实现计时器组件的使用(Vite+Vue3+Node+npm+Element-plus,附测试代码)
前端·npm·vue3·vite·element-plus·计时器
秋雨凉人心4 小时前
简单发布一个npm包
前端·javascript·webpack·npm·node.js
liuxin334455664 小时前
学籍管理系统:实现教育管理现代化
java·开发语言·前端·数据库·安全
qq13267029404 小时前
运行Zr.Admin项目(前端)
前端·vue2·zradmin前端·zradmin vue·运行zradmin·vue2版本zradmin
Amd7944 小时前
PostgreSQL 数据库的启动与停止管理
postgresql·性能优化·数据库管理·故障处理·日常维护·启动数据库·停止数据库
魏时烟5 小时前
css文字折行以及双端对齐实现方式
前端·css
哥谭居民00016 小时前
将一个组件的propName属性与父组件中的variable变量进行双向绑定的vue3(组件传值)
javascript·vue.js·typescript·npm·node.js·css3
踢足球的,程序猿6 小时前
Android native+html5的混合开发
javascript
2401_882726486 小时前
低代码配置式组态软件-BY组态
前端·物联网·低代码·前端框架·编辑器·web
web130933203986 小时前
ctfshow-web入门-文件包含(web82-web86)条件竞争实现session会话文件包含
前端·github