在学习js的时候,或者面试的时候,会常常碰到这一道经典题目:面试
for(var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }); } console.log('a');
熟悉这道题目的人立马就能够说出答案:浏览器
'a' 5 5 5 5 5
结果是先打印字符串'a',而后再打印5个数字5。网络
有人会说这个题目并不难,并且只要你遇到过这个题目,下次再见到基本也不会答错了,但其实这段简单的代码里面包含了不少js知识。多线程
这里就整理总结一下。闭包
单线程、任务队列以及事件循环(event loop)异步
第一次看到这段代码的时候,会给人一种错觉:async
可是实际运行结果跟咱们预期的不同,缘由就是由于这里涉及到了js的运行机制。函数
单线程oop
JavaScript语言的一大特色就是单线程,也就是说,同一个时间只能作一件事。学习
为何不容许js能够实现多线程?由于若是实现了多线程,一个线程建立了一个div元素,而另一个线程删除了这个div元素,那么这个时候浏览器应该听谁的?
因此为了不出现这种互相冲突的操做,js从一开始就是单线程的,这就是它的核心特征。
任务队列
单线程就意味着,全部任务须要排队,前一个任务结束,才会执行后一个任务。若是前一个任务耗时很长,后一个任务就不得不一直等着。
若是排队是由于计算量大,CPU忙不过来,倒也算了,可是不少时候CPU是闲着的,由于IO设备(输入输出设备)很慢(好比Ajax操做从网络读取数据),不得不等着结果出来,再往下执行。
JavaScript语言的设计者意识到,这时主线程彻底能够无论IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回告终果,再回过头,把挂起的任务继续执行下去。
因而,全部任务能够分红两种,一种是同步任务(synchronous),另外一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务能够执行了,该任务才会进入主线程执行。
只要主线程空了,就会去读取"任务队列",这就是JavaScript的运行机制。这个过程会不断重复。
"任务队列"中的事件,除了IO设备的事件之外,还包括一些用户产生的事件(好比鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件发生时就会进入"任务队列",等待主线程读取。
事件循环(event loop)
主线程从"任务队列"中读取事件,这个过程是循环不断的,因此整个的这种运行机制又称为Event Loop(事件循环)。
主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各类外部API,它们在"任务队列"中加入各类事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。
定时器
在了解了刚才那些知识以后,再回过头来看看这段代码:
for(var i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }); } console.log('a');
为何明明定时器的时间设置为了0(setTimeout不写延迟时间参数默认值为0)?定时器却在console.log('a')这句代码运行了以后才运行?
原来在js的任务队列里,除了放置异步操做以外,还会放置定时器事件。
当js代码运行到有定时器的地方的时候,会把定时器操做放在任务队列的尾部,而后跟它说:“你先排队吧,尚未轮到你,由于同步代码尚未执行完。”
这里所说的 同步代码 就是指下面的console.log('a')。
也就是说,js认为setTimeout是一个异步操做,必须让它排队,它只能在同步代码执行结束后才能执行。
因此这里的缘由总结就是这样一句话:
定时器并非同步的,它会自动插入任务队列,等待当前文件的全部同步代码和当前任务队列里的已有事件所有运行完毕后才能执行。
这就是为何字符串'a'在5个5以前就打印出来的缘由。
那么为何是5个5呢?为何不是0,1,2,3,4?
这是由于在全部同步代码执行完毕以后,for循环里的i值早已变成了5,循环已经结束。(注意,for循环的圆括号部分也是同步代码)
这就是为何打印出来5个5,而不是0,1,2,3,4。
因此这段代码真实的运行状况你能够假想成这样,便于理解:
for(var i = 0; i < 5; i++) { } console.log('a'); setTimeout(function () { console.log(i); }); setTimeout(function () { console.log(i); }); setTimeout(function () { console.log(i); }); setTimeout(function () { console.log(i); }); setTimeout(function () { console.log(i); }); //先循环,i变成了5,而后打印a,而后再打印5次i
//这里只是假想,便于理解
做用域和闭包
这道题目还会引伸出来另外一个问题:
若是想要for循环里的定时器打印出0,1,2,3,4,而不是5个5,该怎么办?
答案是:使用当即执行函数。
for(var i = 0; i < 5; i++) { (function(i) { setTimeout(function () { console.log(i); }); })(i) } console.log('a');
打印结果:
'a' 0 1 2 3 4
这又是为何?
这是由于for循环里定义的i变量其实暴露在全局做用域内,因而5个定时器里的匿名函数它们其实共享了同一个做用域里的同一个变量。
因此若是想要0,1,2,3,4的结果,就要在每次循环的时候,把当前的i值单独存下来,怎么存下当前的循环i值??
利用闭包的原理,闭包使一个函数能够继续访问它定义时的做用域。而这个新生成的做用域将每一次循环的当前i值单独保存了下来。
for(var i = 0; i < 5; i++) { (function(i) {//这个匿名函数生成了闭包的效果,新建了一个做用域,这个做用域接收到每次循环的i值保存了下来,即便循环结束,闭包造成的做用域也不会被销毁 setTimeout(function () { console.log(i); }); })(i) }
let关键字、块做用域以及try...catch语句
若是想实现for循环里的定时器打印出0,1,2,3,4,除了闭包,还可使用ES6的let关键字。
for(let i = 0; i < 5; i++) { setTimeout(function () { console.log(i); }); }
注意for循环定义i的时候把var换成了let,打印出的结果就是0,1,2,3,4
这是问什么呢?
由于let关键字劫持了for循环的块做用域,产生了相似闭包的效果。而且在for循环中使用let来定义循环变量还会有一个特殊效果:每一次循环都会从新声明变量i,随后的每一个循环都会使用上一个循环结束时的值来初始化这个变量i。
let能够实现块做用域的效果,可是它是ES6语法,在低版本语法的时候如何生成块做用域?
答案是:使用try...catch语句。
看下面的效果:
for(var i = 0; i < 5; i++) { try { throw(i) } catch(j) { setTimeout(function () { console.log(j); }); } } //打印结果0,1,2,3,4
神奇的效果出现了!
这是由于try...catch语句的catch后面的花括号是一个块做用域,和let的效果同样。因此在try语句块里抛出循环变量i,而后在catch的块做用域里接收到传过来的i,就能够将循环变量保存下来,实现相似闭包和let的效果。
好了,这就是关于这道面试题涉及到的知识。