这是我参与8月更文挑战的第7天,活动详情查看:8月更文挑战
前言
关于事件循环这个话题已经在网上有很多篇文章介绍过了。但是大多数一上来就给你说一大堆的什么浏览器内核原理,堆,栈等等的知识介绍。反正我觉得挺枯燥的,不知道你们作何感想?
关于这篇文章我并不想作一个全面的探讨和总结,因为事件环(EventLoop)确实是web上的一大重难点。所以我们开头用一个常见的问题来开始我们的话题。
一个常见的面试题目
请依次写出下面这段代码的执行顺序:
console.log('script start');
setTimeout(function () {
console.log('setTimeout');
}, 0);
Promise.resolve()
.then(function () {
console.log('promise1');
})
.then(function () {
console.log('promise2');
});
console.log('script end');
这是一道常见的面试题目,很多粗心大意的同学可能会在这里马失前蹄。我们先公布下答案(当然这段代码在某些个别的浏览器可能会打印的顺序不一致,这是浏览器差异所致。我在此列出的答案应该是属于被公认为应该是这样的。):
script start
script end
promise1
promise2
setTimeout
要理解这一点,您需要了解事件循环如何处理任务和微任务。当你第一次遇到这种情况时,你可能会有很多困惑。
首先JS为什么是单线程的?
JavaScript的单线程,与它作为浏览器脚本语言的用途来决定了。JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准呢?所以这个问题很简单。
JS为什么需要异步?
假如JS中不存在异步,只能自上而下执行。比如在进行ajax请求的时候如果没有返回数据后面的代码就没办法执行。这就会导致用户体验非常槽糕。
JS如何实现异步?
异步打个比方你在家煮饭的同时你还可以切菜。因为这允许浏览器优先处理性能敏感的任务,例如用户输入内容等。说到这儿我们真正的问题来了,那么假如同时有多个异步任务来了,浏览器该如何这些个异步任务的先后顺序呢?
事件环(EventLoop)
事件环(EventLoop)是浏览器用来解决多个事件按照一定顺序执行的东东。
这个就是简单的事件环,在没有事件执行的时候。事件环只是在空转。
我们假设在某一时刻浏览器对事件环说:“嘿伙计!我有个任务需要你去完成。”,事件环说:“好的!我把它添加到事件待办列表了,稍后我会去执行它。”
假设我们用两个 setTimeout 将两个回调函数加入任务队列会怎样呢?试看如下代码:
const callback1 = () => { console.log(1) }
const callback2 = () => { console.log(2) }
setTimeout(callback1, 1000);
setTimeout(callback2, 1000);
那么根据我们的规范,浏览器将会在 1000 ms后同步触发 callback1 和 callback2。这两个回调函数将会并行等待1000ms后回到主线程被执行。请看如下图示:
我们用两个黄色的带有“T”标识的块状分别表示callback1和callback2。最左边的环形跑道就是执行任务的地方。当我们向任务队列加入队列的时候,事件环会绕道经过这里。等到合适的时机浏览器将这两个回调函数按照注册的先后顺序交给时间环去执行。所以等待1s后浏览器会先后打印出 1、2。
这就是简单的事件环。但是当我们考虑渲染的时候,事件环就变复杂了。浏览器通过渲染引擎来更新页面显示。渲染步骤是另一个弯道,涉及到样式计算。他会收集所有的CSS计算应用到元素上的样式。然后再通过布局创建一个渲染树,找出页面上的所有内容以及元素的位置然后创建实际的像素数据,绘制内容到页面上。如下图示:
然后浏览器对事件环说:“嘿!我们要更新屏幕上的显示内容啦。”。事件环说:“好的,下次经过右边的弯道时我会处理的。”
同步任务的例子
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo1</title>
</head>
<style>
.blue {
color: blue;
}
</style>
<body>
<p>希望是美好的,也许是人间至善,而美好的事物永不消逝。</p>
<button id="button">while (true)</button>
<script>
let p = document.getElementsByTagName("p");
let button = document.getElementById("button");
button.addEventListener('click', (event) => {
p[0].className = 'blue';
while (true);
})
</script>
</body>
</html>
代码很简单,就是点击按钮的时候执行 while(true)。并且将文字的颜色修改成蓝色。
我们先不点击按钮,可以通过鼠标来选中文字部分。
但是当用户点击按钮后,无法选择文字了。并且字体颜色也没有修改过来...
所以这个例子用事件环如何来表示呢?
当用户点击按钮,浏览器说:“嘿!事件环,我有个任务需要你去执行”
事件环说:“好的没问题,交给我来处理。”。但是这个任务永远不会结束,它一直执行JavaScript...
几毫秒后,浏览器对事件环说:“事件环,我们需要更新页面上的字体颜色。下次你有空的时候更新一下。”
事件环说:“好的,我执行完现在的这个任务后会去做的,我正忙着执行无限循环。”
然后用户试着选取文本,这涉及到获取点击,涉及到查看dom中的文本,于是浏览器又说:“事件环,我又有几个任务需要执行,加到你的待办事项里面吧!”
事件环笑起来说道:“你知道执行无限循环需要多长时间吗?”
显然这是一个漫长的等待,从“无限循环”这四个字也能看出来!
这就是为什么 while 阻止了页面交互的原因。
所以 while 会阻止页面交互,那么 setTimeout 呢?
异步任务的例子
我们来看看接下来的代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>demo2</title>
</head>
<style>
.blue {
color: blue;
}
</style>
<body>
<p> 希望是美好的,也许是人间至善,而美好的事物永不消逝。</p>
<button id="button">setTimeout loop</button>
<script>
function loop() {
setTimeout(loop, 0)
};
let p = document.getElementsByTagName("p");
let button = document.getElementById("button");
button.addEventListener('click', (event) => {
p[0].className = 'blue';
loop();
})
</script>
</body>
</html>
我们将刚才的执行while(true)的代码改为修改文字颜色,并且执行setTimeout的循环。
现在打开页面可以看到,点击按钮后文字颜色成功的修改为蓝色,并且也可以选择文字。感觉一切都很正常。
但是在后台实际上发生的事情是:
我们向任务队列加入一个任务:
绕过事件环去执行一个任务,我们再加入下一个任务,继续执行任务。一直这样,反复循环。
事件环一次只能处理一个任务,他必须绕事件环一圈来接收下一个任务。
这就意味着在合适的时机浏览器对事件环说:“我们应该更新页面显示了”。
此时事件环有机会运行到右侧,它可以绕到右边的渲染那一侧去做渲染。这就是为什么 setTimeout 没有阻止渲染的原因。
但是如果你想运行与渲染有关的代码,不要把它放在任务中。应为任务在渲染的另一边。我们要把渲染的代码放在渲染阶段之前。
值得庆幸的是浏览器给我们提供了一个API,使用 requestAnimationFrame 可以做到这一点。
requestAnimationFrame
requstAnimationFrame 又叫做 RAF 回调(RAF callbacks)。他可以用来作为浏览器执行渲染步骤的一部分来起作用。
为了演示它的用法,我们来做一个盒子动画创建一个循环。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>requestAnimationFrame</title>
</head>
<style>
#box {
width: 100px;
height: 100px;
background-color: blue;
position: absolute;
left: 0;
top: 0;
}
</style>
<body>
<div id="box"></div>
<script>
let box = document.getElementById("box");
function moveBoxForwardOnePixel() {
box.style.left = box.offsetLeft + 1 + 'px';
};
function callback() {
moveBoxForwardOnePixel();
requestAnimationFrame(callback);
};
callback();
</script>
</body>
</html>
我们运行这个动画看看:
可以看到使用了 requestAnimationFrame 来执行这个动画,页面上的蓝色盒子比较平滑匀速的向右移动。
我们再把 requestAnimationFrame 改成 setTimeout 来看看:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>setTimeout</title>
</head>
<style>
#box {
width: 100px;
height: 100px;
background-color: blue;
position: absolute;
left: 0;
top: 0;
}
</style>
<body>
<div id="box"></div>
<script>
let box = document.getElementById("box");
function moveBoxForwardOnePixel() {
box.style.left = box.offsetLeft + 1 + 'px';
};
function callback() {
moveBoxForwardOnePixel();
setTimeout(callback, 0);
};
callback();
</script>
</body>
</html>
这个时候盒子比刚才移动的得明显变得快了许多。
我们将两个动画放在一起做个对比:
我们改写下代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>对比</title>
</head>
<style>
#box1 {
width: 100px;
height: 100px;
background-color: blue;
position: absolute;
left: 0;
top: 50px;
}
#box2 {
width: 100px;
height: 100px;
background-color: red;
position: absolute;
left: 0;
top: 230px;
}
.split {
height: 1px;
background-color: black;
margin-top: 137px;
margin-bottom: 20px;
}
.text {
text-align: center;
}
</style>
<body>
<div id="box1"></div>
<div class="text">requestAnimationFrame</div>
<div class="split"></div>
<div id="box2"></div>
<div class="text">setTimeout</div>
<script>
let box1 = document.getElementById("box1");
let box2 = document.getElementById("box2");
function moveBoxForwardOnePixel1() {
box1.style.left = box1.offsetLeft + 1 + 'px';
};
function moveBoxForwardOnePixel2() {
box2.style.left = box2.offsetLeft + 1 + 'px';
};
function callback1() {
moveBoxForwardOnePixel1();
requestAnimationFrame(callback1);
};
function callback2() {
moveBoxForwardOnePixel2();
setTimeout(callback2, 0);
};
callback1();
callback2();
</script>
</body>
</html>
然后打开浏览器比较一下这两个动画:
通过这个对比可以更加直观的看出来,使用setTimeout实现的动画比 requestAnimationFrame 实现的动画运行的飞快!这也就意味着 setTimeout 的回调被更频繁的调用,这并不是一件好事!
我们看到渲染可能在任务之间执行,但是这个不是必须的。渲染是油浏览器来决定何时渲染并且竟可能高效,只有值得更新才会渲染。如果渲染结果没有改变,则不会去执行渲染。打个比方,如果浏览器运行在后台,没有显示,浏览器也不会渲染,因为没有意义。大多数情况下,页面会以固定频率更新每秒 60 次,有的屏幕快一些,有的屏幕慢一些。60hz是常见的,如果我们一秒钟将页面样式改变1000次,浏览器不会去运行渲染一千次。它将与显示器同步,仅渲染到显示器能够达到的频率。否则就是浪费时间,而且渲染的东西用户也看不到。
所以这就是刚才使用 setTimeout 所看到的盒子移动更快,因为它的调用次数更多,多于用户所能看到的也多余浏览器所能显示的。