Javascript执行机制 - 宏任务与微任务、事件循环(Event Loop)

Javascript执行机制 - 宏任务与微任务、事件循环(Event Loop)

概括

我们常说 Javascript (下面简称JS)是一门单线程编程语言,而单线程就意味着同一时刻只允许一段代码在主线程上执行,那么对于执行一些需要长时间等待的任务来说,它们会占据线程不放,这会造成后续代码无法执行,程序无法正常使用。这是单线程的弊端,而 JS 是通过事件循环机制(Event Loop)来解决这一弊端。

HTML5 标准中提出的新技术 WebWork 概念,它用来实现 “多线程” 技术,但这其实也是单线程模拟出来的,JS 单线程这一核心仍未改变,这点需要注意下噢。

要理解清楚事件循环这个机制会涉及很多让人头疼的概念,如 JS 的执行机制、调用栈(执行栈)、任务队列(消息队列)、同步任务与异步任务、宏任务与微任务。当然,认识完这些东西,你对 JS 将会有更深层次的认识,话不多说,我们开始本篇的愉快旅程吧。

栈、堆、队列

在讲正题之前,我们先来了解一下三个数据结构类型,相信很多小伙伴对它们也很熟悉了,之所以讲这个,是因为下面可能会涉及这其中的概念,希望你能有个更好的认识。

  • 栈(Stack):栈是一种特殊的列表,栈内的元素只能通过列表的一端访问,这一端被称为栈顶。栈是一种后进先出(LIFO,last-in-first-out)的数据结构。由于栈具有后进先出的特点,所以任何不在栈顶的元素都无法被访问,为了得到栈底的元素,就必须先拿掉上面的元素。

image.png

  • 队列(Queue):栈数据结构的访问规则是LIFO(后进先出),而队列数据结构的访问规则是FIFO(Fist-In-First-Out,先进先出)。队列是队尾添加项的,然后从队列的队头移除项的。

image.png

  • 堆(Heap):堆是一种经过排序的树形数据结构,它每个结点都有一个值。通常我们常说的堆数据结构,是指二叉堆。堆的特点是 "根结点" 的值是最小或者最大的,且根结点的两个子树也是一个堆。

满足以下条件即为堆:

  1. 除了最后一层,其他层的节点个数都是满的,最后一层的节点都集中在左部连续位置。
  2. 堆中每一个节点的值都必须大于等于(或小于等于)其左右子节点的值。

由于堆的这个特性,常用来实现优先队列,堆的存取是随意,这就如同我们在图书馆的书架上取书,虽然书的摆放是有顺序的,但是我们想取任意一本时不必像栈一样,先取出前面所有的书,我们只需要关心书的名字。

每个节点都大于等于其子树节点的堆叫 "大顶堆",根是最大值;每个节点都小于等于其子树节点的堆叫 "小顶堆",根是最小值。

image.png

其实感觉整这么多概念也没啥用,记不住(T_T)。对于这三个数据结构,它们很重要,这点很明确,但要怎么记住它们呢?我是记住它的性质再结合例子记忆:

  • 栈:先进后出 or 后进先出。像子弹夹,先压入弹夹的子弹,最后才射出。
  • 队列:先进先出 or 后进后出。像超市排队结算,先排队的人结算完肯定先走了。
  • 堆:顺序排放,随意取用。像书架排列的书,我们按字母或者分类排放好,但取书的时候只要知道书名就能直接找到这本书了。

JS执行机制

我们知道 JS 的执行顺序是从上到下一行一行顺序执行的,但是如果在执行过程中碰到耗时比较长的任务要怎么办呢? JS 就只能傻等了吗? 当然没那么傻了,聪明的程序猿把任务分成了同步任务异步任务,避免了单线程的 JS 在执行过程被阻塞的问题,下面小编画了一幅图来描述这个执行过程,来瞅瞅吧:

JS执行机制.jpg

图解:

  • JS 在开始执行的时候,会把任务分为同步任务和异步任务。

    任务: 一个任务指的是由执行诸如从头执行的一段程序、或是执行某一个事件回调、或是一个 setInterval/setTimeout 被触发之类的标准机制而被调度的任意 JS 代码。详细解释

    同步任务:指的是在主线程上排列执行的任务。只有前一个任务执行完毕,后一个任务才能被执行。

    异步任务:不进入主线程,而进入 "任务队列" 的任务。只有当任务队列通知主线程,某个异步任务已经有结果了,可以执行了,该异步任务才会进入主线程被执行。

  • 同步任务会直接进入主线程依次被执行。

  • 异步任务会进入到 "任务队列" 中,等待异步任务有了结果后通知主线程,然后会以回调函数的形式在任务队列中一直等待被读取。当主线程空闲的时候(执行栈被清空),它会去读取任务队列中的回调函数进入主线程中执行。

  • 异步任务可以再分为宏任务微任务

宏任务与微任务

JS 把异步任务再分为宏任务和微任务。那么,它们俩又是什么呢?

  • 宏任务(Macrotask):可以理解为每次执行栈执行的代码就是一个宏任务。(包括每次从任务队列中读取一个异步任务的事件回调函数放入到执行栈中执行,这个回调函数也是一个宏任务了)。
  • 微任务(Microtask):可以理解为夹杂在两个宏任务之间执行的任务,就是微任务。它的执行时机是在当前宏任务之后,在下一个宏任务之前。微任务可以被主动创建出来,也可能是在执行宏任务过程中产生,多个微任务会形成一个微任务队列更多详情

为什么会产生宏任务和微任务呢?
前面我们说过,异步任务会进入所谓的 "任务队列" 中了,而任务队列具有 队列 的性质,先进先出,也就是后加入的任务必须等待前面的任务执行完才能执行。如果在执行的过程中突然有重要的数据需要获取,或是说有另外的重要的突然事件需要处理一下,那按照队列的先进先出顺序这些 "突然事件" 是无法得到及时处理的。这个时候就催生了宏任务和微任务概念,微任务使得一些 "突然事件" 的异步任务得到及时的处理。

举个例子形容宏任务和微任务?
之前小编在某篇文章中看到一个很好的例子,它把宏任务和微任务形象的比喻为:当你去营业厅办一个业务会有一个排队号码,当叫到你号码的时候,你会去窗口办充值业务(宏任务执行),在你办理充值的时候你又想改个套餐(微任务),这个时候工作人员会直接帮你办,不可能让你重新排队,这是不是很形象(^ω^)。例子来源

产生宏任务和微任务分别有哪些?

  • 宏任务:

    1. script(整体的代码)
    2. setTimeout
    3. setInterval
    4. I/O 操作
    5. UI渲染
    6. setImmediate (Node环境)
  • 微任务:

    1. Promise.then
    2. MutationObserver API具体使用
    3. Process.nextTick(Node环境)
    4. Object.observe (已废弃)

微任务与任务的区别?

  1. 首先,每当一个任务存在,事件循环都会检查该任务是否正把控制权交给其他 JS 代码。如若不然,事件循环就会运行微任务队列中的所有微任务。接下来微任务循环会在事件循环的每次迭代中被处理多次,包括处理完事件和其他回调之后。

  2. 其次,如果一个微任务通过调用 queueMicrotask(), 向队列中加入了更多的微任务,则那些新加入的微任务会早于下一个任务运行 。这是因为事件循环会持续调用微任务直至微任务队列中没有留存的,即使是在有更多微任务持续被加入的情况下。

注意: 因为微任务自身可以入列更多的微任务,且事件循环会持续处理微任务直至微任务队列为空,那么就存在一种使得事件循环无尽处理微任务的真实风险。如何处理递归增加微任务是要谨慎而行的。

事件循环

了解完 JS 的整个执行机制过程后,事件循环(Event Loop)就比较简单好理解了,开头我们提过它的出现是为了解决 JS 单线程带来的弊端,它也是整个 JS 单线程执行过程中最核心的一部分,也是最重要的一部分。

讲事件循环前,还要涉及一个 执行栈(也称调用栈) 的概念。它又是什么呢?网上的说法是,所有同步任务都在主线程上执行,就会形成一个执行栈。详细解释

你也可以简单理解为,它就是主线程上执行 JS 代码的地方,像 console.log(1) 这句代码在执行栈里执行后,控制台就能输出 1 。当然,从它的名字 执行栈 我们要清楚它具有 先进后出的特点哦。

还是一样,我们先上图再分析:

JS事件循环.jpg

  1. 所有同步任务都在主线程上执行,形成一个执行栈。
  2. 主线程之外,还存在一个 "任务队列",它是存放异步任务运行后的回调函数的地方,也就是异步任务有了运行结果后,会在 "任务队列" 中放置一个回调函数。
  3. 一旦 "执行栈" 中的所有同步任务执行完毕,主线程就会读取 “任务队列”,看看里面有哪些回调函数。然后把那些对应的异步任务,压入执行栈中,开始执行。

而主线程不断重复上面的第三步,就形成了我们常说的事件循环(Event Loop)了。

快速记忆:主线程从任务队列中读取异步任务执行,不断循环重复此过程,就是事件循环。

举个栗子

讲了这么多,举个栗子最实在,下面我们就来细细分析一下。

var p = new Promise((resolve, reject) => {
    console.log('Promise - 初始化'); // 同步任务
    resolve('Promise - 结果'); // 同步任务
});

function fn1() {
    console.log('fn1 - 执行');
}

function fn2() {
    console.log('fn2 - 开始执行'); // 同步任务
    setTimeout(() => {
        console.log('setTimeout - 执行');
    }); // 异步任务 - 宏任务
    fn1(); // 同步任务
    console.log('fn2 - 再次执行'); // 同步任务
    // 异步任务 - 微任务
    p.then(res => { 
        console.log('Promise - 第一个then :' + res);
    }).then(() => {
        console.log('Promise - 第二个then');
    })
}

fn2();
复制代码
  1. 首先,从上到下依次执行,先是会把 Promise() 对象压入 执行栈 中执行,输出 “Promise - 初始化” 并给 p 赋值了一个 Promise 对象,之后 执行栈Promise() 对象弹出,也就是 执行栈 清空了。
  2. 继续往下,不管两个函数的声明,来到 fn2() 的调用,把 fn2() 压入栈中执行,输出 “fn2 - 开始执行”,继续把 setTimeout()压入栈中执行,会把它里面的 console.log('setTimeout - 执行'); 语句放入 任务队列中,弹出 setTimeout()执行栈fn2() 继续调用。
  3. 往下,来到 fn1() 的调用,把 fn1() 压入栈中执行,输出 “fn1 - 执行”,弹出 fn1(),往下,再次打印输出 “fn2 - 再次执行”
  4. 往下,来到第一个 .then(),把它压入栈中执行,会把它里面的 console.log('Promise - 第一个then :' + res); 语句放入 微任务队列 中,弹出它,再压入第二个 .then() ,继续把 console.log('Promise - 第二个then'); 语句放入 微任务队列 中, 弹出它。
  5. 到这里, fn2() 就执行完了,会被 执行栈 弹出,栈内又清空了。
  6. 同步任务都执行完了,主线程空闲了,开始读取 微任务队列,按照队列先进先出的性质,会先把 console.log('Promise - 第一个then :' + res); 语句压入 执行栈 中执行,输出 “Promise - 第一个then :Promise - 结果” ,然后弹出,再压入另一语句,输出 “Promise - 第二个then” 弹出。
  7. 执行栈 又清空了,开始读取 任务队列,把 console.log('setTimeout - 执行'); 语句压入栈中执行,输出 “setTimeout - 执行”,然后弹出。

这就是整个执行过程了,文字有点多可能稍微有点乱,但仔细看应该能瞧明白的(-^〇^-) ,步骤中加黑文字对应下图的输出。

image.png

这上面的例子应该还比较好理解,但它还不是很能体现 Event Loop 的精髓,我们再来改造改造。

...
function fn2() {
    console.log('fn2 - 开始执行');
    setTimeout(() => {
        console.log('setTimeout - 执行');
        // start
        setTimeout(() => {
            console.log('又一个宏任务')
        })
        p.then(() => {
            console.log('Promise - 第三个then')
        })
        // end
    })
    fn1();
    console.log('fn2 - 再次执行');
    p.then(res => {
        console.log('Promise - 第一个then :' + res);
    }).then(() => {
        console.log('Promise - 第二个then')
    })
}

fn2();
复制代码

上面代码我们在一个宏任务中再增加了一个宏任务和一个微任务,然后我们直接来看输出结果:

image.png

是否符合你的预期呢?这里其实有个容易踩坑的点,既然 .then() 是一个微任务,我们新加的微任务,为什么它没有在上面提到的第 6 步骤中读取 微任务队列 的时候一起执行呢?原因很简单,就是下面红框的宏任务还没有执行。我们应该把它们看成一个整体,它们还没有细化。

image.png

之所以来细说这个点,主要是想表明,执行一个宏任务可能会继续产生宏任务和微任务,然后主线程来继续读取 微任务队列任务队列,以此来构成 Event Loop 的过程。



至此,本篇文章就写完啦,撒花撒花。

image.png

希望本文对你有所帮助,如有任何疑问,期待你的留言哦。
老样子,点赞+评论=你会了,收藏=你精通了。

分类:
前端