如何优化 JS 代码执行,避免出现阻塞?

1,663 阅读4分钟

前言

最近在工作中遇到这样一个场景:

相同的产品模块,其他账号登录进来用户体验上一切正常,但有一个客户账号进入后,总会有 3 ~ 5 S 的卡顿,也就是说这段时间页面点击是没有任何响应的。这对于用户体验肯定无法接受。

经过排查定位分析后,发现造成这一情况的根本原因是这里接入了产品的 IM 系统,而这个用户的花名册 List 非常之多,导致业务代码中根据花名册 List 在做 DOM 操作时耗时非常长(同步操作 - 阻塞)。

这就导致 JS 代码占据了浏览器执行线程,从而出现页面卡顿、操作无响应等情况。

requestIdleCallback 会在浏览器每一帧空闲时执行「不重要且不紧急」的任务,来避免影响到高优先级任务(事件、动画),造成页面卡顿。

requestIdleCallback 存在兼容问题,Safari 浏览器根本不支持它。polyfill 实现可参考:react requestHostCallback。

React Scheduler - 时间切片

requestIdleCallback

在了解 requestIdleCallback 前需要先铺垫一些前置知识。

  1. 屏幕刷新率: 大多数液晶屏设备的频率是 60 次/秒,每一次代表了一帧的绘制,每一帧分到的时间是 1000/60 ≈ 16 ms,当每秒绘制的帧数(FPS)达到 60 时,页面是流畅的,小于这个值时,用户会感觉到卡顿,所以在写代码时力求不让一帧的工作量超过 16 ms。

  2. 帧: 一个帧(16.6 ms)的工作流程包含:

  1. 输入事件(如:阻塞 - touch、非阻塞 - click)--> 2) 处理 JS 定时器 --> 3) 处理开始帧对应的事件(如:window.resize、scroll)--> 4) rAF回调(请求动画渲染) --> 5) 页面布局(样式计算、更新布局) --> 6) 样式绘制。

六个阶段完成之后进入空闲阶段(requestIdleCallback)。

现在我们知道:requestIdleCallback 会在渲染每一帧的剩余空闲时间执行,它能够根据浏览器是否空闲来执行一些操作。

假如,如果这一帧的 6 个任务(优先级最高)执行完毕后,用了 11 ms,浏览器会将剩余的 5 ms 控制权交由 requestIdleCallback 执行回调任务。

如果第一个回调任务执行完毕后还有剩余时间,会继续执行下一个;如果这一个回调任务执行时间要比 5 ms 长,等这个任务执行完毕后归还给浏览器控制权,并申请下一个时间片,浏览器也继续执行下一帧的任务。

这种调度方式叫做合作式调度,是让浏览器相信用户写的代码,如果客户端或者说用户写代码的时候,执行时间超过给的剩余时间,浏览器没有办法,只能卡死在那里等待执行完成。

下面,我们通过一个 Demo 来感受一下 requestIdleCallback 所带来的体验。

1、示例

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
</head>
<body>
  <button id="btn1">按钮1</button>
  <button id="btn2">按钮2</button>
  
  <script>
    const dataLength = 4000;
    const start = Date.now();

    // 同步阻塞方式
    for (let i = 0; i < dataLength; i ++) {
      for (let j = 0; j < dataLength; j ++) {
        // DOM 操作严重影响程序执行效率
        const btn1Attr = document.getElementById('btn1').attributes;
        const btn2Attr = document.getElementById('btn2').attributes;
        const btn3Attr = document.getElementById('btn1').attributes;
        const btn4Attr = document.getElementById('btn2').attributes;
      }
    }
    console.log('循环结束用时:', Date.now() - start);
  </script>
</body>
</html>

页面上有两个 button 按钮,脚本中会进行两层遍历来模拟代码的执行时长。其中,对 DOM 的操作是非常耗时的,这里我们加入操作 DOM 逻辑用于延长执行时间。

此时打开页面,你会发现页面很卡且 button 按钮都无法点击。大约等待 3s 后 JS 代码执行完毕,页面才能够正常操作。

2、空闲调度

下面,我们通过 requestIdleCallback 对其优化(或许可以封装一个 SDK 方法)。

实现核心:将一段非常耗时的执行程序,拆分成多个片段等待空闲调度执行

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Document</title>
</head>
<body>
  <button id="btn1">按钮1</button>
  <button id="btn2">按钮2</button>
  
  <script>
    // 异步空闲调度方式
    function idleSchedule({
      scheduleTotal,
      callback,
      onComplete,
      name,
      logs = false,
    }) {
      const logName = name ? `「${name}」` : '';
      const start = Date.now();
      const dataLength = 4000;
      let workIndex = 0;

      function handleWork() {
        callback(workIndex);
        workIndex ++;
        if (workIndex >= scheduleTotal) {
          logs && console.log(`${logName}任务调度完成,用时:`, Date.now() - start, 'ms!');
          onComplete();
        }
      }

      function workLoop(deadline) {
        while (deadline.timeRemaining() > 0 && workIndex < scheduleTotal) {
          handleWork();
        }
        if (workIndex < scheduleTotal) {
          window.requestIdleCallback(workLoop);
        }
      }

      if (workIndex < scheduleTotal) {
        logs && console.log(`${logName}开始在空闲时间调度任务!`);
        window.requestIdleCallback(workLoop);
      } else {
        logs && console.log(`${logName}无可调度任务!`);
        onComplete();
      }
    }

    function callback() {
      for (let j = 0; j < dataLength; j ++) {
        // DOM 操作严重影响程序执行效率
        const btn1Attr = document.getElementById('btn1').attributes;
        const btn2Attr = document.getElementById('btn2').attributes;
        const btn3Attr = document.getElementById('btn1').attributes;
        const btn4Attr = document.getElementById('btn2').attributes;
      }
    }

    idleSchedule({
      scheduleTotal: dataLength,
      callback,
      onComplete: () => console.log('后续逻辑处理!'),
      logs: true,
    });
  </script>
</body>
</html>

此时,你会在控制台看到输出:

开始在空闲时间调度任务!
任务调度完成,用时: 3037 ms!
后续逻辑处理!

若在这期间你不断去操作页面按钮时,你会发现任务的完成时间会相应延长。这就体现了 requestIdleCallback 空闲调度的特性。

requestAnimationFrame

还有一个长得很像(容易搞混)的方法是 requestAnimationFrame,它也运行在每一帧的流程中。

不过,它在每一帧的周期中只会执行一次,且发生在页面绘制之前,一般可以用来操作 DOM 处理动画。

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Document</title>
</head>

<body>
  <div id="progress-bar" style="background: lightblue; width: 0; height: 20px;"></div>
  <button id="btn">开始</button>
  <script>
    // 在页面上绘制一个0%-100%的进度条
    let btn = document.getElementById('btn');
    let oDiv = document.getElementById('progress-bar');
    let start;
    function progress() {
      oDiv.style.width = oDiv.offsetWidth + 1 + 'px';
      oDiv.innerHTML = (oDiv.offsetWidth) + '%';
      if (oDiv.offsetWidth < 100) {
        let current = Date.now();
        console.log(current - start); // 上一帧与当前这一帧执行rAF函数之间的毫秒数,平均 16.6ms
        start = current;
        requestAnimationFrame(progress);
      }
    }
    btn.addEventListener('click', () => {
      oDiv.style.width = 0; // 每次点击将宽度清零
      start = Date.now(); // 获取当前时间的毫秒数
      requestAnimationFrame(progress);
    });
  </script>
</body>

</html>

最后

感谢阅读。

参考: 珠峰架构 - 张仁阳老师 React Filber 架构公开课