浏览器事件环
用实例和知识点描述带您清晰的了解浏览器事件环的每一步;
栈和队列
在计算机内存中存取数据, 基本的数据结构分为栈和队列
-
栈(Stack)是一种后进先出的数据结构; 栈的特点是 操作只在一端进行, 一般来说, 栈的操作只有两种: 进栈和出栈; 第一个进栈的数据总是最后一个才出来
-
队列(Queue)和栈类似, 但是它是先进先出的数据结构,它的特点是 操作在队列两端进行, 从一端进入再从另一端出来; 先进入(从A端)的总是先出来(从B端)
| 名称 | 进出特点 | 端的数量 |
|---|---|---|
| 栈 | 后进先出 | 进出都在同一端 |
| 队列 | 先进先出 | 进出是在不同端 |
- 队列好比一条隧道, (车)从隧道的一端(入口)进入, 从隧道的另一端(出口)出来
// 队列执行时按照放置的顺序依次执行
setTimeout(function(){
console.log(1)
});
setTimeout(function(){
console.log(2)
});
setTimeout(function(){
console.log(3)
});
// => 1 2 3
- 栈好比楼梯, 上楼时第一个踩的楼梯也就是下楼时最后踩的一个楼梯
// 在JavaScript中函数的执行就是一个典型的入栈与出栈的过程
function a(){
console.log('a')
function b(){
console.log('b');
function c(){
console.log('c');
}
c();
}
b();
}
a();
// => a b c
// 函数调用顺序是a b c, 而作用域销毁的过程依次是c b a
单线程和异步
JavaScript是单线程的, 这里所谓的单线程指的是主线程是单线程;
-
为什么不是多线程呢? JavaScript最初设计是运行在浏览器中的, 假定是多线程, 有多个线程同时操作DOM, 岂不很混乱! 那会以哪个为准呢?
-
JavaScript为单线程, 在一个线程中代码会一行一行往下走,直到程序执行完毕; 若执行期间遇到较为费时的操作, 那只能等待了;
-
单线程的设计使得语言的执行效率变差, 为了利用多核CPU的性能,javascript语言支持异步代码; 当有较为费时的操作时, 可将任务写为异步; 主线程在执行过程中遇到异步代码, 会先将该异步任务挂起, 继续执行后面的同步代码, 待同步执行完毕再回过头来, 检查是否有异步任务, 如果有异步任务就执行它;
PS: Java君加班有点累, 他想烧水冲一杯咖啡, 如果采用同步执行方式,那他就傻傻地等待,等水开了再冲咖啡;
PS: Java君加班有点累, 他想烧水冲一杯咖啡, 如果采用异步执行方式,那么他在等待水烧开之前,他可以听听歌,刷刷抖音啥的,等水开了再冲咖啡;
(-很明显异步的方式效率会高一些);
JavaScript是怎么执行的
JavaScript代码是在栈里执行的, 不论是同步还是异步; 代码分为同步代码和异步代码, 异步代码又分为: {宏任务} 和 [微任务]
JavaScript是解释型语言,它的执行过程是这样的:
- 从上到下依次解释每一条js语句
- 若是同步任务, 则将其压入一个栈(主线程); 如果是异步任务,就放到一个任务队列里面;
- 开始执行栈里面的同步任务,直到将栈里的所有任务都走完, 此时栈被清空;
- 回头检查异步队列,如果有异步任务完成了,就生成一个事件并注册回调(将异步的回调放到队列里面), 再将回调函数取出压入栈中执行;
- 栈中的异步回调执行完成后再去检查,直到异步队列都清空,程序运行结束
从以上步骤可以看出,不论同步还是异步, 都是在栈里执行的, 栈里的任务执行完成后一遍又一遍地回头检查队列,这种方式就是所谓的"事件环"
事件队列
// 先看个demo吧
console.log('start');
setTimeout(()=>{
console.log('hello');
}, 1000);
console.log('end');
// start end hello 上面代码执行后, 输出'start' 'end', 大约1s之后输出'hello'
// ? 为什么'hello'不在end之前输出呢
- 解析
- setTimeout是一个异步函数, 也就是说当我们设置一个延迟函数的时候, setTimeout异步函数并不会阻塞代码执行, 程序还是会往下执行; 与此同时,它会在浏览事件列表中进行标记;
- 当延迟时间结束之后(准确说应该是当异步完成后), 事件列表会将标记的异步函数【异步函数的回调函数】添加到事件队列(Task Queue)中
- 当主栈中的代码执行完毕, 栈为空时, JS引擎便检查事件队列, 如果不为空的话,事件队列便将第一个任务压入执行栈中运行;
console.log('start');
setTimeout(() => {
console.log('hello');
},0);
console.log('end');
// start end hello
// 将上例微微调整,发现输出结果还是一样的
// 因为setTimeout的回调函数只是会被添加到(事件)队列中,而不会立即执行。 再回头
- 解析:
- 因为setTimeout是异步函数, 首先它会被(事件列表)标记(即挂起);
- setTimeout的延迟时间0并非真正是0, 在浏览器应该是4ms;
- 延迟时间到达(即异步任务完成),setTimeout的回调会被放入事件队列(静静地等待主栈中的同步代码执行);
- 当执行栈(即主栈)中的任务(同步任务)执行完毕, 执行栈为空; // 输出了 start end
- 执行栈为空后, 回头检查事件队列, (发现队列里面有任务[函数]待执行)将队列中注册的任务(即:异步函数完成后的回调函数)压入执行栈执行; // 输出 hello
let promise = new Promise(function(resolve, reject) {
console.log('Promise');
resolve('Sucess');
});
promise.then((data)=>{
console.log(data);
});
console.log('Hello World!');
// 'Promise' 'Hello World!' 'Sucess'
- 解析:
- new Promise()实例时的函数参数(执行器excutor)会立即执行; // 输出 'Promise'
- promise.then是异步函数, 它会被先放入事件队列;
- 同步任务console.log('Hello World!');执行完毕后主栈被清空 // 输出'Hello World!'
- 回头检查事件队列,发现队列里面有任务, 将其压入主栈执行; // 输出'Sucess'
微任务与宏任务
之前说到,异步任务又分为: 宏任务和微任务, 那他们是怎样执行的呢?
- 在浏览器的执行环境中,总是先执行小的,再执行大的; 也就是说先执行微任务再执行宏任务;
- 宏任务有: setImmediate(IE) > setTimeout setInterval
- 微任务有: promise.then > MutationObserver > MessageChannel
- 任务队列中,在每一次事件循环中,宏任务只会提取一个执行, 而微任务会一直提取,直到微任务队列为空为止;
- 如果某个微任务被推入到执行栈中,那么当主线程任务执行完成后,会循环调用该队列任务中的下一个任务来执行,直到该任务队列到最后一个任务为止;
- 事件循环每次只会入栈一个宏任务,主线程执行完成该任务后又会检查微任务队列,并完成里面所有的任务后再执行宏任务
记忆
- js代码执行顺序:同步代码会先于异步代码; 异步任务的微任务会比异步任务宏任务先执行
- js代码默认先执行主栈中的代码,主栈中的任务执行完后, 开始执行清空微任务操作
- 清空微任务执行完毕,取出第一个宏任务到主栈中执行
- 第一个宏任务执行完后,如果有微任务会再次去执行清空微任务操作,之后再去取宏任务
上述步骤就形成事件环
// 查看setTimeout和Promise.then的不同
console.log(1);
setTimeout(()=>{
console.log(2);
Promise.resolve().then(()=>{
console.log(6);
});
}, 0);
Promise.resolve(3).then((data)=>{
console.log(data); // 3
return data + 1;
}).then((data)=>{
console.log(data) // 4
setTimeout(()=>{
console.log(data+1) // 5
return data + 1;
}, 1000)
}).then((data)=>{
console.log(data); // 上一个then没有任何返回值, 所以为undefined
});
// 1 3 4 undefined 2 6 5
- 解析:
- 主栈开始执行, 遇到同步代码
console.log(1);,将其执行, 输出 1 - 主栈继续往下执行, 遇到异步函数
setTimeout(()=>{ console.log(2); }, 0), 将其放入宏任务队列,此时宏任务队列:[s1] - 主栈继续往下执行, 遇到异步函数promise.then将其放入微任务队列, 此时微任务队列[p1(打印3,返回3+1)]
- 主栈继续往下执行, 遇到异步函数promise.then将其放入微任务队列, 此时微任务队列[p1, p2(打印4)]
- 主栈继续往下执行, 遇到异步函数promise.then将其放入微任务队列, 此时微任务队列[p1, p2, p3]
- 主栈的同步代码执行完毕后, 栈里面的任务已空, 回头检查发现有宏任务队列[s1]、微任务队列[p1, p2, p3]
- 清空微任务队列(即微任务队列中的任务挨个的执行,直到全部执行完毕为止) 清空微任务流程
- 把微任务队列里面的p1拿到主栈执行; // 输出 3, 将data + 1(4)作为下一个then的成功值返回
- 把微任务队列里面的p2拿到主栈执行; // 输出 4
- 在执行p2时遇到了
setTimeout(()=>{ console.log(data+1); return data + 1; }),将其放入宏任务队列(先标记,1s后异步执行完成后再将异步函数的回调放入队列), 此时宏任务队列:[s1,s2] - 主栈继续往下执行, 把微任务队列里面的p3拿到主栈执行, 因为上一个then未显示的返回任何值, 因此data为undefined, 执行完毕后输出 undefined
- 主栈继续往下执行, 发现微任务队列已被清空, 此时提取宏任务队列中的第一个s1放到主栈里面执行, 执行后输出 2
- s1在输出2之后, 遇到了异步函数promise.then, 将其放入微任务队列, 此时微任务队列[p4]
- 第一个宏任务执行完毕后, 发现微任务队列有任务p4, 再去执行清空微任务操作
- 把微任务队列里面的p4拿到主栈执行; // 输出 6
- 主栈继续往下执行, 发现微任务队列已被清空, 此时提取宏任务队列中的第一个s2放到主栈里面执行, 执行后输出 5
- 主栈开始执行, 遇到同步代码
浏览器中的事件环
- 所有同步任务都在主线程上执行,形成一个执行栈;
- 主线程之外,还存在一个任务队列; 只要异步任务有了运行结果,就在任务队列中放置一个事件(任务);
- 一旦执行栈中的所有同步任务执行完毕, 系统就会读取任务队列,将队列中的事件放到执行栈中依次执行;
- 主线程从任务队列中读取事件,这个过程是循环不断的 整个这种运行机制又被称为Event Loop(事件循环)
面试题分析
setTimeout(()=>{
console.log(1);
Promise.resolve().then(data => {
console.log(2);
});
}, 0);
Promise.resolve().then(data=>{
console.log(3);
setTimeout(()=>{
console.log(4)
}, 0);
});
console.log('start');
// start -> 3 1 2 4
// 给方法分类: 宏任务 微任务
// 宏任务: setTimeout
// 微任务: then
/*
// 执行顺序: 微任务会先执行
// 默认先执行主栈中的代码,执行后完清空微任务;
// 之后微任务执行完毕,取出第一个宏任务到主栈中执行
// 第一个宏任务执行完后,如果有微任务会再次去清空微任务,之后再去取宏任务,这样就形成事件环;
*/
-
解析:
- 主栈中的代码从上往下执行, 遇到第一个定时器, 先将其挂起(s1) -> 继续往下
- 遇到了Promise.then, 它是一个微任务, 将其放在微任务队列 -> 继续往下
- 遇到同步代码console.log('start'), 执行后输出: start -> 继续往下
- 栈里面的(同步)任务执行完毕后, 查看异步队列, 发现微任务队列有then(p1), 会把这个微任务拿到栈里面执行,执行后输出: 3(微任务要先于宏任务执行)
- 接下来往下执行又遇到一个定时器(宏任务), 又将其挂起(s2)
- 微任务执行完成后,发现微任务队列已清空,然后执行宏任务; 因为s1先于s2放到异步的回调队列, 将s1拿到栈里面执行, 执行后输出: 1
- console.log(1)执行完毕后又遇到一个微任务then, 将其放到微任务队列(p2), 宏任务完成后再次清空微任务队列, 此时发现微任务p2, 将p2拿到主栈执行, 执行后输出: 2
- 微任务p2执行完成后,再取宏任务,发现宏任务队列有s2, 将其放到主栈里面执行, 执行后输出: 4
setTimeout(function () {
console.log(1);
Promise.resolve().then(function () {
console.log(2);
}); // p2
}); // s1
setTimeout(function () {
console.log(3);
}); // s2
Promise.resolve().then(function () {
console.log(4);
}); // p1
console.log(5); // 5 4 1 2 3
-
解析
- 首先输出 5, 因为console.log(5)是同步代码
- 接下来将两个setTimeout和最后的Promise放入异步队列(将setTimeout放入宏任务队列[s1, s2],将Promise.then放入微任务队列[p1]);
- 执行完同步代码后,发现微任务队列和宏任务队列都有代码, 按浏览器事件环机制, 优先执行微任务
- 将微任务队列中的p1拿到栈里执行, 执行完成后输出 4
- 微任务p1执行完后发现微任务队列已清空, 接下来执行宏任务
- 将宏任务队列中的s1拿到栈里面执行, 执行完成后输出 1
- 宏任务s1执行过程中发现promise.then, 将其加入微任务队列[p2]
- 宏任务s1执行完成后, 要再次清空微任务队列, 将微任务队列中的p2拿到主栈执行, 执行完成后输出2
- 微任务p2执行完成后, 发现微任务队列已清空, 此时宏任务队列有s2
- 将宏任务s2拿到栈里面执行, 执行完成后输出 3
setTimeout(()=>{
console.log('A');
},0);
var obj={
func:function () {
setTimeout(function () {
console.log('B')
},0);
return new Promise(function (resolve) {
console.log('C');
resolve();
})
}
};
obj.func().then(function () {
console.log('D')
});
console.log('E');
// C E D A B
-
解析:
- 首先
setTimeout(()=>{ console.log('A'); },0)被加入到宏任务事件队列中,此时宏任务中有[s1(输出A)]; - obj.func()执行时,
setTimeout(()=>{console.log('B'); },0)被加入到宏任务事件队列中,此时宏任务中有[s1,s2(输出B)]; - 接着return一个Promise对象,new Promise实例时,Promise构造函数中的函数参数会立即执行, 执行console.log('C'); 此时打印了 'C'
- 接下来遇到then方法,将其回调函数加入到微队列,此时微任务队列中有[p1];
- 主栈中的代码继续执行, 遇到同步任务
console.log('E'),执行后输出 'E' - 此时所有同步任务执行完毕, 开始检查异步队列,先检查微任务队列, 发现了p1, 执行微任务p1,输出'D'
- p1执行完成后,发现微任务队列已清空, 发现宏任务队列依然有任务,取出第一个宏任务s1压到主栈执行, 执行完成后输出'A'
- s1执行完毕后,检查发现微任务列表已清空, 而宏任务列表还有一个任务,接着取出下一个宏任务s2
- s2执行完毕后输出 'B'
- 首先
小结
磕磕绊绊终于是理解了这一块的知识点, 以前只是在不断的搬砖, 却从未停下来思考、认真学习, GET到之后感觉解开了不少疑惑;
在写文档时候发现自己的语言描述能力居然如此的不堪, 啰里啰嗦写了很多; 这大抵是成长的必经之路吧;
参考了一些朋友的文章, 从中学习到不少, 有知识点的学习也有大佬对知识点巧妙的描述技巧; 向大佬致敬!
参考文章: