要怎么理解 JavaScript 是单线程这个概念呢?大概需要从浏览器来说起。
JavaScript 最初被设计为浏览器脚本语言,主要用途包括对页面的操作、与浏览器的交互、与用户的交互、页面逻辑处理等。如果将 JavaScript 设计为多线程,那当多个线程同时对同一个 DOM 节点进行操作时,线程间的同步问题会变得很复杂。
因此,为了避免复杂性,JavaScript 被设计为单线程。
这样一个单线程的 JavaScript,意味着任务需要一个接一个地处理。如果有一个任务是等待用户输入,那在用户进行操作前,所有其他任务都处于等待状态,页面会进入假死状态,用户体验会很糟糕。
那么,为了高效进行页面的交互和渲染处理,我们围绕着任务执行是否阻塞 JavaScript 主线程,将 JavaScript 中的任务分为同步任务和异步任务
我们先来看一下同步任务在浏览器中的是怎样执行的。
JavaScript 在执行过程中每进入一个不同的运行环境时,都会创建一个相应的执行上下文。那么,当我们执行一段 JavaScript 代码时,通常会创建多个执行上下文。
而 JavaScript 解释器会以栈的方式管理这些执行上下文、以及函数之间的调用关系,形成函数调用栈(call stack)(调用栈可理解为一个存储函数调用的栈结构,遵循 FILO(先进后出)的原则)
我们来看一下
JavaScript 中代码执行
的过程:
由此可见,JavaScript 代码执行过程中,函数调用栈
栈底
永远是全局执行上下文
,栈顶
永远是当前执行上下文
。
在不考虑全局执行上下文时,我们可以理解为刚开始的时候调用栈是空的,每当有函数被调用,相应的执行上下文都会被添加到调用栈中。执行完函数中相关代码后,该执行上下文又会自动被调用栈移除,最后调用栈又回到了空的状态(同样不考虑全局执行上下文)。
由于栈的容量是有限制的,所以当我们没有合理调用函数的时候,可能会导致爆栈异常,此时控制台便会抛出错误:
这样的一个函数调用栈结构,可以理解为 JavaScript 中同步任务的执行环境,同步任务也可以理解为 JavaScript 代码片段的执行。
同步任务的执行会阻塞主线程,也就是说,一个函数执行的时候不会被抢占,只有在它执行完毕之后,才会去执行任何其他的代码。这意味着如果我们一个任务执行的时间过长,浏览器就无法处理与用户的交互,例如点击或滚动。
因此,我们还需要用到
异步任务
。
异步任务包括一些需要等待响应的任务,包括用户交互、HTTP 请求、定时器等。
我们知道,I/O 类型的任务会有较长的等待时间,对于这类无法立刻得到结果的事件,可以使用异步任务的方式。这个过程中 JavaScript 线程就不用处于等待状态,CPU 也可以处理其他任务。
异步任务需要提供回调函数,当异步任务有了运行结果之后,该任务则会被添加到回调队列中,主线程在适当的时候会从回调队列中取出相应的回调函数并执行。
这里提到的回调队列又是什么呢?
实际上,JavaScript 在运行的时候,除了函数调用栈之外,还包含了一个待处理的回调队列。在回调队列中的都是已经有了运行结果的异步任务,每一个异步任务都会关联着一个回调函数。
回调队列则遵循
FIFO(先进先出
)的原则,JavaScript 执行代码过程中,会进行以下的处理:
Event Loop(事件循环)
将会处理队列中的下一个任务。这里我们提到了
Event Loop
,它主要是用来管理单线程的 JavaScript 中同步任务和异步任务的执行问题。
我们知道,单线程的设计会存在阻塞问题,为此 JavaScript 中任务被分为同步和异步任务。那么,同步任务和异步任务之间是按照什么顺序来执行的呢?
JavaScript 有一个基于事件循环的并发模型,称为事件循环(Event Loop)
,它的设计解决了同步任务和异步任务的管理问题。
根据 JavaScript 运行环境的不同,Event Loop
也会被分成浏览器的 Event Loop
和 Node.js 中的 Event Loop
。
事件循环又叫做消息循环,是浏览器渲染主线程的工作方式。
如图,主线程运行的时候,会产生
堆(heap
)和栈(stack
),其中堆为内存、栈为函数调用栈。我们能看到,Event Loop
负责执行代码、收集和处理事件以及执行队列中的子任务,具体包括以下过程
上述过程会不断重复,这就是 JavaScript 的运行机制,称为事件循环机制(Event Loop)。
Event Loop 的设计会带来一些问题,比如setTimeout、setInterval的时间精确性。这两个方法会设置一个计时器,当计时器计时完成,需要执行回调函数,此时才把回调函数放入回调队列中。
如果当回调函数放入队列时,假设队列中还有大量的回调函数在等待执行,此时就会造成任务执行时间不精确。
要优化这个问题,可以使用系统时钟来补偿计时器的不准确性,从而提升精确度。举个例子,如果你的计时器会在回调时触发二次计时,可以在每次回调任务结束的时候,根据最初的系统时间和该任务的执行时间进行差值比较,来修正后续的计时器时间。
除了浏览器,Node.js 中同样存在 Event Loop。由于 JavaScript 是单线程的,Event Loop 的设计使 Node.js 可以通过将操作转移到系统内核中,来执行非阻塞 I/O 操作
Node.js 中的事件循环执行过程为:
与浏览器不一样,Node.js 中事件循环分成不同的阶段:
┌───────────────────────────┐
┌─>│ timers │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ pending callbacks │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
│ │ idle, prepare │
│ └─────────────┬─────────────┘ ┌───────────────┐
│ ┌─────────────┴─────────────┐ │ incoming: │
│ │ poll │<─────┤ |
│ └─────────────┬─────────────┘ │ data, etc. │
│ ┌─────────────┴─────────────┐ └───────────────┘
│ │ check │
│ └─────────────┬─────────────┘
│ ┌─────────────┴─────────────┐
└──┤ close callbacks │
└───────────────────────────┘
由于事件循环阶段划分不一致,Node.js 和浏览器在对宏任务和微任务的处理上也不一样
在浏览器里,每当一个被监听的事件发生时,事件监听器绑定的相关任务就会被添加进回调队列。通过事件产生的任务是异步任务,常见的事件任务包括:
添加任务到微队列的主要方式主要是使用
Promise
、MutationObserver
例如:
// 立即把一个函数添加到微队列
Promise.resolve().then(函数)
事件循环中的异步回调队列有两种:宏任务(MacroTask)和微任务(MicroTask)队列。
什么是宏任务和微任务呢?
宏任务:包括 script 全部代码、setTimeout、setInterval、setImmediate(Node.js)、requestAnimationFrame(浏览器)、I/O 操作、UI 渲染(浏览器),这些代码执行便是宏任务。
微任务:包括process.nextTick(Node.js)、Promise、MutationObserver,这些代码执行便是微任务
为什么要将异步任务分为宏任务和微任务呢?
这是为了避免回调队列中等待执行的异步任务(宏任务)过多,导致某些异步任务(微任务)的等待时间过长。在每个宏任务执行完成之后,会先将微任务队列中的任务执行完毕,再执行下一个宏任务
在浏览器的异步回调队列中,宏任务和微任务的执行过程如下:
我们能看到,在浏览器中每个宏任务执行完成后,会执行微任务队列中的任务。而在 Node.js 中,事件循环分为 6 个阶段,微任务会在事件循环的各个阶段之间执行。也就是说,每当一个阶段执行完毕,就会去执行微任务队列的任务
function a() {
console.log(1);
Promise.resolve().then(function () {
console.log(2);
});
}
setTimeout(function () {
console.log(3);
Promise.resolve().then(a);
}, 0);
Promise.resolve().then(function () {
console.log(4);
});
console.log(5);
// 最后结果: 5、4、3、1、2
解释:
console.log(5)
为全局环境宏任务先输出,优先级第一;Promise.resolve().then(函数)
将该函数直接添加到微队列,所以console.log(4)
输出,优先级第二;setTimeout(){}
为延时队列;console.log(3)
为该队列里的宏任务,所以console.log(3)
输出,优先级第三,并且调用了函数a
;console.log(1);
为函数a
里的宏任务,所以输出console.log(3)
,优先级第四;console.log(2)
。这篇文章讲了一下单线程的 【JavaScript】 是如何管理任务的,像事件循环
以及结果输出问题
一直是面试的高频题目
。但是不要怕,只要搞懂了事件循环
和作用域
一切都没那么可怕。各位小伙伴让我们 let’s be prepared at all times!
✨原创不易,还希望各位大佬支持一下!
点赞,你的认可是我创作的动力!
⭐️ 收藏,你的青睐是我努力的方向!
✏️ 评论,你的意见是我进步的财富!