JavaScript 是一门单线程的编程语言,这意味着它只有一个主执行线程来处理所有的任务。然而,JavaScript 可以利用异步编程的方式实现并发操作,从而提高性能和用户体验。为了更好地理解 JavaScript 中的事件循环,我们首先要了解进程、线程和异步编程的概念。
进程与线程
进程是指操作系统中运行的一个程序实例。在计算机上同时运行着多个进程,每个进程都有自己的独立内存空间和执行环境。进程之间相互独立,彼此不会干扰。而线程是进程中更小的单位,是操作系统调度的基本单位。一个进程可以包含多个线程,这些线程共享进程的内存空间和其他资源。
简单来说,进程指的是cpu在运行指令和保存上下文所需的时间,线程指的是进程中更小的单位,指的是一段指令执行所需要的时间。
在浏览器中,每个标签页通常都是一个独立的进程,这样可以隔离不同页面的运行环境,提高安全性和稳定性。当我们在浏览器中新开一个标签页时,就会创建一个新的进程来处理该标签页的内容。而在这个进程中,又可以有多个线程来并行处理不同的任务。
举个例子,当我们在浏览器中新开一个标签页并访问一个网站时,这个进程中可能会有以下几个线程同时工作:
渲染线程
:负责将 HTML、CSS 和 JavaScript 转换为可视化页面,处理页面的渲染和绘制。HTTP 请求线程
:负责发送网络请求和接收响应,处理与服务器的通信。JavaScript 引擎线程
:负责解析 JavaScript 代码并执行,处理页面的交互和动态效果。
需要知道的是,线程之间是可以一起工作的,这些线程在进程中互相协作,完成各自的任务。但是,渲染线程和 JavaScript 引擎线程是互斥的,即同一时间只能有一个线程在执行。这是因为渲染线程需要访问 DOM 树和样式表来进行页面渲染,而 JavaScript 引擎线程可能会修改 DOM 树或样式表,所以需要互斥执行,以免出现冲突。
单线程的JavaScript
由于 JavaScript 是单线程的
,它在执行时只能按照顺序逐条执行代码。这样做有两个优点:
节约内存开销
:由于只需要一个线程来执行代码,不需要为每个线程分配独立的内存空间,从而节约了内存开销。没有锁的概念
:在多线程编程中,由于多个线程可能同时访问共享资源,需要引入锁机制来保证数据的一致性,但这会增加上下文切换的时间。而 JavaScript 的单线程模型不存在这个问题,可以避免锁的开销。
异步编程
JavaScript 通过异步编程的方式来实现并发操作
。它将任务分为宏任务和微任务两种类型。
宏任务(Macrotask)
包括以下几种:
- script:整体的 JavaScript 代码块。
- setTimeout 和 setInterval:定时器任务。
- setImmediate:在当前事件循环完成后立即执行的任务。
- I/O 操作:例如网络请求、文件读写等。
- UI 渲染:浏览器需要绘制页面时触发的任务。
微任务(Microtask)
包括以下几种:
- Promise.then():Promise 的回调函数。
- MutationObserver:DOM 变动观察器。
- process.nextTick():Node.js 中的微任务。
事件循环(event-loop)
事件循环(Event Loop)是 JavaScript 实现异步编程的关键机制。它负责监听、收集和执行宏任务和微任务。事件循环的执行过程如下:
执行同步代码(宏任务)
:首先执行当前执行上下文中的同步代码,按照顺序逐条执行。查询是否有异步任务需要执行
:当执行栈为空时,事件循环开始查询是否有需要执行的异步任务。执行微任务(优先级高)
:如果有微任务,事件循环会依次执行微任务队列中的任务,直到队列为空。如果有需要,渲染页面
:如果需要更新页面,浏览器会在此阶段进行页面的渲染,保证用户界面的及时响应。执行宏任务(下一次事件循环的开始
):事件循环会从宏任务队列中取出一个任务执行,然后回到第一步,继续循环执行。
我们来看一个例子:
scss
console.log(1);
setTimeout(() => {
console.log(2);
new Promise((resolve) => {
console.log(4);
resolve()
setTimeout(() => {
console.log(6);
})
}).then(() => {
console.log(5);`
})
}, 1000)
console.log(3);
这段代码最终的输出顺序是什么样的呢?
第一步执行同步代码,看这段代码首先是一个console.log(1);
之后是一个异步的定时器setTimeout()
,它是宏任务,我们将它加入宏任务队列[ setTimeout() ],再就是console.log(3);
所以执行同步代码输出的是1,3
。
第二步查询是否有异步任务需要执行,此时宏任务队列里有一个定时器,微任务队列为空,第三步和第四步不用做。
到第五步执行宏任务,执行宏任务队列里的定时器。我们再来看setTimeout()
里的代码,回到第一步重新开始。
执行同步代码 ,首先打印2,此时的输出顺序是1,3,2
,之后是一个new Promise(),它是立即执行的,于是执行它里面的代码打印了4,输出顺序为1,3,2,4
,之后是一个resolve的调用,再就是一个定时器setTimeout(),我们又把它加入到宏任务队列[ setTimeout() setTimeout() ],宏任务队列里的第一个定时器我们已经开始执行了,所以把它划掉。new Promise()之后还有个.then(),这个是微任务,于是把它加入微任务队列里[ Promise.then() ]。此时宏任务和微任务队列不为空,查询到有异步任务 ,于是开始执行微任务 队列里的Promise.then(),输出5,此时输出顺序为1,3,2,4,5
,然后执行宏任务 队列里的定时器,里面是立即执行的console.log(6)
,此时全部代码都执行完毕,最终的输出顺序为1,3,2,4,5,6
。
注意await
这里我们还要再讲一下await会造成的影响,当代码中有await时,浏览器会给await开小灶提速,让它立即执行,而把它之后的代码挤入微任务队列。
我们来看例子,顺便练习一下刚刚学的知识:
javascript
console.log('start');
async function async1() {
await async2() // 浏览器给await开小灶提速
console.log('async1'); // 被await挤入微任务队列
}
async function async2() {
console.log('async2');
}
async1()
setTimeout(function() {
console.log('setTimeout');
}, 0)
new Promise((resolve) => {
console.log('promise');
resolve()
})
.then(() => {
console.log('then1');
})
.then(() => {
console.log('then2');
})
console.log('end');
看完这段代码你得出的输出顺序是什么呢?
正确答案是start,async2,promise,end,async1,then1,then2,setTimeout
,你答对了吗?
我们来看下过程,第一步执行同步代码,首先是console.log('start');
,输出了start,然后是async1()
,于是我们去执行函数async1,里面是await async2()
于是立即执行调用async2,函数async2里面是console.log('async2');
于是现在的输出是start,async2。await async2()
之后的代码是 console.log('async1');
它被挤入了微任务队列[ console.log('async1') ]。此时async1的调用完成,之后是一个定时器,把它放入宏任务队列[ setTimeout() ],之后是立即执行的new Promise(),里面是console.log('promise');
,此时输出为start,async2,promise。后面有两个.then(),把它们加入微任务队列[ console.log('async1'),.then(),.then()],最后是一个打印end,此时输出为start,async2,promise,end。然后是执行异步任务,先是微任务,一个个执行微任务队列里的微任务,此时输出为start,async2,promise,end,async1,then1,then2。然后执行宏任务,就一个setTimeout()输出'setTimeout',所以最终的输出就是start,async2,promise,end,async1,then1,then2,setTimeout啦,你学会了吗?
今天的内容到这就结束啦,欢迎下次再来一起学习ヾ(◍°∇°◍)ノ゙!!