事件循环
- 宏任务有哪些?微任务有哪些?宏任务和微任务触发时机
- 宏任务、微任务和 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 内完成。
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
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 的调用栈、事件循环、回调队列之间的调用关系等等的工具,帮助我们了解代码的执行情况。