面试官问我1000次循环执行耗时任务,如何让页面不卡顿

758 阅读2分钟

我们先来思考一下这个问题,在浏览器的主线程中,JavaScript 执行、样式计算、布局绘制等操作共享同一个运行环境。当遇到耗时任务时,动画卡顿便如影随形。

让我们直接步入正题,首先我们在页面上画一个小球,让他运动起来

import "./App.css";
import { useState } from "react";

const Runtask = () => {
  const [left, setLeft] = useState(0);

  const animateBall = () => {
    let index = 0;
    const moveBall = () => {
      if (index < 500) {
        setLeft((prev) => prev + 1);
        index++;
        requestAnimationFrame(moveBall); // 每帧执行一次
      }
    };
    requestAnimationFrame(moveBall); // 启动动画
  };

  return (
    <div className="container">
      <div
        className="moving-ball"
        style={{ left: `${left}px` }} // 改用 left 属性
      ></div>
      <button onClick={animateBall}>启动JS动画</button>
    </div>
  );
};

export default Runtask;

看看效果

接下来我们添加一些耗时任务

import "./App.css";
import { useState } from "react";

const Runtask = () => {
  //...

  // 耗时任务
  const heavyTask = () => {
    const start = Date.now();
    while (Date.now() - start < 3) {} // 直接阻塞3秒
  };

  const task = new Array(1000).fill(heavyTask);

  const runTask = (task) => {
    // ?
  };

  const onClick = async () => {
    console.log("开始耗时任务");
    const start = Date.now();
    for(let i = 0 ; i < task.length;i++){
      runTask(task[i])
    }
    console.log("任务完成", Date.now() - start);
  };
  return (
    <div className="container">
      <div
        className="moving-ball"
        style={{ left: `${left}px` }} // 改用 left 属性
      ></div>
      <button onClick={onClick}>执行阻塞任务</button>
      <button onClick={animateBall}>启动JS动画</button>
    </div>
  );
};

export default Runtask;

那么问题就来了,runTask这个函数如何编写才能让小球不卡顿呢?

首先,我们先看看直接去调用任务,会发生什么

  const runTask = (task) => {
    task();
  };

可以看到,当开始执行任务之后,小球的运动被阻塞,这个也是意料之中的事情,因为JS单线程,当长时间执行JS代码的时候,主线程一直被占用,所以就不能进行渲染任务。

那我们想一下,既然同步任务不行,我们就试试异步,这时候就迎来了两个问题:微任务、宏任务。

如果我们对时间循环掌握的很好的话,也就可以直接排除微任务了,因为每次事件循环的时候,会将微任务队列清空,再进行下次循环,所以我们就放弃了微任务。

我们再来看看宏任务,

setTimeout(task, 0);

发现还是卡顿了,唉?因为setTimeout计时任务的执行时机,没有官方的标准,完全取决于浏览器的实现。为了证明这个问题,让其他小伙伴帮忙运行:

可以看到,小球还是在运行,只不过是掉帧。

这样肯定还是不能达到我们的效果,我们仔细想一下,既然不能阻塞页面渲染,又还得执行时间。 不阻塞页面渲染,就说明我们要在16ms将主线程交换给渲染线程。 所以我们只能在16ms中抽空去完成任务。

这时候聪明的彦祖们立马就想到了一个API:requestIdleCallback;

来看看这个api的概念:

window.requestIdleCallback() 方法插入一个函数,这个函数将在浏览器空闲时期被调用。这使开发者能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如动画和输入响应


这不正是我们要找的东西吗??

来实现看看:

 const runTask = (task) => {
    return new Promise(resove => {
        _run(task,resove)
    })
  };

 const _run = (task, callback) => {
    requestIdleCallback((dealine) => {
      // 判断当前帧是否还有剩余时间
      if (dealine.timeRemaining() > 0) {
        task();
        callback();
      } else {
        _run(task, callback);
      }
    });
  };

可以发现,当执行任务的时候,丝毫不影响页面的渲染。

看到这里,其实你已经入门了React的任务调度。