面试官:如何实现大量任务执行的调度?

面试官:如何实现大量任务执行的调度?

去年面试遇到的手撕题目,整理一下。主要是requestAnimationFrame这个API。

题目描述

实现一个runTask函数,该函数用于执行耗时任务,耗时任务具体是什么都有可能,可能是一个Promise,也可能就是一个干巴巴的同步代码。要求实现的效果是:

  • 兼容性尽可能的好。
  • 不要让用户觉得页面卡顿(其实就是尽可能不要影响到页面的渲染)
js 复制代码
/**
 * 运行一个耗时任务
 * 如果要异步执行任务,需要返回Promise
 * 要尽快完成任务,同时避免页面的渲染出现卡顿(也就是页面上执行的动画)
 * 尽量兼容更多的浏览器
 * @param {Function} task
 */
function runTask(task) {
    // 你的实现... 
}

具体使用场景如下,主要就是页面上有一个小球在跑的动画效果,要求就是执行多次同步的线程阻塞代码,但是不要影响页面的渲染(也就是动画不要卡顿):

html 复制代码
<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="UTF-8" />
        <meta name="viewport" content="width=device-width, initial-scale=1.0" />
        <title>大量任务的调度</title>
        <link rel="stylesheet" href="./index.css">
    </head>
    <body>
        <div class="container">
                <div id="message"></div>
                <div class="button-container" id="button">
                        <button>点我执行任务</button>
                </div>
                <div class="ani-container">
                        <div class="ball"></div>
                </div>
        </div>
    </body>
    <script src="./index.js"></script>
</html>
js 复制代码
const message = document.getElementById("message");
const button = document.getElementById("button");

function delay() {
	// 模拟耗时任务 通过同步while循环阻塞主线程,实现5ms的延迟。
	const duration = 5;
	const start = Date.now();
	while (Date.now() - start < duration) {}
}

const taskNumber = 1000;
const tasks = new Array(taskNumber).fill(delay);

button.onclick = async () => {
	message.textContent = "任务执行中";
	button.disabled = true;
	const start = Date.now();
	await Promise.all(tasks.map((task) => runTask(task)));
	const end = Date.now();
	message.textContent = `任务执行完毕,耗时${end - start}ms`;
};
/**
 * 运行一个耗时任务
 * 如果要异步执行任务,需要返回Promise
 * 要尽快完成任务,同时避免页面的渲染出现卡顿(也就是页面上执行的动画)
 * 尽量兼容更多的浏览器
 * @param {Function} task
 */
function runTask(task) {
}

尝试直接执行任务(同步执行)

当然要先看看上面的调用场景是什么情况,试着直接执行一下看看:

javascript 复制代码
/**
 * 运行一个耗时任务
 * 如果要异步执行任务,需要返回Promise
 * 要尽快完成任务,同时避免页面的渲染出现卡顿(也就是页面上执行的动画)
 * 尽量兼容更多的浏览器
 * @param {Function} task
 */
function runTask(task) {
    task();
}

很显然这种方式会导致主线程被阻塞,使页面无法响应用户交互,动画也会卡顿。执行情况看下面的动图:

使用微队列(Microtask)

接下来,考虑使用微任务队列,比如Promise.resolve().then(task)。这看起来是不是挺美好的,但是,同样会造成页面卡顿。

javascript 复制代码
function runTask(task) {
    return Promise.resolve().then(task);
}

大致情况如下:

这是因为,浏览器的整体渲染流程大概是这样的:

js 复制代码
for(;;){ // 一个无限执行的for循环
    取出一个宏任务执行;
    清空微任务;
    if(渲染时机是否到达){ // 按电脑刷新率来判断,是不是渲染下一帧
        执行渲染;
    }
}

所以,因为浏览器会优先清空微任务队列再进行渲染,所以如果存在大量的微任务,渲染过程会被延迟推后。

使用宏队列(Macrotask)

理论上,这种方法可以减少对渲染的影响,因为按上面的渲染原理,宏任务是每一次渲染开始执行一个,不一次性全部搞完。但实际上,这种方式还是可能会导致页面卡顿或不流畅,具体效果要取决于浏览器的实现方式。Chrome浏览器就觉得这么多宏任务,我还要渲染,还是尽量渲染和任务执行两边都照顾到吧,所以在Chrome上跑的效果就是卡顿的,最起码不是阻塞的了。Safari上,不同版本还不一样,新版本就直接卡死了,老版本好像可以很丝滑。

javascript 复制代码
function runTask(task) {
    return new Promise((resolve) => {
        setTimeout(() => {
            task();
            resolve();
        }, 0);
    });
}

新的思路

想一想,是不是当前的帧只要还有空闲时间,我们就执行一下任务,没有就不执行了。这样不就可以避免卡顿了吗?毕竟卡顿的本质就是任务执行导致了渲染的推迟,只要任务执行不影响渲染就好了呀。所以可以形成下面的伪代码:

js 复制代码
if(当前帧还有剩余时间){ 
     任务执行();
     回调函数();
 }else{
     // 通过递归推入下一帧执行
    本函数(任务, 执行完的回调函数);
 }

可以用一个辅助函数来中间过渡一下,形成初具人形的伪代码:

js 复制代码
function runTask(task) {
     return new Promise((resolve) => { // 返回Promise是因为可能有处理异步任务的情况
        realDealTask(task, resolve) // 用一个辅助函数
     });
}

function realDealTask(task, callback){
    if(当前帧还有剩余时间){ 
         task();
         callback(); // resolve掉
     }else{
         // 通过递归推入下一帧执行
        realDealTask(task, callback)
     }
}

那么问题的关键就在这个怎么知道 "当前帧还有没有剩余时间" 上了。说白了,这里其实就是两个API:requestIdleCallbackrequestAnimationFrame

requestIdleCallback

requestIdleCallback 是一种由浏览器提供的API,旨在允许开发者在浏览器空闲时期执行低优先级的任务。这样做的目的是为了提高网页应用的响应性能,确保关键任务(如用户交互、动画等)不会因为JavaScript代码执行而被延迟。

使用场景
  • 预加载资源:可以在浏览器空闲时预加载一些非关键资源,以加快后续页面加载速度。
  • 数据整理与清理:执行一些不需要立即完成的数据处理或内存清理工作。
  • 更新UI:对于那些不紧急的UI更新,可以利用这个时机来执行,从而避免影响用户体验。
基本用法
javascript 复制代码
requestIdleCallback(function(deadline) {
    while (deadline.timeRemaining() > 0) {
        // 执行你的工作项
    }
});

这里,deadline对象提供了一个timeRemaining()方法,该方法返回一个数值,表示当前空闲周期剩余的毫秒数。你可以使用这个值来决定在一个requestIdleCallback回调中执行多少工作。如果在这个回调中你没有完成所有的工作,你可以再次调用requestIdleCallback安排另一个回调,以便在下一个空闲周期继续处理。

注意事项
  • requestIdleCallback并不保证会立刻执行你的回调函数,它取决于浏览器是否有足够的空闲时间。
  • 如果你需要确保某些代码在特定时间内运行,即使浏览器当时并不空闲,可以考虑使用第二个参数options中的timeout属性。例如,requestIdleCallback(myNonEssentialWork, {timeout: 2000})将在至少2秒内调度myNonEssentialWork回调。

尽管requestIdleCallback为优化Web应用性能提供了强大的工具,但其支持度在旧版浏览器中可能有限,因此在使用前请检查目标浏览器的支持情况。此外,考虑到浏览器环境的变化,有时可能需要结合其他技术一起使用,以达到最佳效果。

本题目中使用

使用该API:

javascript 复制代码
function runTask(task) {
     return new Promise((resolve) => {
        realDealTask(task, resolve) // 用一个辅助函数
     });
}

function realDealTask(task, callback){
    requestIdleCallback((deadline)=>{
         if(deadline.timeRemaining() > 0){ 
             task();
             callback(); // resolve掉
         }else{
             // 通过递归推入下一帧执行
            realDealTask(task, callback)
         }
    })
}

效果是立竿见影:

requestAnimationFrame

requestAnimationFrame 是一个专门用于在浏览器重绘之前调用指定回调函数的API,它使得网页动画可以更加流畅且高效。与传统的使用 setIntervalsetTimeout 实现动画的方式相比,requestAnimationFrame 更加优化了性能和资源管理,因为它会根据页面是否可见以及显示器的刷新率自动调整执行频率。

主要特点
  • 同步于显示器的刷新率 :大多数设备的屏幕刷新率为60Hz,因此requestAnimationFrame通常每秒调用60次(每16.6ms一次)回调函数。但在一些高刷新率的显示器上,这个数值可能会更高。
  • 节能且高效:当用户切换到其他标签页或隐藏当前页面时,回调将不会被调用,这有助于节省电池电量并减少CPU/GPU的占用。
  • 更好的视觉效果:由于它是与显示器刷新同步的,所以能够避免出现跳帧现象,从而提供更平滑的动画体验。
使用方法

基本使用方式如下:

javascript 复制代码
function animate() {
    // 执行动画逻辑
    // ...

    // 请求下一帧动画
    requestAnimationFrame(animate);
}

// 开始动画
requestAnimationFrame(animate);

在这个例子中,animate 函数会在浏览器准备绘制下一帧之前被调用,从而实现连续的动画效果。

取消动画

如果你需要停止动画,可以使用 cancelAnimationFrame 方法。首先,你需要保存 requestAnimationFrame 返回的一个标识符,然后将该标识符传递给 cancelAnimationFrame 来取消相应的动画请求。

javascript 复制代码
let animationId = requestAnimationFrame(animate);

// 在适当的时候取消动画
cancelAnimationFrame(animationId);
适用场景
  • 动态图表更新
  • 游戏循环
  • 页面滚动、拖拽等交互效果的实现
本题目中使用

使用这个实现和requestIdleCallback差不多的效果(可能效果会差一点),但是它是符合兼容性要求的,主流浏览器都支持,放心用。

javascript 复制代码
function runTask(task) {
     return new Promise((resolve) => {
        realDealTask(task, resolve) // 用一个辅助函数
     });
}

function realDealTask(task, callback){
      const start = Date.now();
      requestAnimationFrame(() => {
        if(Date.now() - start < 16.6){ // 60hz的单帧时间
            task();
            callback();
        }else{
            realDealTask(task, callback)
        }
      })
}

效果个人感觉比requestIdleCallback差一点:

耗时增加

所有任务的整体执行耗时当然会增加啦,因为毕竟没有阻塞渲染,任务没有一直执行。但是通常情况下,这点耗时增加比起用户体验来肯定就无伤大雅了。

放整体代码

html 复制代码
<!DOCTYPE html>
<html lang="en">
	<head>
		<meta charset="UTF-8" />
		<meta name="viewport" content="width=device-width, initial-scale=1.0" />
		<title>大量任务的调度</title>
		<link rel="stylesheet" href="./index.css">
	</head>
	<body>
		<div class="container">
			<div id="message"></div>
			<div class="button-container" id="button">
				<button>点我执行任务</button>
			</div>
			<div class="ani-container">
				<div class="ball"></div>
			</div>
		</div>
	</body>
	<script src="./index.js"></script>
</html>
css 复制代码
* {
  margin: 0;
  padding: 0;
}
html,
body {
  width: 100%;
  height: 100%;
}
.container {
  display: flex;
  justify-content: center;
  align-content: center;
  flex-wrap: wrap;
  row-gap: 16px;
  width: 100%;
  height: 100%;
}
#message {
  display: flex;
  justify-content: center;
  align-items: center;
  width: 100%;
  height: 36px;
  color: red;
}
.button-container {
  width: 100%;
  display: flex;
  justify-content: center;
  align-items: center;
}
.button-container > button {
  width: 100px;
  height: 42px;
  background-color: rgb(0, 174, 255);
  cursor: pointer;
  border: 1px solid rgb(0, 174, 255);
  transition: all 0.2s;
  border-radius: 8px;
  color: #fff;
}
.button-container > button:hover {
  opacity: 0.8;
}
.ani-container {
  width: 100%;
  height: 200px;
  position: relative;
}
@keyframes round {
  0%,
  100% {
    left: 30%;
    top: 0%;
  }
  25% {
    left: 70%;
    top: 0%;
  }
  50% {
    left: 70%;
    top: 100%;
  }
  75% {
    left: 30%;
    top: 100%;
  }
}
.ball {
  position: absolute;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background-color: pink;
  left: 100px;
  top: 0;
  animation: round 8s infinite linear;
}
js 复制代码
const message = document.getElementById("message");
const button = document.getElementById("button");

function delay() {
	// 模拟耗时任务 通过同步while循环阻塞主线程,实现5ms的延迟。
	const duration = 5;
	const start = Date.now();
	while (Date.now() - start < duration) {}
}

const taskNumber = 1000;
const tasks = new Array(taskNumber).fill(delay);

button.onclick = async () => {
	message.textContent = "任务执行中";
	button.disabled = true;
	const start = Date.now();
	await Promise.all(tasks.map((task) => runTask(task)));
	const end = Date.now();
	message.textContent = `任务执行完毕,耗时${end - start}ms`;
};
/**
 * 运行一个耗时任务
 * 如果要异步执行任务,需要返回Promise
 * 要尽快完成任务,同时避免页面的渲染出现卡顿(也就是页面上执行的动画)
 * 尽量兼容更多的浏览器
 * @param {Function} task
 */
function runTask(task) {
	// 同步执行:阻塞
	// task();

	// 微队列:阻塞
	// return Promise.resolve().then(task);
	// 因为浏览器的渲染大概是如下过程
	// for(;;){
	//   取出一个宏任务执行
	//   清空微任务
	//   if(渲染时机是否到达){ // 按电脑刷新率来判断,是不是渲染下一帧
	//     执行渲染
	//   }
	// }
	// 所以,如果大量微任务存在,会导致浏览器的渲染被推后,从而导致页面的卡顿

	// 宏任务 (其实应该拆分为计时器队列/网络队列什么的)看起来是不会影响到渲染的(因为取一个执行一下)
  // 卡顿/丝滑/阻塞 看浏览器具体实现,不同浏览器效果不同 为什么会卡 以谷歌浏览器为例认为有这么多宏任务需要执行,要照顾一下,又照顾到渲染又照顾到任务执行,所以会将渲染稍微延后
	// return new Promise((resolve) => {
	// 	setTimeout(() => {
	// 		task();
	// 		resolve();
	// 	}, 0);
	// });

  // 所以只能自己写了
  return new Promise((resolve) => {
    realDealTask(task, resolve) // 用一个辅助函数
  });
}

function realDealTask(task, callback){
  // 实现思路
  // if(当前帧还有剩余时间){ 
  //   task();
  //   callback();
  // }else{
  //   // 通过递归推入下一帧执行
  //   realDealTask(task, callback)
  // }
  // 那么如何知道当前帧还有剩余时间?两种方案:
  // 1. requestIdleCallback 有些兼容性问题
  // 2. requestAnimationFrame 兼容性好
  // 当然,上面的两种方案都会导致任务的最终执行完成的时间被稍微拉长,因为该渲染的时候渲染了
  
  //1. requestIdleCallback
  // requestIdleCallback((deadline) => {
  //   if(deadline.timeRemaining() > 0){
  //     task();
  //     callback();
  //   }else{
  //     realDealTask(task, callback)
  //   }
  // })

  //2. requestAnimationFrame
  const start = Date.now();
  requestAnimationFrame(() => {
    if(Date.now() - start < 16.6){ // 单帧时间
      task();
      callback();
    }else{
      realDealTask(task, callback)
    }
  })
}
相关推荐
Slow菜鸟5 分钟前
JavaScript与UniApp、Vue、React的关系
javascript·vue.js·uni-app
uhakadotcom10 分钟前
Python应用中的CI/CD最佳实践:提高效率与质量
后端·面试·github
VT.馒头14 分钟前
【力扣】2629. 复合函数——函数组合
前端·javascript·算法·leetcode
程序猿--豪14 分钟前
前端技术百宝箱
javascript·vue.js·react.js·webpack·gitee·css3·html5
程序员buddha14 分钟前
ThinkPHP8.0+MySQL8.0搭建简单实用电子证书查询系统
javascript·css·mysql·php·layui·jquery·html5
╰つ゛木槿16 分钟前
NPM安装与配置全流程详解(2025最新版)
前端·npm·node.js
每天吃饭的羊34 分钟前
React 性能优化
前端·javascript·react.js
hzw05101 小时前
使用pnpm管理前端项目依赖
前端
小柚净静1 小时前
npm install vue-router 无法解析
javascript·vue.js·npm