Skip to content

事件循环 ​

  • 宏任务有哪些?微任务有哪些?宏任务和微任务触发时机
  • 宏任务、微任务和 DOM 渲染关系?
  • 宏任务、微任务和 DOM 渲染在 event loop 的过程?

概览 ​

单线程 ​

JavaScript 语言的一大特点就是单线程,也就是说,同一个时间只能做一件事。那么,为什么 JavaScript 不能有多个线程呢?这样能提高效率啊。

JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定 JavaScript 同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?

所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许 JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质。

调用栈 ​

当我们调用一个函数时,它会被添加到一个叫做 调用栈 (call stack) 的地方,调用栈是 JS 引擎的一部分,而不是浏览器特有的。本质上它是一个栈,具有 后进先出 (Last In, First Out. 即 LIFO) 的特点。当一个函数调用完成,它就被从调用栈中弹出。

任务列队 ​

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大,CPU 忙不过来,倒也算了,但是很多时候 CPU 是闲着的,因为 IO 设备(输入输出设备)很慢(比如 AJAX 操作从网络读取数据),不得不等着结果出来,再往下执行。

JavaScript 语言的设计者意识到,这时主线程完全可以不管 IO 设备,挂起处于等待中的任务,先运行排在后面的任务。等到 IO 设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是,所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。同步任务指的是,在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;异步任务指的是,不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。

具体来说,异步执行的运行机制如下。(同步执行也是如此,因为它可以被视为没有异步任务的异步执行。)

只要主线程空了,就会去读取"任务队列",这就是 JavaScript 的运行机制。这个过程会不断重复。

宏任务与微任务 ​

JS 主线程不断的循环往复的从任务队列中读取任务,执行任务,其中运行机制称为事件循环(event loop)。

主线程从"任务队列"中读取事件,这个过程是循环不断的,所以整个的这种运行机制又称为 Event Loop(事件循环)。

JS 主线程拥有一个 执行栈(同步任务) 和 一个 任务队列(microtasks queue),主线程会依次执行代码

在 JavaScript 中,异步任务被分为两种,一种宏任务(MacroTask)也叫 Task,一种叫微任务(MicroTask)。

宏任务 ​

宏任务(MacroTask) 的例子很多,包括

  • 创建主文档对象、
  • 解析 HTML
  • 执行主线(或全局)JavaScript 代码
  • 更改当前 URL 以及各种事件,如页面加载、输入、网络事件和定时器事件。

从浏览器的角度来看,宏任务代表一个个离散的、独立工作单元。运行完任务后,浏览器可以继续其他调度,如重新渲染页面的 UI 或执行垃圾回收。

  • script 全部代码
  • setTimeout
  • setInterval
  • I/O
  • UI 渲染

微任务 ​

微任务就是一个需要异步执行的函数,执行时机是在主函数执行结束之后、当前宏任务结束之前。每个宏任务都关联着一个微任务队列。

微任务 MicroTask(微任务) 更新应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的 UI。微任务的案例包括

  • promise 回调函数
  • DOM 发生变化等。
  • MutationObserver
  • process.nextTick(Node 独有)

微任务需要尽可能快地、通过异步方式执行,同时不能产生全新的微任务。微任务使得我们能够在重新渲染 UI 之前执行指定的行为,避免不必要的 UI 重 绘,UI 重绘会使应用程序的状态不连续。

事件循环基于两个基本原则:

  • 一次处理一个任务。
  • 一个任务开始后直到运行完成,不会被其他任务中断

事件循环将首先检查宏任务队列,如果宏任务等待,则立即开始执行宏任务。直到该任务运行完成(或者队列为空),事件循环将移动去处理微任务队列。如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中所有微任务执行完毕。

TIP

单次循环迭代中,最多处理一个宏任务(其余的在队列中等待),而队列中的所有微任务都会被处理

当微任务队列处理完成并清空时,事件循环会检查是否需要更新 UI 渲染,如果是,则会重新渲染 UI 视图。至此,当前事件循环结束,之后 将回到最初第一个环节,再次检查宏任务队列,并开启新一轮的事件循环。

两类任务队列都是独立于事件循环的,这意味着任务队列的添加行为也发生在事件循环之外。如果不这样设计,则会导致在执行 JavaScript 代码时,发生的任何事件都将被忽略。正因为我们不希望看到这种情况,因此检测和添加任务的行为,是独立于事件循环完成的。

因为 JavaScript 基于单线程执行模型,所以这两类任务都是逐个执行的。当一个任务开始执行后,在完成前,中间不会被任何其他任务中断。除非浏览器决定中止执行该任务,例如,某个任务执行时间过长或内存占用过大。 所有微任务会在下一次渲染之前执行完成,因为它们的目标是在渲染前更新应用程序状态。

浏览器通常会尝试每秒渲染 60 次页面,以达到每秒 60 帧(60fps)的速度。60fps 通常是检验体验是否平滑流畅的标准,比方在动画里——这意味着浏览器会尝试在 16ms 内渲染一帧。需要注意图 13.1 所示的“更新渲染”是如何发生在事件循环内的,因为在页面渲染时,任何任务都无法再进行修改。 这些设计和原则都意味着,如果想要实现平滑流畅的应用,我们是没有太多时间浪费在处理单个事件循环任务的。理想情况下,单个任务和该任务附属的所有微任务,都应在 16ms 内完成。

js
const pElement = document.createElement('p')
pElement.innerText = '一段文字'

const rootElement = document.getElementById('root')
rootElement.appendChild(pElement)

Promise.resolve().then(() => {
  console.log(rootElement.childNodes.length)
  alert('DOM 还没有渲染')
})

setTimeout(() => {
  console.log(rootElement.childNodes.length)
  alert('DOM 渲染了')
}, 0)

宏任务:DOM 渲染后触发,如 settimeout 微任务:DOM 渲染前触发,如 Promise

js
console.log('script start')

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

new Promise((resolve) => {
  console.log('promise 1')
  resolve()
  console.log('promise 2')
}).then(() => {
  console.log('promise 3')
})

console.log('script end')

// 输出顺序:script start->promise 1 -> promise 2 -> script end -> promise 3 -> settimeout

总结 ​

  • 当遇到函数(同步)时,会先将函数入栈,函数运行结束后再将该函数出栈;
  • 当遇到 task 任务(异步)时,这些 task 会返回一个值,让主线程不在此阻塞,使主线程继续执行下去,而真正的 task 任务将交给 浏览器内核 执行,浏览器内核执行结束后,会将该任务事先定义好的回调函数加入相应的 任务队列(microtasks queue/ macrotasks queue) 中。
  • 当 JS 主线程清空执行栈之后,会按先入先出的顺序读取 microtasks queue 中的回调函数,并将该函数入栈,继续运行执行栈,直到清空执行栈,再去读取任务队列。
  • 当 microtasks queue 中的任务执行完成后,会提取 macrotask queue 的一个任务加入 microtask queue, 接着继续执行 microtask queue,依次执行下去直至所有任务执行结束。

上图中,主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部 API,它们在"任务队列"中加入各种事件(click,load,done)。只要栈中的代码执行完毕,主线程就会去读取"任务队列",依次执行那些事件所对应的回调函数。

  • Loupe 可视化工具:这是一个可视化的工具,能够帮助了解 js 的调用栈、事件循环、回调队列之间的调用关系等等的工具,帮助我们了解代码的执行情况。