阅读 1031

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):堆是一种经过排序的树形数据结构,每个结点都有一个值。通常我们所说的堆的数据结构,是指二叉堆。堆的特点是根结点的值最小(或最大),且根结点的两个子树也是一个堆。由于堆的这个特性,常用来实现优先队列,堆的存取是随意,这就如同我们在图书馆的书架上取书,虽然书的摆放是有顺序的,但是我们想取任意一本时不必像栈一样,先取出前面所有的书,我们只需要关心书的名字。

image.png

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

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

JS执行机制

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

JS执行机制.jpg

图解:

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

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

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

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

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

  • 异步任务会进入到 “任务队列” 中,等待异步任务有了结果后,会将注册的回调函数放入任务队列中等待,当主线程空闲的时候(执行栈被清空),会被读取到执行栈等待主线程的执行。

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

宏任务与微任务

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

  • 宏任务(Macrotask):可以理解为每次执行栈执行的代码就是一个宏任务。(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
  • 微任务(Microtask):可以理解为当前宏任务执行结束后立即执行的任务。也就是说,在当前宏任务后,下一个宏任务之前。微任务是在运行宏任务/同步任务的时候产生的,是属于当前任务的。

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

举个例子形容宏任务和微任务?
曾经看到的一个例子很好,宏任务和微任务形象的来说就是:你去营业厅办一个业务会有一个排队号码,当叫到你的号码的时候你去窗口办充值业务(宏任务执行),在你办理充值的时候你又想改个套餐(微任务),这个时候工作人员会直接帮你办,不可能让你重新排队。例子来源

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

  • 宏任务:

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

    1. Promise.then
    2. Mutation Observer API具体使用
    3. Process.nextTick(Node独有)
    4. Object.observe (废弃)

微任务与任务的区别?

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

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

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

事件循环

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

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

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

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

JS事件循环.jpg

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

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

我记忆的概念:主线程从任务队列中读取异步任务执行,不断循环重复的过程,就称为事件循环。

举个栗子

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

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

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

文章分类
前端
文章标签