面试官:如何实现大量任务执行的调度?
去年面试遇到的手撕题目,整理一下。主要是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:requestIdleCallback
和requestAnimationFrame
。
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,它使得网页动画可以更加流畅且高效。与传统的使用 setInterval
或 setTimeout
实现动画的方式相比,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)
}
})
}