前言

一般我们常说的事件循环其实分为两个概念,一个是node的事件循环,一个是浏览器的事件循环,。它们之间的实现原理是不一样的,如果有人问你,最好区别回答。


一、浏览器的事件循环

主线程事件循环线程其实是同一概念的不同说法。不同的文章可能有不同的叫法,这里先说明。

1. 经典题目

开始之前先看一道经典考事件循环event loop题目

setTimeout(() => {
	Promise.resolve().then(()=>{
		console.log(1)
	})
	setTimeout(() => {
		console.log(2)
	},0)
	console.log(3);
},0)

setTimeout(() => {
	console.log(4)
},0)

new Promise((resolve) => {
	console.log(5)
	resolve()
	console.log(6)
}).then(()=>{
	console.log(7)
})

console.log(8)

上面代码打印顺序为

5 6 8 7 3 1 4 2

怎么样是不是猜对了,下面我来来讲讲为什么会是这样


2. 微任务和宏任务

要了解上面代码执行结果的原因,搜先要知道什么是微任务(micro task)和宏任务(macro task)。
它们是由执行时机去区分的。

  • 宏任务:在下一次事件循环执行的就是宏任务
  • 微任务:在本次事件循环执行结束时(即所有同步代码执行后执行)的就是微任务

3. 如何产生宏任务和微任务

这些方法可以产生宏任务

  • setTimeout(cb):它的回调函数是宏任务,它本身是个同步方法

  • setInterval(cb):它的回调函数是宏任务,它本身是个同步方法

  • requestAnimationFrame(cb): 高性能js动画方法,根据刷新率,cb回调方法定时执行一次

  • 事件监听的回调函数:dom点击事件绑定函数等,点击按钮后,js将回调函数开启为一个宏任务

  • Ajax、FileRead 的回调函数:使用XMLHttpRequst发送请求或这FileRead读取文件,实际是开启了一个异步操作,这个和微任务宏任务没关系,操作执行完毕后会将它们的回调函数做为一个宏任务


这些方法可以产生微任务

  • Promise的 .then .catch .finally
  • Async:async函数内部await之前的语句都是同步执行
    async function fn(){
    	console.log("1")
    	await console.log("2")
    	console.log("3");
    }
    fn(); //同步打印 1 2 异步打印 3 
    
  • Generator:异步函数旧语法,async是它的升级版
  • MutationObserver: 用于监听dom属性变化,不怎么常用了解就行

4. 事件循环流程

在浏览器上,所有的js文件最总都会化为javascript标签中的代码,下面我们就来逐步讲解js引擎执行js代码的步骤。

  1. 浏览器加载一个html时,会创建一个js引擎环境。在js引擎环境初始化,会创建调用栈(call stack)和任务队列(task queue)。微任务宏任务有不同的任务队列
  2. script标签中的代码会作为一个宏任务加入任务队列,并立即推入调用栈中,开始执行(由主线程执行代码,主线程再js引擎初始化时创建)
  3. 调用栈中遇到创建宏任务的代码(setTimeout等),会在宏任务队列中加入一个宏任务。当遇到创建微任务的代码(Promise等),会在微任务队列中加入一个微任务
  4. 调用栈本次宏任务所有同步代码执行完毕后,事件循环开始逐个执行将微任务队列中的微任务,压入调用栈开始执行。微任务中如果存在创建微任务宏任务的代码同步骤3
  5. 当所有微任务代码执行完毕后,从宏任务队列中取出最先压入的宏任务开始执行,开始循环3-4步骤。这个循环机制就是事件循环

有张图可能比较直观
在这里插入图片描述

值得一提的是,只会有一个宏任务队列和微任务队列,微任务会在本次宏任务所有代码执行后,顺序执行微任务队列中的所有微任务,但它们不属于包含关系,它们分别拥有自己的任务队列。


二、node的事件循环

浏览器中有事件循环,node 中也有,事件循环是 node 处理非阻塞 I/O 操作的机制,node中事件循环的实现是依靠的libuv引擎。由于 node 11 以及11之后,事件循环的一些原理发生了变化,这里就以新的标准去讲,但是可以最为一个面试点显得你好学~,最后再列上变化点让大家了解前因后果。

1. 创建微任务和宏任务

node环境中创建微\宏任务的方法和浏览器差不多,没有requestAnimationFrameMutationObserver。但是多了一些特有的方法

宏任务

  • setImmediate:在check阶段执行
  • I/O操作等:一般在poll和pending阶段执行
  • EventEmitter:所有的事件回调函数

微任务

  • process.nextTick(cb):这个比较特殊他会在下一次事件循环中所有微任务之前执行

node中宏任务的执行机制与浏览器中有所差别,但是微任务的执行机制是一样的,会在当前宏任务执行后,下个宏任务开始之前执行


2. 事件循环实现机制

node中事件循环和浏览器的事件事件循环机制不一样,因为要和内核打交流,所以更加复杂但总体来说分为6个阶段顺序执行

  • 每个阶段都有自己的宏任务队列,它们在内存中是独立的对象。这个对象在node.js进程启动时就已经创建,并不会在每次事件循环滴落中重新创建
  • 与宏任务队列不同,事件循环中只存在一个微任务队列,它在内存中的储存对象也是在node进程启动时就已经创建。所有宏任务中产生的微任务,都放在同一个微任务队列中,它们并不是一个宏任务关联一个微任务队列。
  • 每个阶段中,每个宏任务执行完毕后,在下一个宏任务执行之前,会先顺序执行微任务队列中的所有微任务。

node在开始执行脚本的时候,会将最外层的代码作为第一个宏任务放到任务队列中,并立即取出执行
在这里插入图片描述

  • timers
    此阶段检查定时器(setTimeout、setInterval)中时间是否到期,如果有到期的定时器任务,则执行回调,否则进入下一阶段

  • pending(io/callbacks)
    执行上一轮事件循环遗留的 I/O 回调。根据 Libuv 文档的描述:大多数情况下,在轮询 I/O 后立即调用所有 I/O 回调,某些情况下比如读取大文件,一次事件循环可能无法处理完成,此类回调会推迟到下一次循环迭代。听完更像是上一个阶段的遗留。

  • idle,prepare
    只供libuv内部使用,可以忽略

  • poll
    这个阶段比较重要,它有两个作用执行i/o操作的回调函数等待异步任务的回调

    当此阶段任务队列中存在任务(i/o操作的回调函数)时,按任务队列中的顺序执行任务,执行完毕后。

    • 如果其他阶段没有任务
      比如有一个setTimeout(cb,5000)。五秒后才会在timer阶段任务队列推入任务。而在此期间,其余各个阶段任务队列中都没有任务。那么事件循环机制会在此等待,直到5秒timers阶段的任务队列中出现任务, 才进入timer阶段
    • 如果某个阶段(除poll外)没有任务,则会直接跳过此阶段,所以这里直接跳过check close callbacks阶段进入下一个事件循环
    • 进入下一个事件循环后,此时其他阶段包括poll阶段都没有任务需要执行,又因为5秒timers阶段会推入一个任务,所以node进程不会退出,直接跳过timersi/o callbacks进入poll阶段等待。
    • 如果所有io任务都已执行完毕,包括所有的定时器,那么node会退出进程
  • check
    这个阶段执行setImmediate中的回调函数

  • close callbacks
    此阶段执行关闭请求的回调函数,比如socker.on('close',cb),此阶段执行完毕后又开始执进入timers阶段,形成循环

node为什么会知道5秒后会timers阶段会插入一个任务,nodejs的所有异步操作,包括I/O操作、定时器等,都是由libuv库来管理,这个库是由c++(超纲了)写的一个高性能库。你只需要知道它实现了这个功能

值得一提的是,在主模块中setTimeout(cb)setImmdiate(cb)要早执行,因为在一个事件循环是timers阶段比check阶段更快执行。而在i/o的回调函数中(readFile(cb)),已经处于事件循环的poll阶段,所以处于check阶段执行的setImmdiate(cb)要更快执行,setTimeout(cb)需要到下个循环的timers阶段执行

3. node11以前的不同

在node11以前,node事件循环处理微任务的方式有点不一样。
假定一个阶段中存在多个宏任务

setImmediate(() => {
  log('setImmediate1');
  Promise.resolve('Promise microtask 1')
    .then(log);
});
setImmediate(() => {
  log('setImmediate2');
  Promise.resolve('Promise microtask 2')
    .then(log);
});

在node10以及以前执行结果是

setImmediate1
setImmediate2
Promise microtask 1
Promise microtask 2

node11及以后

setImmediate1
Promise microtask 1
setImmediate2
Promise microtask 2

在node10及以前会先将阶段内所有的宏任务执行完毕后在执行微任务。
在node11及以后一个宏任务执行完毕之后,立即执行微任务,微任务执行完毕再执行下一个宏任务


三、值得思考的问题

1. 浏览器中所有任务执行完毕之后会怎样

  • 和node不一样,因为浏览器中存在交互,你不知道什么时候用户就会点击一个按钮,所以当主线程执行完所有任务队列后,它并不会"死去",而是会进入等待状态,等待新的任务加入到任务队列中。
  • 新的任务可以由各种事件触发,比如用户的点击事件、网络请求返回等。当这些事件发生时,会有相应的回调函数被加入到任务队列中,然后由主线程执行。
  • 只有当浏览器标签页被关闭后,主线程才会死去
Logo

GitCode 天启AI是一款由 GitCode 团队打造的智能助手,基于先进的LLM(大语言模型)与多智能体 Agent 技术构建,致力于为用户提供高效、智能、多模态的创作与开发支持。它不仅支持自然语言对话,还具备处理文件、生成 PPT、撰写分析报告、开发 Web 应用等多项能力,真正做到“一句话,让 Al帮你完成复杂任务”。

更多推荐