十一个案例带你搞懂JavaScript的事件循环机制——EventLoop

324 阅读13分钟

写在前面

之前做一个十万数据图表 (目前荣登三十万) 时遇到一个问题,怎么样能让 Echarts 不那么卡顿。真的就是画着画着内存就溢出了,试了无数种方法。其中有一种就是使用定时器,每十秒增加2000条数据,然后开始绘制,发现还是卡。这就是垫话了,顺便给大家出道题,setTimeout中递归自己,浏览器会出现什么样的效果?Promise的then中递归自己,浏览器又会出现什么样的效果?

基础概念

简单版: 运行时对 JavaScript 脚本的调度方式就叫做事件循环。
详细版: 主线程事件执行完毕后从任务队列中读取异步事件,并将其放入调用栈中执行,这个过程是循环不断的,所以整个的这种运行机制被称为 Event Loop(事件循环)。

一句话描述该技术的用途

简单版: 提供了异步编程的可能性。
绕口版: 改善因 JavaScript 单线程的特性而导致的没必要的程序阻塞问题 ( 事件协调 / 用户交互 / 脚本 / 渲染 / 网络 ... )。

  • 事件协调: 定时器 Promise ...
  • 用户交互: click onScroll ...
  • 脚本: JavaScript 脚本执行
  • 渲染: 解析 DOM / CSS ...
  • 网络: HTTP 请求

核心概念或运作流程

浏览器是多线程的,譬如:

  • 定时器触发线程
  • HTTP异步线程
  • EventLoop 处理线程

事件循环机制

  1. 事件循环机制从宏任务开始
  2. 第一个执行的宏任务是一个匿名函数,该函数内部是当前 JavaScript 整体代码
  3. 将任务放置于主线程上按从上到下的顺序依次进入调用栈执行
    • 2.1 同步任务压栈直接执行,然后弹栈
    • 2.2 异步任务压栈,将任务的函数体添加至任务队列,然后弹栈
  4. 当主线程上的任务都执行完毕,按照先进先出的原则读取任务队列中可执行的任务,将出队列的任务放置到主线程上
    • 3.1 优先微任务队列 ( Microtask Queue ),例如 Promise / MutationObserver
    • 3.2 其次执行消息队列 ( Message Queue ),例如 setTimeout / setInterval
  5. 无论微任务宏任务,其内部肯定也存在同步任务和异步任务,因此重复2-3

考点分析

  • 事件循环流程
  • 同步异步执行的时机
  • async/await 执行顺序
  • 递归异步任务时浏览器会发生什么

考题分析

题·一

console.log(0)
setTimeout(() => { // timer1
  console.log(6)
}, 3000)
console.log(1)
setTimeout(() => { // timer2
  console.log(4)
}, 1000)
Promise.resolve(3) // promise
  .then(res => {
    console.log(res)
    setTimeout(() => { // timer3
      console.log(5)
    }, 1000)
  })
console.log(2)
/*
  0 1 2 3 4 5 6
*/

分析

  • 整体代码进入主线程
  • console.log(0) 进入调用栈,输出 0,弹栈
  • timer1 进入调用栈,这是一个宏任务,将 console.log(6) 推入定时器触发线程并开始倒计时,等待3秒后放入消息队列
  • console.log(1) 进入调用栈,输出 1,弹栈
  • timer2 进入调用栈,这是一个宏任务,将 console.log(4) 推入定时器触发线程并开始倒计时,等待1秒后放入消息队列
  • promise 进入调用栈
    • 执行 Promise.resolve(3)
    • then 是一个微任务,将 console.log(res) 和 timer3 打包推入微任务队列
  • promise 弹栈
  • console.log(2) 进入调用栈,输出 2,弹栈
  • 主线程代码执行完毕,微任务队列存在任务
  • 从微任务队列中读取 console.log(res) 和 timer3 的 chunk,将其放入主线程
  • console.log(3) 进入调用栈,输出 3,弹栈
  • timer3 进入调用栈,这是一个宏任务,将 console.log(5) 推入定时器触发线程并开始倒计时,等待1秒后放入消息队列
  • 主线程代码执行完毕,微任务队列不存在任务,消息队列存在任务
  • 从消息队列中读取可执行任务
  • 等待 timer2 倒计时完毕,进入主线程
  • console.log(4) 进入调用栈,输出 4,弹栈
  • 等待 timer1 倒计时完毕,进入主线程
  • console.log(5) 进入调用栈,输出 5,弹栈
  • 等待 timer1 倒计时完毕,进入主线程
  • console.log(6) 进入调用栈,输出 6,弹栈

题·二

setTimeout(() => { // timer1
  console.log(1)
}, 20)
setTimeout(() => { // timer2
  console.log(2)
}, 10)
setTimeout(() => { // timer3
  console.log(3)
}, 20)
setTimeout(() => { // timer4
  console.log(4)
}, 10)
/*
  2 4 1 3
*/

分析

  • timer1 进入调用栈,这是一个宏任务,将 console.log(1) 推入定时器触发线程并开始倒计时,等待20毫秒后放入消息队列
  • timer2 进入调用栈,这是一个宏任务,将 console.log(2) 推入定时器触发线程并开始倒计时,等待10毫秒后放入消息队列
  • timer3 进入调用栈,这是一个宏任务,将 console.log(3) 推入定时器触发线程并开始倒计时,等待20毫秒后放入消息队列
  • timer4 进入调用栈,这是一个宏任务,将 console.log(4) 推入定时器触发线程并开始倒计时,等待10毫秒后放入消息队列
  • 主线程代码执行完毕,微任务队列不存在任务,消息队列存在任务
  • 定时器触发线程中执行完的先后顺序为 timer2 -> timer4 -> timer1 -> timer3
  • 因此依次输出 2 4 1 3

题·三

setTimeout(() => { // timer1
  console.log(1)
}, 20)
setTimeout(() => { // timer2
  console.log(2)
}, 10)
console.time('loop')
for (let i = 0; i < 99999999; i++) {
  // do something
}
console.timeEnd('loop') // 82ms
setTimeout(() => { // timer3
  console.log(3)
}, 20)
setTimeout(() => { // timer4
  console.log(4)
}, 10)
/*
  2 1 4 3
*/

分析

  • timer1 进入调用栈,这是一个宏任务,将 console.log(1) 推入定时器触发线程并开始倒计时,等待20毫秒后放入消息队列
  • timer2 进入调用栈,这是一个宏任务,将 console.log(2) 推入定时器触发线程并开始倒计时,等待10毫秒后放入消息队列
  • for 循环体进入调用栈,执行了82毫秒,弹栈
  • 在 for 循环执行的82毫秒中,定时器触发线程中的 timer1 和 timer2 已经倒计时完毕,并按照 timer2 -> timer1 的顺序放入消息队列
  • timer3 进入调用栈,这是一个宏任务,将 console.log(3) 推入定时器触发线程并开始倒计时,等待20毫秒后放入消息队列
  • timer4 进入调用栈,这是一个宏任务,将 console.log(4) 推入定时器触发线程并开始倒计时,等待10毫秒后放入消息队列
  • 主线程代码执行完毕,微任务队列不存在任务,消息队列存在任务
  • 定时器触发线程中执行完的先后顺序为 timer2 -> timer1 -> timer4 -> timer3
  • 因此依次输出 2 1 4 3

题·四

async function async1() {
  console.log('async1 start')
  async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 call')
}
console.log(1)
setTimeout(() => { // timer
  console.log('setTimeout')
})
async1()
new Promise(resolve => { // promise
  console.log('promise')
  resolve()
}).then(() => {
  console.log('then')
})
console.log(2)
/*
  1
  async1 start
  async2 call
  async1 end
  promise
  2
  then
  setTimeout
*/

分析

  • 先声明了 async1 和 async2 两个函数
  • console.log(1) 进入调用栈,输出 1,弹栈
  • timer 进入调用栈,这是一个宏任务,将 console.log(setTimeout) 推入定时器触发线程并开始倒计时,等待4毫秒后放入消息队列
  • async1() 进入调用栈
    • console.log(async1 start) 进入调用栈,输出 async1 start,弹栈
    • async2() 进入调用栈
      • console.log(async2 call) 进入调用栈,输出 async2 call,弹栈
    • async2() 弹栈
    • console.log(async1 end) 进入调用栈,输出 async1 end,弹栈
  • async1() 弹栈
  • promise 进入调用栈
    • console.log(promise) 进入调用栈,输出 promise,弹栈
    • resolve() 进入调用栈,执行,弹栈
    • then 是一个微任务,将 console.log(then) 推入微任务队列
  • promise 弹栈
  • console.log(2) 进入调用栈,输出 2,弹栈
  • 主线程代码执行完毕,微任务队列存在任务
  • 从微任务队列中读取 console.log(then)
  • console.log(then) 进入调用栈,输出 then,弹栈
  • 主线程代码执行完毕,微任务队列不存在任务,消息队列存在任务
  • 等待 timer 倒计时完毕,进入主线程
  • console.log(setTimeout) 进入调用栈,输出 setTimeout,弹栈

题·五

async function async1() {
  console.log('async1 start')
  await async2()
  console.log('async1 end')
}
async function async2() {
  console.log('async2 call')
}
console.log(1)
setTimeout(() => {
  console.log('setTimeout')
})
async1()
new Promise(resolve => {
  console.log('promise')
  resolve()
}).then(() => {
  console.log('then')
})
console.log(2)
/*
  1
  async1 start
  async2 call
  promise
  2
  async1 end
  then
  setTimeout
*/

分析

  • 先声明了 async1 和 async2 两个函数
  • console.log(1) 进入调用栈,输出 1,弹栈
  • timer 进入调用栈,这是一个宏任务,将 console.log(setTimeout) 推入定时器触发线程并开始倒计时,等待4毫秒后放入消息队列
  • async1() 进入调用栈
    • console.log(async1 start) 进入调用栈,输出 async1 start,弹栈
    • await async2() 进入调用栈
      • async2() 进入调用栈
        • console.log(async2 call) 进入调用栈,输出 async2 call,弹栈
      • async2() 弹栈
    • await 之后的内容类似 promise 中的 then,因此是个微任务,将 console.log(async1 end) 推入微任务队列
    • await async2() 弹栈
  • async1() 弹栈
  • promise 进入调用栈
    • console.log(promise) 进入调用栈,输出 promise,弹栈
    • resolve() 进入调用栈,执行,弹栈
    • then 是一个微任务,将 console.log(then) 推入微任务队列
  • promise 弹栈
  • console.log(2) 进入调用栈,输出 2,弹栈
  • 主线程代码执行完毕,微任务队列存在任务
  • 从微任务队列中读取 console.log(async1 end)
  • console.log(async1 end) 进入调用栈,输出 async1 end,弹栈
  • 主线程代码执行完毕,微任务队列存在任务
  • 从微任务队列中读取 console.log(then)
  • console.log(then) 进入调用栈,输出 then,弹栈
  • 主线程代码执行完毕,微任务队列不存在任务,消息队列存在任务
  • 等待 timer 倒计时完毕,进入主线程
  • console.log(setTimeout) 进入调用栈,输出 setTimeout,弹栈

题·六

<button id="btn">按钮</button>
<script>
  const button = document.getElementById('btn')
  button.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('Microtask-1'))
    console.log('Listener-1')
  })
  button.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('Microtask-2'))
    console.log('Listener-2')
  })
  button.click()
/*
  // 自动触发
  Listener-1
  Listener-2
  Microtask-1
  Microtask-2

  // 手动点击
  Listener-1
  Microtask-1
  Listener-2
  Microtask-2
*/
</script>

分析 —— 自动触发

  • button.click() 进入调用栈
    • Promise.resolve().then(() => console.log('Microtask-1')) 进入调用栈
      • 执行 Promise.resolve()
      • then 是一个微任务,将 console.log(Microtask-1) 推入微任务队列
    • Promise.resolve().then(() => console.log('Microtask-1')) 弹栈
    • console.log('Listener-1') 进入调用栈,输出 Listener-1,弹栈
    • Promise.resolve().then(() => console.log('Microtask-2')) 进入调用栈
      • 执行 Promise.resolve()
      • then 是一个微任务,将 console.log(Microtask-2) 推入微任务队列
    • Promise.resolve().then(() => console.log('Microtask-2')) 弹栈
    • console.log('Listener-2') 进入调用栈,输出 Listener-2,弹栈
    • 主线程代码执行完毕,微任务队列存在任务
    • 从微任务队列中读取 console.log('Microtask-1')
    • console.log('Microtask-1') 进入调用栈,输出 Microtask-1,弹栈
    • 主线程代码执行完毕,微任务队列存在任务
    • 从微任务队列中读取 console.log('Microtask-2')
    • console.log('Microtask-2') 进入调用栈,输出 Microtask-2,弹栈

分析 —— 手动点击

  • Promise.resolve().then(() => console.log('Microtask-1')) 进入调用栈
    • 执行 Promise.resolve()
    • then 是一个微任务,将 console.log(Microtask-1) 推入微任务队列
  • Promise.resolve().then(() => console.log('Microtask-1')) 弹栈
  • console.log('Listener-1') 进入调用栈,输出 Listener-1,弹栈
  • 主线程代码执行完毕,微任务队列存在任务
  • 从微任务队列中读取 console.log('Microtask-1')
  • console.log('Microtask-1') 进入调用栈,输出 Microtask-1,弹栈
  • Promise.resolve().then(() => console.log('Microtask-2')) 进入调用栈
    • 执行 Promise.resolve()
    • then 是一个微任务,将 console.log(Microtask-2) 推入微任务队列
  • Promise.resolve().then(() => console.log('Microtask-2')) 弹栈
  • console.log('Listener-2') 进入调用栈,输出 Listener-2,弹栈
  • 主线程代码执行完毕,微任务队列存在任务
  • 从微任务队列中读取 console.log('Microtask-2')
  • console.log('Microtask-2') 进入调用栈,输出 Microtask-2,弹栈

题·七

function timerTimeout() {
  setTimeout(timerTimeout) // timer
}
function timerPromise() {
  Promise.resolve().then(timerPromise) // promise
}
timerTimeout()
/*
  OR
*/
timerPromise()

分析 —— timerTimeout

  • timerTimeout() 进入调用栈
    • timer 进入调用栈,这是一个宏任务,将 () => { timerTimeout() } 推入定时器触发线程并开始倒计时,等待4毫秒后放入消息队列
  • timerTimeout() 弹栈
  • 微任务队列不存在任务,消息队列存在任务
  • 进入下一轮事件循环
  • 循环上面流程
  • 因为当前任务总是可以弹栈,导致页面刷新会消失,且正常操作

分析 —— timerPromise

  • timerTimeout() 进入调用栈
    • 执行 Promise.resolve()
    • then 是一个微任务,将 () => { timerPromise() } 推入微任务队列
  • timerTimeout() 弹栈
    • 微任务队列存在任务
      • 循环上面流程
    • 永远进不到宏任务,无法进入下一轮事件循环,导致页面刷新符号不会消失

拓展

Node 的事件循环机制 -> 使用基于C语言的 libuv 库让 Node 平台的异步回调回到主线程的机制

  • 每次切换阶段会清空当前阶段的所有任务
  • 清空任务后,切换阶段前会清空微任务队列
  • process.nextTick 优先级 高于 Promise
  • poll 阶段会造成阻塞,等待 I/O 池的任务执行 libuv 中存在一个类似定时器触发线程的线程池,在定时器入栈时会将定时器的回调挂载到一课红黑树的节点上,等倒计时完毕后将其推入 timers
    ┌───────────────────────────┐
┌─> │           timers          │
│   └─────────────┬─────────────┘
│   ┌─────────────┴─────────────┐
│   │     pending callbacks     │
│   └─────────────┬─────────────┘
│   ┌─────────────┴─────────────┐
│   │       idle, prepare       │
│   └─────────────┬─────────────┘       ┌───────────────┐
│   ┌─────────────┴─────────────┐       │   incoming:   │
│   │           poll            │ <─────┤  connections, │
│   └─────────────┬─────────────┘       │   data, etc.  │
│   ┌─────────────┴─────────────┐       └───────────────┘
│   │           check           │
│   └─────────────┬─────────────┘
│   ┌─────────────┴─────────────┐
└── ┤      close callbacks      │
    └───────────────────────────┘
( 清空微任务 )
└─ timers 阶段
│  ├─ timers 队列**不为空**,则执行
│  │  └─ 清空队列后进入 poll 阶段
│  │
│  └─ timers 队列中**为空**,则进入 poll 阶段
( 清空微任务 )
├─ poll 阶段
│  ├─ poll 队列**不为空**,则执行
│  │  └─ 清空队列后检测是否存在 setImmediate
│  │     └─ 存在,则进入 check 阶段去执行
│  │
│  └─ poll 队列中**为空**
│     └─ 检测是否存在 I/O 任务
│        ├─ 存在,阻塞
│        │  └─ 检测是否存在可调度的定时器任务
│        │     ├─ 存在,则进入 timers 阶段去执行
│        │     │
│        │     └─ 不存在,则检测是否存在 setImmediate
│        │        ├─ 存在,则进入 check 阶段去执行
│        │        │
│        │        └─ 不存在,则等待 I/O 任务加入队列并执行
│        │
│        └─ 不存在,则检测是否存在 setImmediate
│           ├─ 存在,则进入 check 阶段去执行
│           │
│           └─ 不存在,则进入 timers 阶段
( 清空微任务 )
└─ check 阶段
   └─ 清空队列后进入 timers 阶段
( 清空微任务 )

题·一

setTimeout(() => {
  console.log('setTimeout')
})
setImmediate(() => {
  console.log('setImmediate')
})

分析

结果不确定

  • 初始化 -> setTimeout 放入线程池 -> setImmediate 放入 check
  • 主线程执行完毕
  • 进入 timers 阶段
    • 如果这时候倒计时完毕,timers 中就会被推入 console.log('setTimeout')
      • 执行并输出 setTimeout -> 进入 poll 阶段
    • 如果这时候倒计时未完毕,timers 中队列为空 -> 进入 poll 阶段
  • 进入 poll 阶段
    • 检测队列是否为空 -> 为空
    • 检测是否存在 I/O 任务 -> 不存在
    • 检测是否存在 setImmediate -> 存在 -> 进入 check 阶段
  • 进入 check 阶段
    • 输出 setImmediate
    • 如果之前进入 timers 阶段时 setTimeout 的倒计时未完毕,但这时候完毕了,说明这时候 timers 队列中存在 console.log('setTimeout')
    • 输出 setTimeout

题·二

setTimeout(() => {
  console.log('setTimeout')
})
time('loop')
for (let i = 0; i < 9999999; i++) { }
timeEnd('loop') // loop: 8ms
setImmediate(() => {
  console.log('setImmediate')
})

分析

结果确定

  • 初始化 -> setTimeout 放入线程池 -> 执行 for 循环 -> setImmediate 放入 check
  • 主线程执行完毕
  • 进入 timers 阶段
    • 因为 for 循环执行花费了8毫秒,timers 中已经被推入 console.log('setTimeout')
      • 执行并输出 setTimeout -> 进入 poll 阶段
  • 进入 poll 阶段
    • 检测队列是否为空 -> 为空
    • 检测是否存在 I/O 任务 -> 不存在
    • 检测是否存在 setImmediate -> 存在 -> 进入 check 阶段
  • 进入 check 阶段
    • 输出 setImmediate

题·三

fs.readFile('/path/to/file', () => {
  setTimeout(() => {
    console.log('setTimeout')
  })
  setImmediate(() => {
    console.log('setImmediate')
  })
})

分析

结果确定

  • 初始化 -> readFile 放入 I/O 池
  • 主线程执行完毕
  • 进入 timers 阶段
    • 没有任务 -> 进入 poll 阶段
  • 进入 poll 阶段
    • 检测队列是否为空 -> ( 假设存在文件已获取到,并将回调加入了 poll 队列中 )
      • setTimeout 放入线程池 -> setImmediate 放入 check
    • 检测是否存在 setImmediate -> 存在 -> 进入 check 阶段
  • 进入 check 阶段
    • 输出 setImmediate -> 进入 timers 阶段
  • 进入 timers 阶段
  • 如果这时候倒计时完毕,timers 中就会被推入 console.log('setTimeout')
    • 执行并输出 setTimeout
    • 如果这时候倒计时未完毕,timers 中队列为空 -> 进入 poll 阶段 -> 循环,直到倒计时完毕
      • 执行并输出 setTimeout

题·四

setTimeout(() => { // timer1
  console.log('timout-1')
  Promise.resolve().then(() => {
    console.log('promise-1') // promise1
  })
  process.nextTick(() => {
    console.log('tick-1') // tick1
  })
  setTimeout(() => { // timer3
    console.log('setTimeout-3')
  })
})
Promise.resolve().then(() => {
  console.log('promise-2') // promise2
})
setTimeout(() => { // timer2
  console.log('timeout-2')
  Promise.resolve().then(() => {
    console.log('promise-3') // promise3
  })
  process.nextTick(() => {
    console.log('tick-2') // // tick2
  })
})

分析

结果确定

  • 初始化 -> timer1 放入线程池 -> 执行 Promise.resolve() -> promise2 放入微任务队列 -> timer2 放入线程池
  • 主线程执行完毕
  • 清空微任务队列
    • 执行 promise2,并输出promise-2 -> 进入 timers 阶段
  • 进入 timers 阶段 ( 假设 timer1 / timer2 倒计时都完毕了 )
    • timers 中存在 timer1 和 timer2,先进先出原则,执行 timer1
      • 输出timout-1
      • 执行 Promise.resolve() -> promise1 放入微任务队列 -> tick1 放入微任务队列 -> timer3 放入线程池
      • timer1 执行完毕,清空微任务队列
        • nextTick 优先级更高,因此依次输出tick-1promise-1
    • 执行 timer2
      • 输出timeout-2
        • 执行 Promise.resolve() -> promise3 放入微任务队列 -> tick2 放入微任务队列
        • timer2 执行完毕,清空微任务队列
          • nextTick 优先级更高,因此依次输出tick-2promise-3
    • timers 队列清空 -> 进入 poll 阶段
  • 进入 poll 阶段
    • 检测队列是否为空 -> 为空
    • 检测是否存在 I/O 任务 -> 不存在
    • 检测是否存在 setImmediate -> 不存在 -> 进入 timers 阶段
  • 进入 timers 阶段 ( 假设 timer3 倒计时完毕了 )
    • timers 中存在 console.log('setTimeout-3')
      • 执行并输出 setTimeout-3