通过伪代码彻底理解事件循环

前言

学习了本文内容后,你将更好的理解以下内容:

  1. 宏任务和微任务有哪些
  2. 一帧中浏览器的工作流程
  3. 浏览器的事件循环机制
  4. Nodejs 的事件循环机制
  5. Web Worker 的事件循环机制
  6. Nodejs 和浏览器的事件循环差异

本文默认你已知道进程线程的概念,并且知道浏览器内核中的五个工作线程:GUI渲染线程JS引擎线程定时器触发线程事件触发线程异步http请求线程的工作内容。

宏任务和微任务

  • 宏任务:script(整体代码)、setTimeout、setInterval、I/O、UI 事件交互、setImmediate(Nodejs 环境)
  • 微任务:Promise.then、MutationObserver、process.nextTick(Nodejs 环境)

一帧中(对于 FPS 为 60HZ 的显示器来说就是 16.6ms)浏览器的工作流程

  1. 输入事件(如:阻塞-touch、非阻塞-click),并将回调添加到事件循环队列
  2. 处理 JS 定时器,并将回调添加到事件循环队列
  3. 处理开始帧对应的事件(如:window.resize、scroll)
  4. 执行 rAF 回调(请求动画渲染) requestAnimationFrame
  5. 页面布局(样式计算、更新布局)
  6. 样式绘制
  7. 如果以上六个高优先级的阶段执行完则进入该阶段(空闲阶段)requestIdleCallback

requestIdleCallback 执行超出时间剩余时间的函数会导致浏览器卡住。而 requestAnimationFrame 会自动调节频率,如果 callback 工作太多无法在一帧内完成会自动降低为 30fps。虽然降低了,但总比浏览器卡死好。

浏览器的事件循环机制

/*
 浏览器的事件循环相当于一个无限循环的旋转木马一样
*/
while (true) {
  queue = getNextQueue() // 获取下一个任务队列
  task = queue.shift() //取出任务队列中的第一个任务
  execute(task) // 执行当前任务

  // 当微任务队列有任务时,全部执行
  while (microtaskQueue.hasTasks()) {
    doMicrotask()
  }

  // 准备开始重绘
  if (isRepaintingTime()) {
    animationTasks = animationQueue.copyTasks() // 复制所有的动画队列中的任务
    // 遍历动画队列并依次执行
    for (task in animationTasks) {
      doAnimationTask(task)
    }
    repaint() // 重绘
  }
}
  • 微任务队列和动画队列(由 requestAnimationFrame 产生的回调队列)都会在单次循环中全部执行完
  • 微任务队列在运行时递归自身进入会死循环,导致页面卡死
  • 动画队列在运行时递归自身不会进入死循环,不会卡死页面。因为他是根据系统来决定回调函数的执行时机的,

Nodejs 的事件循环

Nodejs 可比浏览器的事件循环简单多了,有以下原因:

  1. 没有脚本解析事件:你不需要再 HTML 中去挑选出 JavaScript,你只需要给它一个 JavaScript 文件然后运行它即可
  2. 没有讨厌的用户交互
  3. 没有动画队列的回调

Nodejs 有三个主要阶段(从上往下顺序执行):

  1. timer 计时器阶段,负责执行setTimeoutsetInterval回调
  2. poll 轮训阶段,用于执行所有 I/O 事件的回调,比如 定时回调、XHR 请求、磁盘读取、磁盘写入以及其他内容
  3. check 检查阶段,通过setImmediate添加,setImmediate(cb)会先于setTimeout(cb,0)执行

poll 阶段比较特殊和重要:

  • 如果队列不为空,会遍历队列并同步执行,直到队列为空或达到系统限制
  • 如果队列为空,会有三件事发生:

    1. 如果有 setImmediate 回调需要执行,poll 阶段会立即停止并进入 check 阶段执行回调
    2. 如果没有 setImmediate 回调需要执行,会等到回调被加入的到队列中并立即执行,这里会有超时时间设置防止一直等待下去
    3. 如果没有 setImmediate 回调并且没有其他事件回调需要执行时,当 timer 到期了,需要回到 timer 阶段执行回调
/*
 Nodejs事件循环相当于一个等待循环的过山车一样
*/
while (tasksAreWaiting()) {
  queue = getNextQueue() // 每一次事件循环都会获取下一个任务队列

  // 当前阶段有任务时,全部执行
  while (queue.hasTasks()) {
    task = queue.shift() // 取出任务队列中的第一个任务
    execute(task) // 执行当前任务

    // 当process.nextTick队列有任务时,全部执行
    while (nextTickQueue.hashTasks()) {
      doNextTickTask()
    }

    // 当promise队列有任务时,全部执行
    while (promiseQueue.hashTasks()) {
      doPromiseTask()
    }
  }
}

Web Worker 的事件循环

每个 Web Worker 都在自己的线程中工作,它有自己的堆栈,队列,一切都自己运行,它比 Nodejs 事件循环机制更加简单

  1. 没有 script 标签
  2. 没有用户交互
  3. 没有 DOM 操作,所以不用关心动画、帧或者类似的东西
  4. 没有 Nodejs 中的setImmediatenextTick
/*
 其和浏览器的事件循环相似,只是任务队列中少了很多事件和动画
*/
while (true) {
  queue = getNextQueue() // 获取下一个任务队列
  task = queue.shift() //取出任务队列中的第一个任务
  execute(task) // 执行当前任务

  while (microtaskQueue.hasTasks()) {
    // 当微任务队列有任务时,全部执行
    doMicrotask()
  }

  if (isRepaintingTime()) {
    repaint() // 重绘
  }
}

Nodejs 和浏览器的事件循环差异

  • 浏览器环境下,微任务队列是每个宏任务执行完之后再执行
  • Nodejs 环境下,微任务队列会在事件循环的各个阶段执行,在 Nodejs11 之前,需要执行完每个阶段中的所有宏任务才会执行微任务队列,但 Nodejs11 后与浏览器一致,当每个阶段执行完一个宏任务后,就执行微任务队列。

事件循环的差异

接下来以一个例子看清楚他们的区别

setTimeout(() => {
  console.log("timer1")
  Promise.resolve().then(function() {
    console.log("promise1")
  })
}, 0)
setTimeout(() => {
  console.log("timer2")
  Promise.resolve().then(function() {
    console.log("promise2")
  })
}, 0)
  • 在浏览器和 Node11 及之后版本中会打印出:timer1 -> promise1 -> timer2 -> promise2
  • 在 Nodejs11 之前会打印出:(如果 timer1 和 timer2 都在 timer 阶段中)timer1 -> timer2 -> promise1 -> promise2,如果 timer2 还没到期则和浏览器一致。

Published under  on .

Last updated on .

pipihua

我是皮皮花,一个前后端通吃的前端攻城狮,如果感觉不错欢迎点击小心心♥(ˆ◡ˆԅ) star on GitHub!