前言
众所周知,javascript是一门单线程语言,而当我们使用ajax和服务端进行通信的时候是需要一定时间的,这样当前线程就会被阻塞,使浏览器失去相应。因此,当js执行执行一些长时间的任务时,我们希望有一种异步的方式处理这种任务。事件循环(event loop)就是如何处理异步执行顺序的一种机制。
$.get(url, function (data) { //do something});复制代码
浏览器中的事件循环
接下来会一一介绍,事件循环中的执行栈
、事件队列
、宏任务
、微任务
等概念
什么是执行栈
执行栈就是js代码运行的地方,上图call stack所示。当下面程序运行时,会推送的调用栈中被执行。
console.log('Hi');setTimeout(function cb1() { console.log('cb1');}, 500);console.log('Bye');复制代码
什么是事件队列
当浏览器中的事件监听函数被触发(DOM)、网络请求的相应(ajax)、定时器被触发(setTimeout)相对应的回调函数就会被推送到事件队列中,等待执行;如上图中的Callback Queue。
什么是事件循环
事件循环是一个这样的过程:当执行栈中的任务结束之后,会将事件队列中的第一个任务推入到执行栈中执行,当任务处理完毕,又会取事件队列中的第一个任务,如此往复,便构成了事件循环。
对应到下面代码中。
console.log('Hi');setTimeout(function cb1() { console.log('cb1');}, 500);console.log('Bye');复制代码
- 程序推送到执行栈中被执行
- 执行console语句、输出Hi
- 执行setTimeou语句
- 执行console语句、输出Bye
- 500ms的时候,setTimeout的回调函数被推送到事件队列中
- 此时事件队列中只有setTimeout的回调函数这一个任务,会被推到执行栈中执行
- console语句执行、输出cb1
通过上面的例子会对执行栈和事件队列有个基本的认识。由于JS是单线程的,同步任务会造成浏览器阻塞,我们把任务分成一个一个的异步任务,通过事件循环来执行事件队列中的任务。这就使得当我们挂起某一个任务的时候可以去做一些其他的事情,而不需要等待这个任务执行完毕。所以事件循环的运行机制大致分为以下步骤:
1、检查事件队列是否为空,如果为空,则继续检查;如不为空,则执行 2;
2、取出事件队列的首部,压入执行栈;
3、执行任务;
4、检查执行栈,如果执行栈为空,则跳回第 1 步;如不为空,则继续检查;
浏览器渲染时机
我们知道DOM操作会触发浏览器渲染,如增、删节点,改变背景颜色。那么这类操作是如何在浏览器当中奏效的?
至此我们已经知道了事件循环是如何执行的,事件循环器会不停的检查事件队列,如果不为空,则取出队首压入执行栈执行。当一个任务执行完毕之后,事件循环器又会继续不停的检查事件队列,不过在这间,浏览器会对页面进行渲染。这就保证了用户在浏览页面的时候不会出现页面阻塞的情况,这也使 JS 动画成为可能。
function move() { setTimeout(() => { dom.style.left = dom.offsetLeft + 10 + 'px' move() }, 15);}move()复制代码
现在用事件循环的机制说明js动画的过程。上面代码会在执行栈中执行,move函数被调用,setTimeout的回调函数15ms之后会被推送到事件队列中。此时执行栈中的任务结束,浏览器渲染、检查事件队列不断循环。当15ms之后事件队列中有任务时,会被推送到执行栈中执行,这时dom节点向右偏移10px,move函数执行、执行栈结束,浏览渲染、检查事件队列。如此往复就形成了动画。
宏任务和微任务(microtask)
先看一段代码,是如何输出的;
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'
。
setTimeout的回调函数是宏任务、Promise的回调函数是微任务。微任务和宏任务一样遵循事件循环机制,但是他们还是有些差别。
1、宏任务和微任务的事件队列是相互独立的;
2、微任务队列的检查时机早于宏任务。(执行栈中任务结束就会马上清空微任务事件队列)
根据上面的规则,解释代码的输出。
-
执行栈中的代码执行,宏任务推入宏任务事件队列、微任务推入微任务事件队列,执行栈任务结束
-
检查微任务事件队列,此时已经有Promise的回调函数,推入执行栈,输出
promise1
。Promise还有回调函数,推入微任务事件队列,执行栈结束。 -
检查微任务事件队列,推入执行栈,输出
promise2
,执行栈结束。 -
检查微任务事件队列,此时被清空
-
检查宏任务事件队列,推入执行栈,输出
setTimeout
,执行栈结束。宏任务有: **setTimeout** 、**setImmediate** 、 **MessageChannel** 微任务有: **setTimeout** 、**setImmediate** 、 **MessageChannel**复制代码
Node.js中的事件循环
Node中的事件循环是和浏览器有很大区别的
当Node.js启动时,会初始化event loop;每个event loop都会包含按如下顺序六个循环阶段
┌───────────────────────┐┌─>│ timers ││ └──────────┬────────────┘│ ┌──────────┴────────────┐│ │ I/O callbacks ││ └──────────┬────────────┘│ ┌──────────┴────────────┐│ │ idle, prepare ││ └──────────┬────────────┘ ┌───────────────┐│ ┌──────────┴────────────┐ │ incoming: ││ │ poll │<─────┤ connections, ││ └──────────┬────────────┘ │ data, etc. ││ ┌──────────┴────────────┐ └───────────────┘│ │ check ││ └──────────┬────────────┘│ ┌──────────┴────────────┐└──┤ close callbacks │ └───────────────────────复制代码
- timers 阶段: 这个阶段执行setTimeout(callback) and setInterval(callback)预定的callback;
- I/O callbacks 阶段: 执行除了 close事件的callbacks、被timers(定时器,setTimeout、setInterval等)设定的callbacks、setImmediate()设定的callbacks之外的callbacks;
- idle, prepare 阶段: 仅node内部使用;
- poll 阶段: 获取新的I/O事件, 适当的条件下node将阻塞在这里;
- check 阶段: 执行setImmediate() 设定的callbacks;
- close callbacks 阶段: 比如socket.on(‘close’, callback)的callback会在这个阶段执行。
每一个阶段都有一个装有callbacks的fifo queue(队列),当event loop运行到一个指定阶段时, node将执行该阶段的fifo queue(队列),当队列callback执行完或者执行callbacks数量超过该阶段的上限时,event loop会转入下一下阶段。
Node.js中的宏任务和微任务
宏任务:setTimeout和setImmediate复制代码
- setTimeout 设计在poll阶段为空闲时,且设定时间到达后执行;但其在timer阶段执行
- setImmediate 设计在check阶段执行;
谁先输出,谁后输出?
setTimeout(function timeout () { console.log('timeout');},0);setImmediate(function immediate () { console.log('immediate');});复制代码
答案是不确定的。有两个前提我们是需要清楚的;
- event loop初始化是需要一定时间的
- setTimeout有最小毫秒数的,通常是4ms。
当:event loop准备时间 > setTimeout最小毫秒数。从timers阶段检查,此时队列中已经有setTimeout的任务,所以timeout
先输出;
当:event loop准备时间 < setTimeout最小毫秒数。从timers阶段检查,此时队列是空的就下检查接下来的阶段,到check阶段,已经有setImmediate的任务,所以immediate
先输出;
微任务:process.nextTick()和Promise.then()复制代码
微任务不在event loop的任何阶段执行,而是在各个阶段切换的中间执行,即从一个阶段切换到下个阶段前执行;nextTick比Promise.then()先执行
下面代码是如何执行的。
setImmediate(() => { console.log('setImmediate1') setTimeout(() => { console.log('setTimeout1') }, 0);})setTimeout(()=>{ process.nextTick(()=>console.log('nextTick')) console.log('setTimeout2') setImmediate(()=>{ console.log('setImmediate2') })},0);复制代码
- 从前面的知识知道,此时setTimeout和setImmediate执行顺序是不确定的。
- 假设setImmediate先执行,输出
setImmediate1
,setTimeout的任务添加到timer阶段 - 检查timer阶段,这时已经有两个任务。先执行之前的第一个任务,nextTick添加到微任务队列,输出
setTimeout2
,setImmediate的任务添加到check阶段。 - timer中还有一个任务,执行输出
setTimeout1
- 切换阶段,微任务执行,输出
nextTick
- 检查check阶段,输出
setImmediate2
思考题
let fs = require('fs')fs.readFile('./1.txt', 'utf8', function (err, data) { setTimeout(() => { console.log('setTimeout') }, 0); setImmediate(() => { console.log('setImmediate') })})复制代码
这种情况下的setTimeout和setImmediate执行的顺序确定吗?readFile的回调函数是在poll阶段执行 答案是setImmediate
比setTimeout
先执行
结语
浏览器中和Node.js中的事件循环可以说是两套不同的机制,做个总结,希望有所帮助。