写在前面:弄懂JavaScript事件执行机制必须要先知道的两点。
- 1.JavaScript是一门单线程语言。
- 2.Event Loop(事件循环)是JavaScript的执行机制。
- 首先什么是单线程语言呢?简单通俗的解释就是代码的执行顺序是从上到下依次执行的。那么问题来了,当我运行下面代码,自信满满,以为会输出1,2,3,4时,却发现结果和我想象的不太一样啊,这是为什么呢?
setTimeout(()=>{
console.log('1')
},0)
new Promise((resolve,reject)=>{
console.log('2')
resolve()
}).then(()=>{
console.log('3')
})
console.log('4');
- 是不是觉得和js是按照语句出现的顺序执行/产生的冲突呢?这时我们就要引入js的事件执行机制了。
关于JavaScript
- javascript是一门单线程语言,在最新的HTML5中提出了Web-Worker, 但javascript是单线程这一核心扔未改变。所以一切javascript版的“多线程”都是用单线程模拟出来的,一切javascript多线程都是纸老虎。
JavaScript为什么需要异步
- 如果js中不存在异步,只能自上而下执行,如果上一行解析时间很长,那么下面的代码就会被阻塞。 对于用户而言,阻塞就以为着“卡死”,这样就导致了很差的用户体验。比如在进行ajax请求的时候如果没有返回数据后面的代码就没办法执行。
- JavaScript异步的执行机制其实就是事件循环(eventloop),理解了eventloop机制,就理解了js异步的执行机制。
javascript事件循环
- 我们都知道JavaScript中的任务可以分为两种:
- 同步任务
- 异步任务
-
当我们打开网站时,网页的渲染过程就是一大堆同步任务,比如页面骨架和页面元素的渲染。而像加载图片音乐之类占用资源大耗时久的任务,就是异步任务。此处引入一张图片来辅助理解
- 图片中的内容可以用以下文字表述:
- 首先判断JS是同步还是异步,同步就进入主线程运行,异步的进入Event Table并注册函数。
- 当指定的事情完成时,Event Table会将这个函数移入Event Queue。
- 同步任务进入主线程后顺序执行,直到主线程空闲时,才会去 Event Queue中查看是否有可执行的异步任务,如果有就推入主线程中。
- 上述过程会不断重复,也就是常说的Event Loop(事件循环)。
- 那么现在问题来了,我们怎么知道什么时候主线程是空闲的呢?js引擎存在
monitoring process
进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去Event Queue那里检查是否有等待被调用的函数。 - 下面上代码,进一步学习
let data = [];
$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!');
}
})
console.log('代码执行结束');
// 控制台输出结果
'代码执行结束'
'发送成功'
- 上面代码的执行顺序如下:
-
ajax
进入Event Table,注册回调函数success
。 - 执行
console.log
('代码执行结束')。 -
ajax
事件完成,回调函数success
进入Event Queue。 - 主线程从Event Queue读取回调函数
success
并执行
-
- 相信通过上面的文字和代码,你已经对js的执行顺序有了初步了解。接下来我们来研究进阶话题:
setTimeout
setTimeout
-
setTimeout
是一个典型的异步操作,我们经常会用它来延迟执行一个任务。不知道大家在项目中会不会遇到这样的问题,明明定时延迟3秒,但是3秒时间过去了,任务还没执行,这是怎么回事呢?让我们先看看下面的代码:
setTimeout(() => {
task()
},3000)
console.log('执行console');
- 根据前面我们的结论,
setTimeout
是异步的,所以上面的代码应该先执行console.log
这个同步任务,然后再执行setTimeout
里面的task()
- 现在我们修改一下代码
setTimeout(() => {
task()
},3000)
wait(10000000)
- 乍一看好像没有什么区别,但是如果拿到chrome里执行就会发现打印'执行
setTimeout
的时间远超3秒,这是为什么呢? - 现在我们来详细分析一下上面的代码
-
task()
进入Event Table并注册,计时开始。 - 执行
wait
函数,很慢,非常慢,计时仍在继续。 - 3秒到了,计时事件
setTimeout
完成,但是wait
也太慢了吧,还没执行完,只好等着。 -
wait
终于执行完了,task()
终于从Event Queue进入了主线程执行。
-
- 分析结束,我们知道
setTimeout
这个函数,是经过指定时间后,把要执行的任务(本例中为task()
)加入到Event Queue中,又因为是单线程任务要一个一个执行,如果前面的任务需要的时间太久,那么只能等着,导致真正的延迟时间远远大于3秒。 - 我们经常还会遇到这样的代码
setTimeout(fn,0)
,0秒后执行,但是实际上真的能做到0秒执行吗? - 答案是不会的,
setTimeout(fn,0)
的含义是,指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。举例说明:
//代码1
console.log('先执行这里');
setTimeout(() => {
console.log('执行啦')
},0);
# 代码1执行结果
//先执行这里
//执行啦
//代码2
console.log('先执行这里');
setTimeout(() => {
console.log('执行啦')
},3000);
# 代码2执行结果
//先执行这里
3s后...
//执行啦
- 关于
setTimeout
要补充的是,即便主线程为空,0毫秒实际上也是达不到的。根据HTML的标准,最低是4毫秒哟。
setInterval
-
setTimeout
与setInterval
差不多,只不过后者是循环的执行。对于执行顺序来说,setInterval
会每隔指定的时间将注册的函数置入Event Queue,如果前面的任务耗时太久,那么同样需要等待。 - 需要注意的一点是,对于
setInterval(fn,ms)
来说,我们已经知道不是每过ms秒会执行一次fn,而是每过ms秒,会有fn进入Event Queue。所以一旦setInterval
的回调函数fn执行时间超过了延迟时间ms,那么就完全看不出来有时间间隔了。
Promise与process.nextTick(callback)
- 除了广义的同步任务和异步任务,我们对任务有更精细的定义:
-
宏任务:包含整个script代码块,
setTimeout
,setIntval
-
微任务:
promise.then
,process.nextTick
(node.js环境) - 不同类型的任务会进入对应的event queue, 比如
setTime
和setIntval
会进入相同(宏任务)的event queue, 而promise
和process.nextTick
会进入相同(微任务)的event queue。 - 事件循环的顺序,决定js代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务。然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。是不是觉得有点绕?我们下面来通过代码进行分析。
setTimeout(function() {
console.log('setTimeout');
})
new Promise(function(resolve) {
console.log('promise');
}).then(function() {
console.log('then');
})
console.log('console');
- 这段
script
代码块是一个宏任务,进入主线程执行 - 首先遇到了
setTimeout
,那么将其回调函数注册后分发到宏任务Event Queue。 - 接下来遇到了
new Promise
,立即执行,then
函数分发到微任务Event Queue。 - 然后又遇到
console.log()
,立即执行。 - 至此,整体代码
script
作为第一个宏任务执行结束,接着看看有哪些微任务?我们发现了then在微任务Event Queue里面,执行。 - ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务Event Queue开始。我们发现了宏任务Event Queue中
setTimeout
对应的回调函数,立即执行。 - 结束。
-
事件循环,宏任务,微任务的关系如图所示:
- 下面用代码来深入理解上面的机制:
console.log('1')
setTimeout(function() {
console.log('2')
process.nextTick(function() {
console.log('3')
})
new Promise(function(resolve) {
console.log('4')
resolve()
}).then(function() {
console.log('5')
})
})
process.nextTick(function() {
console.log('6')
})
new Promise(function(resolve) {
console.log('7')
resolve()
}).then(function() {
console.log('8')
})
setTimeout(function() {
console.log('9')
process.nextTick(function() {
console.log('10')
})
new Promise(function(resolve) {
console.log('11')
resolve()
}).then(function() {
console.log('12')
})
})
- 第一轮事件循环:
- 首先
script
代码快作为一个宏任务进入主线程执行,遇到console.log('1')
,执行输出1 - 再遇到
setTimeout
,分配到宏任务Event Queue中。并记为setTimeout1
。 - 下面遇到
process.nextTick
,分配到微任务Event Queue中。记为process1
。 - 接着遇到
Promise
,new Promise
,执行输出7,then
被分发到微任务Event Queue中。记为then1
。 - 最后又遇到
setTimeout
,分配到宏任务Event Queue中。记为setTimeout2
。第一轮宏任务执行完毕。 - 现在开始执行微任务,微任务Event Queue中有
process1
和then1
。顺序执行,输出6,8。 - 到此第一次事件循环执行完毕,输出1,7,6,8。
- 首先
- 第二轮的事件循环从宏任务的第一个Event Queue,
setTimeout1
开始执行。- 执行
setTimeout1
,首先输出2, - 遇到了
process.nextTick
,分配到微任务Event Queue中。记为process2
, - 遇到
Promise
,new Promise
立即执行,输出4,then
被分发到微任务Event Queue中。记为then2
。第二轮宏任务setTimeout1
执行完毕。 - 现在开始执行微任务,微任务Event Queue中有
process2
和then2
,顺序执行,输出3,5。 - 到此第二次事件循环执行完毕,输出2,4,3,5。
- 执行
- 第三轮的事件循环就从宏任务的第二个Event Queue,
setTimeout2
开始执行。- 执行
setTimeout1
,首先输出9, - 遇到了
process.nextTick
,分配到微任务Event Queue中。记为process3
, - 遇到
Promise
,new Promise
立即执行,输出11,then
被分发到微任务Event Queue中。记为then3
。第二轮宏任务setTimeout2
执行完毕。 - 现在开始执行微任务,微任务Event Queue中有
process3
和then3
,顺序执行,输出10,12。 - 至此整段代码执行完毕,共进行了三次事件循环,完整的输出为1,7,6,8,2,4,3,5,9,11,10,12。
(请注意,node环境下的事件监听依赖libuv与前端环境不完全相同,输出顺序可能会有误差)。
- 执行
- 下面再来一个更复杂的案例代码:
new Promise(function (resolve) {
console.log('1')
resolve()
}).then(function () { // then1
console.log('3')
})
setTimeout(function () { // setTimeout1
console.log('4')
setTimeout(function () { // setTimeout4
console.log('7')
new Promise(function (resolve) {
console.log('8')
resolve()
}).then(function () { // then2
console.log('10')
setTimeout(function () { // setTimeout6
console.log('12')
})
})
console.log('9')
})
})
setTimeout(function () { // setTimeout2
console.log('5')
})
setTimeout(function () { // setTimeout3
console.log('6')
setTimeout(function () { // setTimeout5
console.log('11')
})
})
console.log('2')
- 第一轮事件循环:
- 首先script代码快作为一个宏任务进入主线程执行,遇到
Promise
,new Promise
立即执行,输出1,then
被分发到微任务Event Queue中。记为then1
。 - 然后遇到
setTimeout
,分配到宏任务Event Queue中。并记为setTimeout1
。 - 再遇到
setTimeout
,分配到宏任务Event Queue中。并记为setTimeout2
。 - 接下来还是
setTimeout
,分配到宏任务Event Queue中。并记为setTimeout3
。 - 遇到
console.log('2')
,执行,输出2。第一轮宏任务执行完毕。 - 现在开始执行微任务,微任务Event Queue中有
then1
。执行,输出3。 - 到此第一轮事件循环结束,输出1,2,3。
- 首先script代码快作为一个宏任务进入主线程执行,遇到
- 第二轮事件循环从宏任务的第一个Event Queue,
setTimeout1
开始执行。- 遇到
console.log('4')
,执行,输出4, - 遇到
setTimeout
,分配到宏任务Event Queue中。并记为setTimeout4
。 - 此时微任务Event Queue里没有任务,第二轮事件循环结束,输出4。
- 遇到
- 第三轮事件循环从宏任务的第二个Event Queue,
setTimeout2
开始执行。- 遇到
console.log('5')
,执行,输出5。 - 此时微任务Event Queue里没有任务,第三轮事件循环结束,输出5。
- 遇到
- 第四轮事件循环从宏任务的第三个Event Queue,
setTimeout3
开始执行。- 遇到
console.log('6')
,执行,输出6 - 遇到
setTimeout
,分配到宏任务Event Queue中。并记为setTimeout5
。 - 此时微任务Event Queue里没有任务,第四轮事件循环结束,输出6。
- 遇到
- 第五轮事件循环从宏任务的第四个Event Queue,
setTimeout4
开始执行。- 遇到
console.log('7')
,执行,输出7 - 遇到
Promise
,new Promise
立即执行,输出8,then
被分发到微任务Event Queue中。记为then2
。 - 遇到
console.log('9')
,执行,输出9。第五轮宏任务执行完毕。 - 现在开始执行微任务,微任务Event Queue中有
then2
。- 遇到
console.log('10')
,执行,输出10, - 遇到
setTimeout
,分配到宏任务Event Queue中。并记为setTimeout6
。
- 遇到
- 到此第五轮事件循环结束,输出7,8,9,10
- 遇到
- 第六轮事件循环从宏任务的第五个Event Queue,
setTimeout5
开始执行。- 遇到
console.log('11')
,执行,输出11, - 此时微任务Event Queue里没有任务,第六轮事件循环结束,输出11。
- 遇到
- 第七轮事件循环从宏任务的第六个Event Queue,
setTimeout6
开始执行。- 遇到
console.log('12')
,执行,输出12, - 此时微任务Event Queue里没有任务,第七轮事件循环结束,输出12。
- 遇到
- 到此整段代码执行完毕,输出顺序为1,2,3,4,5,6,7,8,9,10,11,12。
写在最后:
- 事件循环是js实现异步的一种方法,也是js的执行机制。
- 执行和运行有很大的区别,javascript在不同的环境下,比如node,浏览器,Ringo等等,执行方式是不同的。而运行大多指javascript解析引擎,是统一的。
- 最后的最后
- javascript是一门单线程语言
- Event Loop(事件循环)是JavaScript的执行机制