前言
大家都知道JavaScript是一种单线程的脚本语言。在任意时刻,JavaScript都只能执行一个任务。为了更有效地处理事件、用户交互、脚本执行、UI渲染和网络请求,而不阻塞主线程,Event Loop(事件循环机制)应运而生。
为什么是单线程?
单线程同时间只能做一件事。那为什么不能有多个线程呢?这样能提高效率啊。
JavaScript最初被设计为浏览器的脚本语言,其核心功能之一是操作DOM。
假设 JavaScript 同时有两个线程,并且同时对同一个dom进行操作,这时浏览器要应该以哪个线程为准?为了避免这种问题,所以js设计为单线程语言以避免这类问题。
同步与异步的执行
同步任务:同步任务直接在主线程上执行,一个接一个地进行。
异步任务 :会被加入到任务队列
中,当主线程上的同步任务执行完毕,系统会按顺序执行队列中的异步任务,这种机制有效避免了在等待异步操作(如Ajax请求)期间的主线程阻塞。
执行栈与任务队列
执行栈
当我们调用一个方法的时候,JavaScript 会生成一个与这个方法对应的执行环境,又叫执行上下文(context)。
这个执行环境中保存着该方法的私有作用域、上层作用域(作用域链)、方法的参数,以及这个作用域中定义的变量和 this 的指向,而当一系列方法被依次调用的时候。由于 JavaScript 是单线程的,这些方法就会按顺序被排列在一个单独的地方,这个地方就是所谓执行栈。
任务队列
主线程会不停的从执行栈中读取事件,同步代码则立即执行 。当遇到一个异步事件后,并不会一直等待异步事件返回结果,而是会将这个事件放置另一个队列中,即任务队列(Task Queue)。
当主线程将执行栈中所有的代码执行完之后,主线程将会去查看任务队列是否有任务。如果有,那么事件队列便依次将任务压入执行栈中运行。
宏任务与微任务:
异步任务分为 宏任务(macrotask) 与 微任务 (microtask),不同的API注册的任务会依次进入自身对应的队列中,然后等待 Event Loop 将它们依次压入执行栈中执行。
宏任务(macro task)
宏任务(macro task) 指的是浏览器在执行代码的过程中会调度的任务,比如事件循环中的每一次迭代、setTimeout 和 setInterval 等。宏任务会在浏览器完成当前同步任务之后执行。
常见宏任务:script(整体代码)、setTimeout、setInterval、UI 渲染、 I/O、postMessage、 MessageChannel、setImmediate(Node.js 环境)
微任务(micro task)
微任务(micro task) 指的是在当前宏任务执行完成之后立即执行的任务,比如 Promise 的回调函数、process.nextTick 等。
常见微任务:Promise、 MutaionObserver、process.nextTick(Node.js环境)
Event Loop(事件循环):
-
- 进入到script标签,就进入到了第一次事件循环.
-
- 遇到同步代码,立即执行
-
- 遇到宏任务,放入到宏任务队列里.
-
- 遇到微任务,放入到微任务队列里.
-
- 执行完所有同步代码
-
- 查看是否有微任务,如果有则将代码放入主线程,新写入主线程的代码执行完毕后,查看是否还有微任务,依次执行,直至清空。
-
- 更新render(每一次事件循环,浏览器都可能会去更新渲染)
-
- 查看是否有宏任务,如果有则执行,将其代码放入主线程,重复步骤2。
- 以此反复直到清空所有宏任务,这种不断重复的执行机制,就叫做事件循环
示例代码
js
console.log("Start");
setTimeout(() => {
console.log("Timeout1");
}, 0);
Promise.resolve().then(() => {
console.log("Promise 1");
}).then(() => {
for(let i = 0 ; i < 9999; i++){
console.log(`我是for循环的第${i}个`)
}
});
setTimeout(() => {
console.log("Timeout2");
},0);
console.log("End");
分析过程
-
console.log("Start");
:同步任务会立即执行,在控制台输出 "Start"。 -
setTimeout(() => { console.log("Timeout1"); }, 0);
:会被放入宏任务队列中。 -
Promise.resolve().then(() => { console.log("Promise 1"); })
:会被放入微任务队列,因为Promise.resolve()为同步执行,会返回一个已解析(resolved)的 Promise 对象,Promise对象调用then中的回调函数会被放入微任务队列中。 -
.then(() => { for(let i = 0 ; i < 9999; i++){ console.log(
我是for循环的第${i}个) } });
: 会被放置微任务队列,因为then中的回调函数会被放入微任务队列中。(秉承着队列的"先进先出"的属性,大量的 "我是for循环的第i个" 会在Promise1
之后输出) -
setTimeout(() => { console.log("Timeout2"); }, 0);
:会被放入宏任务,此前宏任务中已有Timeout1
,宏任务也是队列,一样是先进先出,所以Timeout2
会在之前进入的Timeout1
之后输出。 -
console.log("End");
:同步任务立即执行,在控制台输出 "End"。
输出结果:
js
Start
End
Promise1
我是for循环的第0个
我是for循环的第1个
.............
我是for循环的第9998个
Timeout1
Timeout2
注意:微任务始终优先于宏任务执行,而在微任务队列中的任务会一直执行完毕,直到队列为空,然后才会执行下一个宏任务。这种执行机制是事件循环的关键特性。
结论
通过理解JavaScript的单线程本质、执行栈与任务队列的工作方式以及任务与微任务的区别,我们可以更好地编写高效且可靠的JavaScript代码。事件循环机制是这一切的核心,它确保了代码的有序执行和非阻塞交互。