"浏览器事件循环是如何工作的?" 这个问题几乎在每次面试中都会被问到。面试官经常出一下谁先执行谁先输出的代码题来恶心人!对事件循环的深入了解可以让你了解浏览器将如何处理代码,以及如何不做无用功,而是帮助其提高效率,以便应用程序的用户获得最佳体验。
事件循环类似于项目管理器。它协调事件、渲染、网络请求、用户交互和所有异步代码。
接下来我们测验一些挑战。面试官喜欢问这样的问题。还请各位同学自行检查。如果不明白这段代码是如何工作的,请不要担心,我将在本文中进一步讲解。
1. 测试题
1. 第一题
这是演示事件循环如何工作的最简单的示例,这也是最常见的面试题。
js
setTimeout(() => console.log(1), 0);
console.log(2);
new Promise(res => {
console.log(3)
res();
}).then(() => console.log(4));
console.log(5);
2. 第二题
js
setTimeout(() => {
console.log('timeout 1');
const promise = new Promise((res) => res());
promise.then(() => console.log('promise 1'));
});
setTimeout(() => console.log('timeout 2'));
const promise = new Promise((res) => res());
promise.then(() => console.log('promise 2'));
3. 第三题
js
setTimeout(() => console.log('timeout 1'));
const promise = new Promise(resolve => setTimeout(resolve));
promise.then(() => console.log('promise 1'));
Promise.resolve().then(() => console.log('promise 2'));
setTimeout(() => console.log('timeout 2'));
为了理解这些示例中的代码,让我们更深入地了解一下事件循环。
2. 事件循环是如何工作的
事件循环对宏任务和微任务队列进行操作。
宏任务(和微任务)是浏览器需要完成的一项工作。一般来说,我们可以说任务是一个函数。
任务示例:
- 脚本执行
- 解析html
- 设置超时回调
- 鼠标单击或任何其他事件的回调
微任务是脚本执行后需要立即执行的任务。
微任务示例:
- 承诺回调
- MutationObserver
我们将研究事件循环的算法,并通过一个图形示例来帮助我们清楚地了解。
考虑以下等待执行的任务:
为了简化,事件循环无限期地执行以下步骤:
- 如果任务队列中至少有一个任务,则获取第一个任务并执行它。这就是
bar()
我们例子中的函数。
- 如果微任务队列中有任务,则从最早的任务开始执行所有任务。函数
qqz()
,然后是baz()
函数。
- 更新渲染图,最后任务队列为空。
3. 调用堆栈
当任务被执行时会发生什么?它被推入调用堆栈。让我们看一个例子。
js
function bar() {
new Promise((resolve) => resolve())
.then(() => console.log('promise'));
}
function foo() {
setTimeout(() => console.log('timeout'));
bar();
}
foo();
浏览器面临的第一个任务是执行脚本
浏览器执行的任务进入调用堆栈。
此代码调用的所有函数也会进入调用堆栈。
如果函数执行后创建了一个新任务,则该任务将被放入任务队列中。
当一个函数被执行时,它会从栈中弹出。(setTimeout
在我们的例子中)
在"堆栈"数据结构中------最后一个进,最后一个出。在"队列"数据结构中,情况正好相反------先进先出。
如果函数执行结果创建了一个新的微任务,它将被放入微任务队列中。
接下来,Promise()
、bar()
、foo()
、 和script
依次从堆栈中弹出。
当堆栈变空时,任务完成。之后,就轮到执行任务执行过程中积累的所有微任务了。
当堆栈为空并且队列中没有微任务时,浏览器可以继续执行下一个宏任务。
当两个队列都为空时,浏览器不执行任何操作,等待新任务。
现在我们可以回到我们的测试题中,看看浏览器在执行它们时将经历哪些步骤。
4. 讲解测试题
1. 第一题
js
setTimeout(() => console.log(1), 0);
console.log(2);
new Promise(res => {
console.log(3)
res();
}).then(() => console.log(4));
console.log(5);
浏览器步骤:
- 第一个要执行的任务是脚本。这一步的结果:
- 回调
setTimeout
被添加到任务队列中。 - 执行
promise
器函数res => { console.log(3); res(); }
是同步执行的。所以,这一步的输出:2, 3, 5
。 - 回调
promise
被添加到微任务队列中。
-
执行微任务队列中的任务。输出:
4
. -
执行最后一个任务------
setTimeout
回调。输出:1
.
结果:2, 3, 5, 4, 1
.
2. 第二题
js
setTimeout(() => {
console.log('timeout 1');
const promise = new Promise((res) => res());
promise.then(() => console.log('promise 1'));
});
setTimeout(() => console.log('timeout 2'));
const promise = new Promise((res) => res());
promise.then(() => console.log('promise 2'));
浏览器步骤:
- 执行脚本。这一步的结果:
- 两个
setTimeout
回调都按照它们在代码中出现的顺序添加到任务队列中。 - 第二个回调
promise
被添加到微任务队列中。
-
执行微任务队列中的任务。输出:
promise 2
. -
执行宏任务队列中最早的任务。这一步的结果:
- 输出:
timeout 1
. - 新的
promise
回调被添加到微任务队列中。
-
执行微任务队列中的任务。输出:
promise 1
. -
执行最后一个宏任务。输出:
timeout 2
.
结果:promise 2, timeout 1, promise 1, timeout 2
.
3. 第三题
js
setTimeout(() => console.log('timeout 1'));
const promise = new Promise(resolve => setTimeout(resolve));
promise.then(() => console.log('promise 1'));
Promise.resolve().then(() => console.log('promise 2'));
setTimeout(() => console.log('timeout 2'));
浏览器步骤:
- 执行脚本。这一步的结果:
timeout 1
并将timeout 2
回调添加到任务队列中。- 执行
promise
器函数resolve => setTimeout(resolve)
是同步执行的。setTimeout
于是任务队列中又添加了一个。 Promise.resolve()
立即解决承诺,其回调被添加到微任务队列中。因为第一个promise
尚未完成,所以它的回调尚未添加到微任务队列中。
-
执行微任务队列中的任务。输出:
promise 2
. -
从任务队列中执行任务,从最旧的任务开始。输出:
timeout 1
. -
由于微任务队列为空,因此再次执行任务队列中最旧的任务。结果,
() => console.log('promise 1')
函数被添加到微任务队列中。 -
执行微任务队列中的任务。输出:
promise 1
. -
执行任务队列中的任务。最后我们会
timeout 2
在控制台看到。
结果:promise 2, timeout 1, promise 1, timeout 2
.
5. 渲染测试题
1. 测试1
尝试猜测屏幕上会显示什么:
js
setTimeout(() => {
console.log('timeout 1');
document.body.innerHTML = 'A';
});
requestAnimationFrame(() => {
console.log('requestAnimationFrame');
document.body.innerHTML = 'B';
});
setTimeout(() => {
console.log('timeout 2');
document.body.innerHTML = 'C';
});
console.log('script');
document.body.innerHTML = 'D';
根据MDN:
该window.requestAnimationFrame()
方法告诉浏览器您希望执行动画,并请求浏览器在下一次重绘之前调用指定的函数来更新动画。
换句话说,该requestAnimationFrame()
方法在渲染步骤之前被调用。
宏任务(以及所有微任务)执行后,浏览器会更新渲染。
因此,第一次渲染预计在脚本执行后进行。然后屏幕上显示字母的顺序如下:B, A, C
。D
永远不会被绘制到屏幕上,因为它与requestAnimationFrame
回调共享一个渲染步骤,该回调将最后执行。
因此,用户将C
在屏幕上看到。这就是这段代码现在在 Safari 中的工作方式。
但是,如果浏览器认为渲染步骤不会包含视觉变化,或者为了优化目的而想要组合多个任务,则浏览器可能会跳过渲染步骤。例如,浏览器可能决定将多个计时器回调合并在一起并跳过其间的渲染。
因此,执行此示例的大多数现代浏览器将在执行脚本后跳过渲染并捆绑setTimeout
回调。因此,三项任务将只有一次渲染。这个小小的遗漏将极大地改变最终结果。浏览器将呈现的唯一内容是B
.
要验证这一点,请运行代码,打开 chrome Dev Tools
->Performance
选项卡 -> 单击Record
,刷新页面。您只会看到已绘制的一帧。
这种不一致的结果不仅可能出现在浏览器之间,而且可能出现在同一浏览器从启动到启动的整个过程中。因此,应该避免这种不明确的代码。
2. 测试2
在下面的代码中,有两个按钮可以使徽标旋转 360 度。一个按钮使用 进行动画处理setTimeout
,另一个按钮使用 进行动画处理requestAnimationFrame
。您认为哪种动画可以更快地将徽标旋转 360 度?
js
const animationBtn = document.getElementById('animation-btn');
const timeoutBtn = document.getElementById('timeout-btn');
const logo = document.getElementById('logo');
animationBtn.addEventListener('click', () => animate(requestAnimationFrame));
timeoutBtn.addEventListener('click', () => animate(setTimeout));
// animates a 360 degree rotation of the logo
function animate(fn) {
let i = 0, done = false;
logo.style.transform = `rotate(0deg)`;
function cb() {
i += 1;
logo.style.transform = `rotate(${i}deg)`;
if (i > 360) done = true;
if (!done) fn(cb);
}
fn(cb);
}
这是浏览器跳过渲染步骤的另一个示例。该requestAnimationFrame()
函数每次在渲染之前都会被调用,因此它会被调用整整 360 次。在这种setTimeout()
情况下,浏览器会为多次 (3-4)setTimeout
次调用执行一次渲染。因此,这样的动画会运行得更快,并且会跳过很多帧。请看演示。
requestAnimationFrame() 动画不会跳过任何帧。setTimeout 动画会跳过很多帧。
下篇我会写一下如何使用循环事件函数编写高效的代码
有兴趣的同学给个三联不迷路
点赞收藏支持、手留余香、与有荣焉,动动你发财的小手哟,感谢各位大佬能留下您的足迹。
往期热门精彩推荐
面试相关热门推荐
实战开发相关推荐
移动端相关推荐
Git 相关推荐
更多精彩详见:个人主页