本文目录
- 1.基础概念
- 2.调用栈
- 3.事件轮询
- 4.ES6的任务队列
- 5.从setTimeout()理解事件循环机制
1.基础概念
Javascript语言是一门“单线程”的语言,所以,javascript就像一条流水线,仅仅是一条流水线而已,要么加工,要么包装,不能同时进行多个任务和流程。
最简单直接的理解:可以改变程序正常执行顺序的操作就可以看成是异步操作。
如:
<script type="text/javascript">
console.log( "1" );
setTimeout(function() {
console.log( "2" )
}, 0 );
setTimeout(function() {
console.log( "3" )
}, 0 );
setTimeout(function() {
console.log( "4" )
}, 0 );
console.log( "5" );
</script>
//输出顺序是 1,5,2,3,4
可见,尽管我们设置了setTimeout(function,time)中的等待时间为0,结果其中的function还是最后执行。
js所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有等主线程任务执行完毕,"任务队列"开始通知主线程,请求执行任务,该任务才会进入主线程执行。
具体来说,异步运行机制如下:
(1)所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
(2)主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
(3)一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
(4)主线程不断重复上面的第三步。
所谓"回调函数"(callback),就是那些会被主线程挂起,比如说点击事件$(selectot).click(function)中的function就是一个回调函数。异步任务必须指定回调函数,当主线程开始执行异步任务,就是执行对应的回调函数。例如ajax的success,complete,error也都指定了各自的回调函数,这些函数就会加入“任务队列”中,等待执行。
定时器,事件绑定都是异步操作,ajax一般采取异步操作,回调函数可以理解为异步操作。
2.调用栈
JS 只有一个调用栈,因为它是一种单线程编程语言。调用堆栈具有 LIFO 结构,这意味着项目只能从堆栈顶部添加或删除。
3.事件轮询
上述的“任务队列”严格意义上叫做“消息队列”
事件轮询的工作是监听调用堆栈,并确定调用堆栈是否为空。如果调用堆栈是空的,它将检查消息队列,看看是否有任何挂起的回调等待执行。
在这种情况下,消息队列包含一个回调,此时调用堆栈为空。因此,事件轮询将回调推到堆栈的顶部。例如 console.log(“Async Code”) 被推送到堆栈顶部,执行并从堆栈中弹出。此时,回调已经完成,因此从堆栈中删除它,程序最终完成。
消息队列还包含来自DOM事件(如单击事件和键盘事件)的回调。例如:
document.querySelector('.btn').addEventListener('click',(event) => {
console.log('Button Clicked');
});
对于DOM事件,事件侦听器位于web api环境中,等待某个事件(在本例中单击event)发生,当该事件发生时,回调函数被放置在等待执行的消息队列中。
同样,事件轮询检查调用堆栈是否为空,并在调用堆栈为空并执行回调时将事件回调推送到堆栈。
4.ES6的任务队列
我们已经了解了异步回调和DOM事件是如何执行的,它们使用消息队列存储等待执行所有回调。
ES6引入了任务队列的概念,任务队列是 JS 中的 promise 所使用的。消息队列和任务队列的区别在于,任务队列的优先级高于消息队列,这意味着任务队列中的promise 作业将在消息队列中的回调之前执行,例如:
const bar = () => {
console.log('bar');
};
const baz = () => {
console.log('baz');
};
const foo = () => {
console.log('foo');
setTimeout(bar, 0);
new Promise((resolve, reject) => {
resolve('Promise resolved');
}).then(res => console.log(res))
.catch(err => console.log(err));
baz();
};
foo();
打印结果:
foo
baz
Promised resolved
bar
可以看到 promise 在 setTimeout 之前执行,因为 promise 响应存储在任务队列中,任务队列的优先级高于消息队列。
上面所说的任务队列也可以称之为微任务,而消息队列也就是宏任务,下面用一个具体例子来讲解下JS中的事件循环机制。
5.从setTimeout()理解事件循环机制
首先我们还是来看那道大家再熟悉不过的前端面试题:
for (var i = 1; i <= 5; i++) {
setTimeout(function timer() {
console.log(i)
}, i * 1000)
}
我想刚入门的童鞋或者对JS作用域、闭包以及事件循环等概念不了解的童鞋会想当然的认为这道题的答案应该是:
第一次循环,隔一秒输出1;
第二次循环,隔两秒输出2;
第三次循环,隔三秒输出3;
第四次循环,隔四秒输出4;
第五次循环,隔五秒输出5;
或者还有同学预期的结果是分别输出数字1~5,每秒一次,每次一个。
但实际结果大家去控制台打印了都知道:以一秒的频率连续输出五个6!
相信对于很多童鞋第一次看到这个结果是懵的,包括我第一次看到结果是懵逼的!
然而还没等你反应过来,面试官又要求你改动一下代码,要它以一秒的频率分别输出1,2,3,4,5。如果你不了解或者没有深入理解JS中的作用域、闭包以及事件循环,那么就可以和面试官说拜拜了。
这道题涉及到的知识点我上面已经提到过两次,这里我们还是先简单地过一下这些知识点:
1、作用域:这里我引用《你不知道的javascript》中的一个比喻,可以把作用域链想象成一座高楼,第一层代表当前执行作用域,楼的顶层代表全局作用域。我们在查找变量时会先在当前楼层进行查找,如果没有找到,就会坐电梯前往上一层楼,如果还是没有找到就继续向上找,以此类推。到达顶层后(全局作用域),可能找到了你所需的变量,也可能没找到,但无论如何查找过程都将停止。
2、闭包:我的理解是在传递函数类型的变量时,该函数会保留定义它的所在函数的作用域。读起来可能比较绕,或者可以简单的这么理解,A函数中定义了B函数并且它返回了B函数,那么不管B函数在哪里被调用或如何被调用,它都会保留A函数的作用域。
3、事件循环:这个概念深入起来很复杂,下面新开一个段落只说一些跟本文相关的内容。
说起事件循环,不得不提起任务队列。事件循环只有一个,但任务队列可能有多个,任务队列可分为宏任务(macro-task)和微任务(micro-task)。XHR回调、事件回调(鼠标键盘事件)、setImmediate、setTimeout、setInterval、indexedDB数据库操作等I/O以及UI rendering都属于宏任务(也有文章说UI render不属于宏任务,目前还没有定论),process.nextTick、Promise.then、Object.observer(已经被废弃)、MutationObserver(html5新特性)属于微任务。注意进入到任务队列的是具体的执行任务的函数。比如上述例子setTimeout()中的timer函数。另外不同类型的任务会分别进入到他们所属类型的任务队列,比如所有setTimeout()的回调都会进入到setTimeout任务队列,所有then()回调都会进入到then队列。当前的整体代码我们可以认为是宏任务。事件循环从当前整体代码开始第一次事件循环,然后再执行队列中所有的微任务,当微任务执行完毕之后,事件循环再找到其中一个宏任务队列并执行其中的所有任务,然后再找到一个微任务队列并执行里面的所有任务,就这样一直循环下去。这就是我所理解的事件循环。来,还是看个栗子:
console.log('global')
setTimeout(function () {
console.log('timeout1')
new Promise(function (resolve) {
console.log('timeout1_promise')
resolve()
}).then(function () {
console.log('timeout1_then')
})
},2000)
for (var i = 1;i <= 5;i ++) {
setTimeout(function() {
console.log(i)
},i*1000)
console.log(i)
}
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('then1')
})
setTimeout(function () {
console.log('timeout2')
new Promise(function (resolve) {
console.log('timeout2_promise')
resolve()
}).then(function () {
console.log('timeout2_then')
})
}, 1000)
new Promise(function (resolve) {
console.log('promise2')
resolve()
}).then(function () {
console.log('then2')
})
我们来一步一步分析以上代码:
1)、首先执行整体代码,“global”会被第一个打印出来。这是第一个输出.
2)、执行到第一个setTimeout时,发现它是宏任务,此时会新建一个setTimeout类型的宏任务队列并派发当前这个setTimeout的回调函数到刚建好的这个宏任务队列中去,并且轮到它执行时要延迟2秒后再执行。
3)、代码继续执行走到for循环,发现是循环5次setTimeout(),那就把这5个setTimeout中的回调函数依次派发到上面新建的setTimeout类型的宏任务队列中去,注意,这5个setTimeout的延迟分别是1到5秒。此时这个setTimeout类型的宏任务队列中应该有6个任务了。再执行for循环里的console.log(i),很简单,直接输出1,2,3,4,5,这是第二个输出。
4)、再执行到new Promise,Promise构造函数中的第一个参数在new的时候会直接执行,因此不会进入任何队列,所以第三个输出是"promise1",上面有说到Promise.then是微任务,那么这里会生成一个Promise.then类型的微任务队列,这里的then回调会被push进这个队列中。
5)、再继续走,执行到第二个setTimeout,发现是宏任务,派发它的回调到上面setTimeout类型的宏任务队列中去。
6)、再走到最后一个new Promise,很明显,这里会有第四个输出:"promise2",然后它的then中的回调也会被派发到上面的Promise.then类型的微任务队列中去。
7)、第一轮事件循环的宏任务执行完成(整体代码可以看做宏任务)。此时微任务队列中只有一个Promise.then类型微任务队列,它里面有两个任务。宏任务队列中也只有一个setTimeout类型的宏任务队列。
8)、下面执行第一轮事件循环的微任务,很明显,会分别打印出"then1",和"then2"。分别是第五和第六个输出。此时第一轮事件循环完成。
9)、开始第二轮事件循环:执行setTimeout类型队列(宏任务队列)中的所有任务。发现都有延时,但延时最短的是for循环中第一次循环push进来的那个setTimeout和上面第5个步骤中的第二个setTimeout,它们都只延时1s。它们会被同时执行,但前者先被push进来,所以先执行它!它的作用就是打印变量i,在当前作用域找变量i,木有!去它上层作用域(这里是全局作用域)找,找到了,但此时的i早已是6了。(为啥不是5,那你得去补补for循环的执行流程了~)所以这里第七个输出是延时1s后打印出6。
10)、紧接着执行第二个setTimeout,它会先后打印出"timeout2"和"timeout2_promise",这分别是第八和第九个输出。但这里发现了then,又把它push到上面已经被执行完的then队列中去。
11)、这里要注意,因为出现了微任务then队列,所以这里会执行该队列中的所有任务(此时只有一个任务),即打印出"timeout2_then"。这是第十个输出。
11)、继续回过头来执行宏任务队列,此时是执行延时为2s的第一个setTimeout和for循环中第二次循环的那个setTimeout,跟上面一样,前者是第一个被push进来的,所以它先执行。这里会延时1秒(原因下面会解释)分别输出“timeout1”和“timeout1_promise”,但发现了里面也有一个then,于是push到then微任务队列并立即执行,输出了"timeout1_then"。紧接着执行for中第二次循环的setTimeout,输出6。注意这三个几乎是同时被打印出来的。他们分别是第十一到十三个输出。
12)、再就很简单了,把省下的for循环中后面三次循环被push进来的setTimeout依次执行,于是每隔1s输出一个6,连续输出3次。
13)、第二轮事件循环结束,全部代码执行完毕。
所以上代码的执行结果为:
global
1
2
3
4
5
promise1
promise2
then1
then2
//延迟1s
6
timeout2
timeout2_promise
timeout2_then
//延迟1s
timeout1
17 timeout1_promise
20 timeout1_then
6
//每隔1s输出3个6
这里解释下为什么上面第11步不是延迟2秒再出“timeout1”和“timeout1_promise”,这时需要理解setTimeout()延时参数的意思,这个延迟时间始终是相对主程序执行完毕的那个时间算的 ,并且多个setTimeout执行的先后顺序也是由这个延迟时间决定的。
再回过头来看上面那个问题,理解了事件循环的机制,问题就很简单了。for循环时setTimeout()不是立即执行的,它们的回调被push到了宏任务队列当中,而在执行任务队列里的回调函数时,变量i早已变成了6。那如何得到想要的结果呢?很简单,原理就是需要给循环中的setTimeout()创建一个闭包作用域,让它执行的时候找到的变量i是正确的。
知道了原理,解决方案就很多了,下面给出5种方案,
(1)引入IIFE
for(var i = 0;i<5;i ++) {
(function(i){
setTimeout(function timer() {
console.log(i)
}, i * 1000);
})(i);
}
(2)利用ES 6引入的let关键字
for(let i = 0;i<5;i++) {
setTimeout(function timer(){
console.log(i);
}, i * 1000);
}
for 循环头部的let 声明还会有一个特殊的行为。这个行为指出变量在循环过程中不止被声明一次,每次迭代都会声明。随后的每个迭代都会使用上一个迭代结束时的值来初始化这个变量。
(3)利用ES 5引入的bind函数
for (var i=1; i<=5; i++) {
setTimeout( function timer(i) {
console.log(i);
}.bind(null,i), i*1000 );
}
(4)利用setTimeout第三个参数
for (var i=1; i<=5; i++) {
setTimeout( function timer(i) {
console.log(i);
}, i*1000,i );
}
注:setTimeout函数第三个参数及以后的参数都可以作为timer函数的参数。
(5)把setTimeout用一个方法单独出来形成闭包
var loop = function (i) {
setTimeout(function timer() {
console.log(i);
}, i*1000);
};
for (var i = 1;i <= 5; i++) {
loop(i);
}