Skip to content

浏览器也拥有了原生的“时间切片”能力

就在 Chrome 115 版本,浏览器开始了对 scheduler.yield 的灰度测试。scheduler.yieldscheduler API 中新增的一个功能,它能以更简单、更好的方式将控制权交还给主线程。在开始讲解这个 API 之前,我们先来看一个新的性能指标。

下次绘制交互 (INP)

下次绘制交互 (INP) 是一项新的指标,浏览器计划于 2024 年 3 月将其取代取代首次输入延迟 (FID) ,成为最新的 Web Core VitalsChrome 使用数据显示,用户在页面上花费的时间有 90% 是在网页加载完成后花费的,因此,仔细测量整个页面生命周期的响应能力是非常重要的,这就是 INP 指标评估的内容。

良好的响应能力意味着页面可以快速响应并且与用户进行的交互。当页面响应交互时,最直接的结果就是视觉反馈,由浏览器在浏览器渲染的下一帧中体现。例如,视觉反馈会告诉我们是否确实添加了购物车的商品、是否快读打开了导航菜单、服务器是否正在对登录表单的内容进行身份验证等等。INP 的目标就是确保对于用户进行的所有或大多数交互,从用户发起交互到绘制下一帧的时间尽可能短。

INP 是一种指标,通过观察用户访问页面的整个生命周期中发生的所有单击、敲击和键盘交互的延迟来评估页面对用户交互的整体响应能力。

image.png

交互是在同一逻辑用户手势期间触发的一组事件处理程序。例如,触摸屏设备上的 “点击” 交互包括多个事件,例如 pointerup、pointerdownclick。交互可以由 JavaScript、CSS、内置浏览器控件或其组合驱动。

image-20230829165248057

交互的延迟就是由驱动交互的这一组事件处理程序的单个最长持续时间组成的,从用户开始交互到渲染下一帧视觉反馈的时间。

INP 考虑的是所有页面的交互,而首次输入延迟 (FID) 只会考虑第一次交互。而且它只测量了第一次交互的输入延迟,而不是运行事件处理程序所需的时间或下一帧渲染的延迟。

浏览器希望使用 INP 替代 FID 就意味着用户的交互体验越来越重要了,我们常常听到的时间切片的概念,实际上就是为了提升网页的交互响应能力。

时间切片

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

除了任务永远不会完成的极端情况(例如无限循环)之外,屈服是 JavaScript 任务调度逻辑不可避免的一个方面。屈服迟早会发生,只是时间问题,而且越早越好。当任务运行时间过长(准确地说超过 50 毫秒)时,它们会被视为长任务。

长任务是页面响应能力差的一个根源,因为它们延迟了浏览器响应用户输入的能力。长任务发生的次数越多,而且运行的时间越长,用户就越有可能感觉到页面运行缓慢,甚至感觉页面完全挂掉了。

不过,代码在浏览器中启动任务并不意味着必须等到任务完成后才能将控制权交还给主线程。你可以通过在任务中明确交出控制权来提高对页面上用户输入的响应速度,这样就能在下一个合适的时间来完成任务。这样,其他任务就能更快地在主线程上获得时间,而不必等待长任务的完成。

image-20230829165433144

这张图可以很直观的显示:在上面的执行中,只有在任务运行完成后才会交还控制权,这意味着任务可能需要更长时间才能完成,然后才会将控制权交还给主线程。在下面,控制权交还是主动进行的,将一个较长的任务分解成多个较小的任务。这样,用户交互可以更快地运行,从而提高输入响应速度和 INP

当我们想要明确屈服时,就是在告诉浏览器 “嘿,我知道我要做的工作可能需要一段时间,并且我不希望你在响应用户输入之前必须完成所有这些工作或其他可能也很重要的任务”。

听起来这个是不是很熟悉?这其实就是我们常说的 “时间切片” 的概念,之前你听到可能还是在 React 的理念里,因为它是最早提出这个能力的前端框架。我们再来回顾下面这个典型的例子:

旧版 React 架构是递归同步更新的,如果节点非常多,即使只有一次 state 变更,React 也需要进行复杂的递归更新,更新一旦开始,中途就无法中断,直到遍历完整颗树,才能释放主线程。

image.png

当渲染的层级很深时,递归更新时间超过了16ms,如果这时有用户操作或动画渲染等,就会表现为卡顿

640.gif

后来,React 实现了自己的 Scheduler ,它可以将一次耗时很长的更新任务被拆分成一小段一小段的。这样浏览器就有剩余时间执行样式布局和样式绘制,减少掉帧的可能性。

image.png

每个小的任务完成后,控制权就会交还给主线程,浏览器就有了时间去及时的完成用户的交互或页面的绘制,所以页面会很丝滑:

640 (1).gif

在原生的 JavaScript 代码,或者其他框架中我们也想要这样的能力怎么办?

使用 setTimeout

一种常见的过渡方法是使用时间为 0 的 setTimeout。 这种方法之所以有效,是因为传递给 setTimeout 的回调会将剩余工作转移到一个单独的任务中,这个任务将排队等待后续执行,这样也可以实现把一大块工作分成更小的部分。

但是,使用 setTimeout 进行屈服可能会带来不良的副作用:屈服之后的工作将进入任务队列的最尾部。通过用户交互安排的任务仍会排在任务队列的前面,但你想做的剩余工作可能会被排在它前面的其他任务进一步延迟。 我们可以看一个下面的示例:

html
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width,initial-scale=1" />
    <title>Task chunking demo</title>
  </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="schedulerdotyield" tabindex="0">
      Run loop, yielding with <code>scheduler.yield</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>
  </body>
  <script>
    console.log(11);
    function yieldToMain() {
      return new Promise((resolve) => {
        setTimeout(resolve, 0);
      });
    }

    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);
      }
    }

    const TASK_OUTPUT = document.getElementById('task-queue');
    const MAX_TASK_OUTPUT_LINES = 10;
    let taskOutputLines = 0;
    let intervalId;

    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();
      }
    }

    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('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('schedulerdotyield')
      .addEventListener('click', () => {
        runTaskQueueSchedulerDotYield();
      });

    document.getElementById('reload-demo').addEventListener('click', () => {
      location.reload();
    });
  </script>
</html>

在线地址: codepen

我们先通过 setinterval 来定期执行一些任务,下面我们来使用 setTimeout 来模拟时间切片,将长任务进行拆解,我们会得到下面这样的打印结果:

shell
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 来拆解长任务意味着,来自其他任务源的工作可能会排在退出事件循环后必须完成的剩余工作之前。

这也许能够起到一定的作用,但在许多情况下,这种行为是开发者不愿轻易放弃主线程控制权的原因。能主动交出控制权是好事,因为用户交互有机会更快地运行,但它也会让其他非用户交互的工作在主线程上获得时间。这确实是个问题,scheduler.yield 可以帮助解决这个问题!

scheduler.yield

我们需要注意一下,交出主线程控制权并不是 setTimeout 的设计目标,它的核心目标是能在未来某个时间完成某个任务,所以它会把任务中的工作排在队列的最后面。

但是,与之相反,默认情况下,scheduler.yield 会将剩余的工作发送到队列的前面。这意味着你想要在 yield 后立即恢复的工作不会让位于其他来源的任务(用户交互除外)。

scheduler.yield 是一个向主线程主动屈服并在调用时返回 Promise 的函数。这意味着你可以在异步函数中等待它:

javascript
async function yieldy () {
  // Do some work...
  // ...

  // Yield!
  await scheduler.yield();

  // Do some more work...
  // ...
}

还是使用前面的例子,这次我们使用 scheduler.yield 进行等待:

javascript
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);
  });
}

const TASK_OUTPUT = document.getElementById("task-queue");
const MAX_TASK_OUTPUT_LINES = 10;
let taskOutputLines = 0;
let intervalId;

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();
  }
}

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("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("schedulerdotyield").addEventListener("click", () => {
  runTaskQueueSchedulerDotYield();
});

document.getElementById("reload-demo").addEventListener("click", () => {
  location.reload();
});

在线地址:scheduler.yield

我们会发现打印的结果是这样的:

shell
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

这样就可以达到两全其美的效果:既能将长任务进行分割,主动给主线程让出控制权来提高网站的交互响应速度,又能确保让出主线程后要完成的工作不会被延迟。

试用

如果大家对 Scheduler.yield 感兴趣并且想尝试一下,从 Chrome 115版本开始可以: 打开 chrome://flags ,然后选择启用 Experimental Web Platform Features ,这样就可以使用 Scheduler.yield 了。

image.png

也可以尝试使用官方提供的 Polifill :

GitHub - GoogleChromeLabs/scheduler-polyfill: A polyfill for self.scheduler

如果在业务代码里使用,为了兼容不支持的低版本浏览器,可以在不支持时回退到 setTimeout 写法:

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:
  // ...
}

当然,如果你不想让你的任务被其他任务延迟掉,也可以在不支持这个 API 时选择不屈服:

javascript
// A function for shimming scheduler.yield with no fallback:
function yieldToMain () {
  // Use scheduler.yield if it exists:
  if ('scheduler' in window && 'yield' in scheduler) {
    return scheduler.yield();
  }

  // Fall back to nothing:
  return;
}

// Example usage:
async function doWork () {
  // Do some work:
  // ...

  await yieldToMain();

  // Do some other work:
  // ...
}

参考

前端知识体系 · wcrane