Javascript事件循环
听说事件循环是面试必问的哦。
可视化
JS单线程,非阻塞
JavaScript的主要用途是与用户互动,以及操作DOM。
JS所执行代码的线程称作主线程
多线程会使操作复杂起来,比如有两个线程同时操作DOM,一个线程删除了当前的DOM节点,一个线程是要操作当前的DOM阶段,最后以哪个线程的操作为准?
所以JS是单线程的。
非阻塞就是通过事件循环实现的。
任务(事件)队列
任务(事件)队列可以视作异步任务的缓冲区。
异步任务一般包含IO设备,键盘,网络IO等非CPU操作,异步任务直接被放到“任务队列”中,只有“任务队列”通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
异步的具体机制如下:
- 所有同步任务在主线程上执行,构成一个“执行栈(execution context stack)”
- 主线程外,存在一个“任务队列(task queue)”。存放异步事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。某些异步任务就会进入执行栈来执行。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。
异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。
"任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。
浏览器的事件循环
执行栈和事件队列
上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
执行栈
同步代码的执行,按照顺序添加到执行栈中
function a() {
b();
console.log('a');
}
function b() {
console.log('b')
}
a();
可视化工具结果:
- 执行函数
a()
先入栈 a()
中先执行函数b()
函数b()
入栈- 执行函数
b()
,console.log('b')
入栈 - 输出
b
,console.log('b')
出栈 - 函数
b()
执行完成,出栈 console.log('a')
入栈,执行,输出a
, 出栈- 函数
a
执行完成,出栈。
事件队列
异步代码的启动后,异步代码会被挂起,继续执行执行栈其他代码。
就算当异步事件返回结果,并不会立刻执行回调函数(如果有回调的话),而是会被放入事件队列,只有当目前的执行栈中所有任务代码执行完,主线程才回去查找并执行事件队列中的任务。取出第一位的事件,将其回调放入执行栈执行。。。如下图:
在上面同步代码的基础上添加异步事件:
function a() {
b();
console.log('a');
}
function b() {
console.log('b')
setTimeout(function() {
console.log('c');
}, 2000)
}
a();
上图中,Web Apis框框内,绿色圈圈转了2s,就是setTimeout
参数的2000ms,由于之后还有同步代码,所以setTimeout
的回调函数并没有在2000ms后立即执行。
再加上点击事件看一下:
$.on('button', 'click', function onClick() {
setTimeout(function timer() {
console.log('You clicked the button!');
}, 2000);
});
console.log("Hi!");
setTimeout(function timeout() {
console.log("Click the button!");
}, 5000);
console.log("Welcome to loupe.");
总结一下:
宏任务和微任务
不同的异步任务可被再次分为:宏任务和微任务,只能属于其中一种。
宏任务:
- script(整体代码)
- setTimeout()
- setInterval()
- postMessage
- I/O
- UI交互,渲染
- XHR回调
微任务:
- new Promise().then(),catch(),基于的promise如fetch
MutationObserver
(html5 新特性)Object.observe
(已废弃;被ES6的Proxy
替代)
微任务存在的必要性:
更好地控制任务优先级
页面渲染事件,各种IO的完成事件等随时被添加到任务队列中,一直会保持先进先出的原则执行,我们不能准确地控制这些事件被添加到任务队列中的位置。但是这个时候突然有高优先级的任务需要尽快执行,那么一种类型的任务就不合适了,所以引入了微任务队列。
两种异步任务的运行机制
在当前执行栈为空时,主线程会查看微任务队列是否有事件存在
存在,依次执行微任务队列中的事件对应的回调,直到微任务队列为空,然后去宏任务队列中取出最前面的事件。
如果不存在,去宏任务队列中取出一个事件并把对应的回调加入当前执行栈去执行;
当前执行栈执行完毕后时会立刻处理所有微任务队列中的事件,然后才会去宏任务队列中取出一个事件。
一次事件循环中,微任务永远在宏任务之前执行。
事件处理过程
在事件循环中,每进行一次循环操作称为 tick
,每一次 tick
的任务处理模型是比较复杂的,但关键步骤如下:
- 检查 宏 任务队列是否为空,非空则到2,为空则到3
- 执行 宏 任务中的一个任务
- 检查 微 任务队列是否为空,若有则到4,否则到5
- 取出微 任务中的任务执行,执行完成返回到步骤3
- 执行视图更新
- 检查是否有Web Worker任务,有则执行
- 执行下一个宏任务
简单总结一下执行的顺序:
执行宏任务队列,宏任务执行完,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行新的微任务,微任务执行完毕后,再回到宏任务(有的话)中进行下一轮循环。
示例演示
首先,全局代码(main())压入调用栈执行,打印start;
接下来setTimeout
压入宏任务队列,promise.then
回调放入微任务队列,最后执行console.log
,打印出end;
至此,调用栈中的代码被执行完成,回顾宏任务的定义,我们知道全局代码属于宏任务,宏任务执行完,那接下来就是执行微任务队列的任务了,执行promise回调打印promise1;
promise回调函数默认返回undefined,promise状态变为fullfill,触发接下来的then回调,继续压入微任务队列,事件循环会把当前的微 任务队列一直执行完,即执行第二个promise.then回调并打印出promise2;
然后,微任务队列已经为空,从上面的流程图可以知道,接下来主线程会去做一些UI渲染工作(不一定会做,看浏览器的),然后开始下一轮事件循环event loop,即检查执行宏任务列表,有setTimeout
的回调则执行,打印出setTimeout;
这个过程会不断重复,也就是所谓的事件循环。
事件循环与渲染的更新
回顾上面的事件循环示意图,update rendering(视图渲染)发生在本轮事件循环的microtask队列被执行完之后,也就是说执行任务的耗时会影响视图渲染的时机。通常浏览器以60帧/S(60fps)的速率刷新页面,据说这个帧率最适合人眼交互,大概1000ms / 60 = 16.7ms
渲染一帧,所以如果要让用户觉得顺畅,单个宏任务及它相关的所有微 任务最好能在内完成。
但也不是每轮事件循环都会执行视图更新,浏览器有自己的优化策略,例如把几次的视图更新累积到一起重绘,重绘之前会通知requestAnimationFrame
执行回调函数,也就是说requestAnimationFrame
回调的执行时机是在一次或多次事件循环的UI render阶段。
验证代码:
setTimeout(function() {console.log('timer1')}, 0)
requestAnimationFrame(function(){
console.log('requestAnimationFrame')
})
setTimeout(function() {console.log('timer2')}, 0)
new Promise(function executor(resolve) {
console.log('promise 1')
resolve()
console.log('promise 2')
}).then(function() {
console.log('promise then')
})
console.log('end')
可以看到,结果1中requestAnimationFrame()是在一次事件循环后执行,而在结果2,它的执行则是在三次事件循环结束后。
总结
- 事件循环是js实现异步的核心
- 每轮事件循环分为3个步骤:
a) 执行macrotask队列的一个任务
b) 执行完当前microtask队列的所有任务
c) UI render - 浏览器只保证requestAnimationFrame的回调在重绘之前执行,没有确定的时间,何时重绘由浏览器决定
测验
var date = new Date()
console.log(1, new Date() - date)
setTimeout(() => {
console.log(2, new Date() - date)
}, 500)
// Promise.resolve().then(() => console.log(3, new Date() - date))
Promise.resolve().then(console.log(3, new Date() - date))
while (new Date() - date < 1000) { }
console.log(4, new Date() - date)
输出?
执行结果: 1 3 4 2。
代码从上往下执行,
先打印1,看见setTimeout丢到宏任务里面,等待执行,
因为promise.then()的参数是一个console.log(注意:并不是一个函数),且then是立即执行的。
函数立即执行,会先走参数的逻辑,然后在去调用函数。
console.log(1);
let a = Promise.resolve()
a.then(console.log('2'))
console.log(3);
输出:
1
2
3
所以先打印3,并且给then传了一个undefined(console.log的返回值是undefined),再把then丢到微任务里面
while循环是同步任务,等待1s后打印4,
此时同步任务走完了,开始执行异步任务,先将then取出来执行,发现then的第一个参数是一个undefined,promise内部会判断,如果then的第一个参数,也就是成功回调函数,不是一个参数的话,会自动给他包装成一个函数,并且将resolve的value值透传到下一个then里面。
然后去执行setTimeout,最后打印2。
本文参考链接:
https://www.ruanyifeng.com/blog/2014/10/event-loop.html
https://segmentfault.com/a/1190000022805523
http://lynnelv.github.io/js-event-loop-browser
https://github.com/Advanced-Frontend/Daily-Interview-Question/issues/471