作为最受欢迎的Web编程语言,Javascript的单线程执行是其一大特点,也就是说在同一时间只能有一个任务处于执行状态,而后续的任务需要等待当前任务处理完毕后才能继续处理,而在当前编程语言普遍都支持多线程执行的情况下,Javascript依旧保持着其单线程执行的机制,并且单线程所带来的最显著的一个问题在于当任务需要的执行时间过长时,就会导致整个线程的任务队列出现阻塞,反馈在页面上就会造成页面内容出现加载错误甚至是短暂的白屏,给用户带来极其糟糕的体验,那么既然单线程带来的问题显而易见且可以通过多线程并行执行任务的方式来解决,那么为什么Javascript依旧保留了其单线程执行的特点呢?并且为了服务于更加复杂的页面结构,势必需要有更加合理的解决办法来实现页面的加载不受制于单个任务的过长处理时间,Javascript内部又是如何来处理这个问题呢?
给出答案前,我们首先思考一个问题,就是我们需要用这个编程语言来实现什么样的场景。对于Javascript而言,Web就是它最大的用武之地,我们通过使用JS来创造出可视化的交互页面以及操作DOM来搭建页面需要的元素结构,这就决定了JS的执行过程一点要有一个先后的顺序,举一个简单的例子,假如我们需要在页面的一个节点上进行内容的添加
以及删除节点
两个操作,如果此时的JavaScript是一个多线程的执行过程,当节点内容的添加
和删除节点
并行执行,浏览器到底是应该以哪一个任务为先哪一个任务在后呢?为了避免可能发生的交互逻辑执行顺序混乱的情况,Javascript从一诞生起就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
单线程执行
多线程执行
在前面的小节中我们解释了JS保持其单线程执行的原因,那么在这一个小节中,我们接着探讨另外一个问题,那就是我们要用什么样的方式来解决在单线程的执行条件下,由于单个任务执行时间过长而导致的队列阻塞问题,答案就是异步。
在JS的单线程环境当中,所有的任务排成一列按照顺序执行,后面的任务只有等待前面的任务执行完毕后,才能被继续处理,这就是同步任务,也就是每一个任务的执行都会受制于前面任务的执行状态,不能插队。而作为能够解决队列阻塞问题的异步任务的定义就是只有当需要任务执行的时候才会加入到主线程队列当中顺序执行,不需要时则加入到任务队列当中挂起,这样当我们存在一个诸如请求发送(AJAX)的任务时,就可以暂时先将该任务放置到任务队列当中,等待请求成功返回后,再推入到主线程执行其返回结果。任务队列(也称消息队列),是一个独立于主线程之外的执行队列,专门用于存放异步任务,当代码执行遇到异步任务时,将异步任务加入到任务队列当中挂起,等待主线程执行栈中的同步任务执行完毕后,扫描任务队列,如果任务队列中存在挂起的异步任务,则将其依次加入到主线程执行栈中顺序执行,且满足队列"先进先出"的原则。
我们来看下面的这段代码,结合任务队列来分析一下代码最终的输出结果
console.log("start") setTimeout(() => { console.log("setTime_1") }, 0) setTimeout(() => { console.log("setTime_2") }, 0) console.log("end")
这是一段很简单的代码,如果按照同步执行顺序来解读的话,那么执行的结果应该是start
=》setTime_1
=》setTime_2
=》end
,但是实际的结果如下:
start end settime_1 setTime_2
结合上述任务队列和异步任务的基本概念,我们来具体分析一下代码的执行情况:
console.log("start")
加入主线程的执行栈执行,输出执行结果start
setTimeout(() => console.log("setTime_1"), 0)
加入主线程的执行栈执行,方法包含回调函数,故其回调函数加入任务队列挂起setTimeout(() => console.log("setTime_2"), 0)
加入主线程的执行栈执行,方法包含回调函数,故其回调函数加入任务队列挂起console.log("end")
加入主线程的执行栈执行,输出执行结果end
我们可以看到,任务队列的存在改变了最终代码的输出顺序,但是目前我们只是简单地将执行的任务分类为同步任务和异步任务,实际上,对于异步任务而言,还存在更进一步的划分,也就是说在异步任务队列当中,并不是简单地将所有的异步任务都加入到一个任务队列当中顺序挂起,而是进一步划分成为了多个任务队列,根据一个既定的原则来判断哪一个任务队列中的任务先推入主线程的执行栈中,哪一个任务队列中的异步任务后推入主线程的执行栈中,这就是我们接下来要讨论到的宏任务队列和微任务队列。
宏任务和微任务
在JS的异步系统当中,对于任务队列进一步划分为宏任务队列和微任务队列,当我们主线程执行栈中的同步任务执行完毕,首先扫描宏任务队列,取队首元素加入到主线程执行栈,执行完毕后开始扫描微任务队列,如果存在微任务,则清空微任务队列推入到主线程执行栈中,等待执行完毕,继续扫描宏任务队列,以此顺序在主线程执行栈,宏任务队列,微任务队列间往复循环直至执行栈和所有任务队列都处于空状态为止。
宏任务(macro-task):script代码,setTimeout(),setInterval(),I/O操作,UI交互事件等
微任务(micro-task):Promise.then()方法,process.nextTick(Node.js环境)等
我们来看下面这段复杂一些的代码,结合宏任务和微任务的划分来分析一下代码最终的输出结果
console.log(1) setTimeout(() => { console.log(2) }) setTimeout(() => { console.log(3) }) // Promise 是异步编程的一种解决方案,比传统的解决方案——回调函数和事件——更合理和更强大。 // 它由社区最早提出和实现,ES6 将其写进了语言标准,统一了用法,原生提供了Promise对象。 new Promise((resolve, reject) => { console.log(4) resolve() }).then(() => { console.log(5) }).then(() => { console.log(6) }) setTimeout(() => { console.log(7) }) console.log(8)
script代码块
作为第一个宏任务加入到主线程的执行栈中console.log(1)
进入主线程的执行栈执行,输出1
setTimeout(() => console.log(2))
进入主线程的执行栈执行,其回调函数进入宏任务队列挂起setTimeout(() => console.log(3))
进入主线程的执行栈执行,其回调函数进入宏任务队列挂起Promise对象内部的代码块立即执行
,输出4
,执行resolve()
方法,其返回结果的then()
方法加入微任务队列挂起setTimeout(() => console.log(7))
进入主线程的执行栈执行,其回调函数进入宏任务队列挂起console.log(8)
进入主线程的执行栈执行,输8
() => console.log(2)
,() => console.log(3)
,() => console.log(7)
console.log(5)
,console.log(6)
5
,6
2
3
,7
最终的打印结果如下:
1 4 8 5 6 2 3 7
到此,通过对JS的单线程特点,异步任务,任务队列以及宏任务队列和微任务队列这些概念的梳理,我们应该对于JS的整个异步系统的运行有了基本的了解,我们来概括一下JS的异步系统的运行步骤:
(JavaScript异步任务系统的执行过程)
首先,引用阮一峰老师在《JavaScript 运行机制详解:再谈Event Loop》一文中对于事件循环机制的概括定义为:主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为Event Loop(事件循环)。
其实通过这句话,再结合上述我们花费大量篇幅对于JS的异步任务系统的解读,我们应该已经可以有一个很清晰的认识了,事件循环机制的本质就是当我们的主线程执行完同步任务之后,不断地将宏任务队列和微任务队列中的异步任务加入到主线程的执行栈当中,这一过程是往复循环,并且直至所有任务队列和主线程执行栈为空,这下大家就知道我们为什么花费了大量的篇幅来讲解关于JS的异步任务系统的各个组成部分的概念,而直到文章末尾才真正的点名了本文的主题事件循环机制,因为JS的单线程特点,导致了我们需要通过异步任务这样的方式来解决在主线程上任务执行可能会产生的队列阻塞现象,而异步任务队列的进一步划分,且其特定的执行顺序最终形成了JS的事件循环这样一个特殊的机制,其实归根结底,不论是异步任务的产生,还是对于异步任务队列的进一步划分为宏任务和微任务队列,都是为了能更好的解决单线程执行引发的队列阻塞问题,从而使得最终实现的页面能够承载更加复杂的交互逻辑以及更快的内容加载速度。