前置知识
JavaScript引擎是单线程的,一段JS代码只能在一个线程从上到下执行。
JS内置了一个setTimeout函数,用途是指定某个函数或者某段代码在多少毫秒之后执行。
疑问
console.log(1)setTimeout(()=>{console.log(2)},1000)console.log(3)
有这么一段代码,按照JS是单线程的说法,输出结果应该是1,过了1秒之后输出2,最后才输出3。然而事实是先输出1和3,过了1秒后输出2。
为什么单线程的JS,执行setTimeout()函数的时候下面的表达式不会被阻塞,而是无视setTimeout的运行状态,继续执行呢?
浏览器的线程
如果所有代码就放在JS主线程上执行,那么若setTimeout的延迟时间非常久,下面的代码一直等待setTimeout返回成功才继续执行,那么JS的效率可就太低了,所以为了解决这个问题,浏览器搞了几个其他线程辅助JS主线程的运行。
GUI渲染线程
JS引擎线程
setTimeout定时器触发线程
...等等
其中JS引擎线程也就是主线程,就是运行JS代码的线程,setTimeout线程是异步线程。
任务队列
要实现非阻塞,主要靠异步,怎么实现呢,需要有一个静态的任务队列,存储异步处理完毕后返回的回调函数。
同步任务
在主线程上排队执行的任务,常见的有:
输出,如console.log()
变量声明
同步函数,也就是被调用时不会立即返回,而是等函数内所有任务都做完了再返回的函数。
异步任务
在异步线程上执行的任务,比如setTimeout,常见的还有AJAX等。
工作原理
一开始主线程执行console.log(1)
,紧接着检测到setTimeout()
函数,把它移交给响应的异步线程处理,之后主线程就跳过setTimeout()
,继续执行下面的console.log(3)
;一开始任务队列是空的,主线程中的setTimeout()
进入异步线程后,开始执行,经过1秒钟的延迟后,setTimeout()
的回调函数进入任务队列,主线程的同步任务执行完毕后,进入了空闲期,于是开始询问任务队列是否有任务需要主线程完成,此时任务队列里存在之前的setTimeout()
运行完毕后的回调函数,这个函数被取出到主线程执行,于是回调函数中的console.log(2)
内容被执行。(主线程、异步线程和任务队列中其实还有一个中介人叫轮询处理线程Event Loop,为了简化描述这里省略了)。
函数执行时机对输出结果的影响
从上面的描述可以知道,因为JS的异步特性,函数执行的时机不同,最后输出的结果也可能会不同。尤其是异步函数,因为被调用的时间和实际完整地在主线程中执行完毕的时间不一致,这个过程中异步函数涉及到的变量可能早已发生变化,输出结果可能不符合我们这些初学者的预期。
典型例子
letifor(i=0;i<6;i++){setTimeout(()=>{console.log(i)},0)}
这段代码输出值为6 6 6 6 6 6,而不是0 1 2 3 4 5,原因就像上面说的,setTimeout()
是异步函数,异步函数一开始并不在主线程中执行,主线程最先执行let i
变量声明,然后执行for循环,for循环中每个循环执行一次异步函数setTimeout()
,而这个异步函数移入setTimeout定时器触发线程,在这个异步线程中执行完毕后回调函数进入任务队列,一共6个循环,任务队列中就有6个回调函数console.log(i)
等待主线程调用。6个循环结束后,同步任务for循环运行结束,主线程中没有要执行的任务了,就询问任务队列是否为空,任务队列中有6个console.log(i)
,依次按先入先出取出到主线程中执行,而这时i已经变成6了,所以输出6个6。
例外
for(leti=0;i<6;i++){setTimeout(()=>{console.log(i)},0)}
这段代码输出值不是6 6 6 6 6 6,而是0 1 2 3 4 5,原因是变量i放在了for循环参数列表中声明,这样做每次循环都会多创建一个i的副本,用来保存当前循环的i值,最后执行console.log(i)
时,打印的不是i,而是前面i在不同阶段创建的副本,跟刻舟求剑有异曲同工之妙。
其他输出0 1 2 3 4 5的方法
用带参函数实现参数传递
letifunctiona(i){setTimeout(function(){console.log(i)},0);}for(i=0;i<6;i++){a.call(undefined,i);}
将回调函数写成立即执行函数
letifor(i=0;i<6;i++){setTimeout((()=>{console.log(i)})(),0)}