在理解JavaScript事件循环机制之前,先理解几个概念:
进程
进程是是操作系统进行资源分配和调度的基本单位,是一个具有一定独立功能的程序在一个数据集上的一次动态执行的过程,这里我们可以比喻为一个工厂的车间
线程
是程序执行流的最小单元,一个进程可以有多个线程,这里我们可以比喻为车间里的工人,一个车间可以由多个工人协同完成一个任务
进程与线程的关系
可以用下面的图来表示:
浏览器的多进程架构
浏览器的每一个 tab页都是一个进程,有对应的内存占用空间、CPU使用量以及进程ID。新打开一个tab页时,都会新建一个进程,所以就有一个tab页对应一个进程的说法,但是这种说法又是错误的,因为浏览器有自己的优化机制,当我们打开多个空白的 tab页时,浏览器会将这多个空白页的进程合并为一个,从而减少了进程的数量个数。
浏览器内核是多线程的
浏览器内核是多线程的,多个线程之前相互配合以保持同步,其中的线程包括:
- GUI渲染线程
- JavaScript引擎线程
- 事件触发线程
- 定时器触发线程
- http请求线程
GUI渲染线程:
- 主要负责页面的渲染,解析html、css,构建DOM树,完成页面的布局和绘制等。
- 当页面进行重绘或者回流时,会执行该线程。
- 与JavaScript引擎线程互斥,当执行JavaScript引擎线程时,GUI渲染线程会被挂起。
JavaScript引擎线程:
- 主要负责解析并执行JavaScript脚本程序。
- 与GUI渲染线程互斥。
事件触发线程:
- 当一个事件被触发时,该线程会把事件添加到待处理队列的队尾,等待JavaScript引擎执行。
- 定时器、http异步请求、事件绑定,当这类的事件被触发时,都会执行该线程。
定时器线程:
- 浏览器的定时器并不是由JavaScript引擎线程来计数的,如果JavaScript引擎处于阻塞情况下会影响定时器的准确性,所以需要定时器线程单独执行。
- 当setTimeout或者setInterval的计时结束后,会由事件触发线程将其回调函数加入待处理队列的队尾,等待JavaScript引擎执行。
http请求线程:
- 当遇到http请求的时候,会由该线程处理,比如ajax。
- 如果http请求中加入了回调函数,且回掉函数被触发,则会由事件触发线程将其回调函数加入待处理队列的队尾,等待JavaScript引擎执行。
为什么JavaScript是单线程
这是因为Javascript这门脚本语言诞生的使命所致:JavaScript为处理页面中用户的交互,以及操作DOM树、CSS样式树来给用户呈现一份动态而丰富的交互体验和服务器逻辑的交互处理。如果JavaScript是多线程的方式来操作这些UI DOM,则可能出现UI操作的冲突;如果Javascript是多线程的话,在多线程的交互下,处于UI中的DOM节点就可能成为一个临界资源,假设存在两个线程同时操作一个DOM,一个负责修改一个负责删除,那么这个时候就需要浏览器来裁决如何生效哪个线程的执行结果。当然我们可以通过锁来解决上面的问题。但为了避免因为引入了锁而带来更大的复杂性,Javascript在最初就选择了单线程执行。
同步任务和异步任务
- 同步任务
按代码顺序执行,只有当上一行代码被执行完毕才会执行下一行代码。
- 异步任务
比如定时器、http请求、事件处理等都是异步任务,不会阻塞主线程代码的执行。
看下面代码的执行过程:
'use strict'; console.log(1); setTimeout(function() { console.log(2); }); console.log(3);复制代码
执行过程如下:
- 创建全局上下文环境,压入执行栈中,并初始化,进入预解析阶段。
- 进入执行阶段,按预解析后的代码顺序执行。
- 当执行到 console.log(1) 的时候,判断其为同步任务,压入执行栈中,立即执行。
- console.log(1) 执行完毕,出栈,重新回到全局上下文环境。
- 执行到 setTimeout 定时器的时候,判断其为异步任务,将其交给 定时器触发线程。
- 执行到 console.log(3) 的时候,判断其为同步任务,压入执行栈中,立即执行。
- 当主线程中的同步任务执行完毕,会去待处理任务队列中依次执行任务。
- 此时 setTimeout 定时器的回调函数被 事件触发线程 加入到任务队列中。
- 执行 setTimeout 定时器的回调函数,压入执行栈中,立即执行。
- 执行到 console.log(2),判断其为同步任务,压入执行栈中,立即执行。
- console.log(2) 执行完毕出栈。
- setTimeout 定时器的回调函数执行完毕出栈。
- 重新回到全局上下文执行环境。
所以最终打印的结果是: 1 3 2
宏任务和微任务
- 宏任务(可以有多个任务队列)
包括全局任务(script)、定时器、http请求、事件处理、I/O、UI渲染
- 微任务(只有一个任务队列)
包括 process.nextTick、Promise.then()、Object.observe(已废弃,避免使用)、MutationObserver
在微任务中process.nextTick的优先级高于Promise
事件循环机制(Event Loop)
- 从全局任务(script) 开始,依次进入执行栈中,在主线程中执行,执行完毕出栈。
- 如果遇到异步任务,则由对应的触发线程来处理。
- 当异步任务达到可执行的状态,事件触发线程会将其回调函数依次加入任务队列,等待主线程执行完毕时依次执行。
- 主线程执行完毕。
- 执行任务队列中的任务,首先执行微任务队列中的任务。
- 微任务队列中的任务执行完毕之后,会执行排在最前面的宏任务队列。
- 如果在执行宏任务的过程中发现微任务,会由事件触发线程将微任务加入到微任务队列,等待下一次的循环执行。
- 排在最前面的宏任务队列执行完毕后,出栈,进行下轮循环(从第5步开始)。
看下面的例子:
'use strict'; console.log(1); setTimeout(function() { console.log(2); Promise.resolve().then(() => { console.log(3); }); }); new Promise(resolve => { console.log(4); resolve(); }) .then(() => { console.log(5); }); console.log(6); 复制代码
执行过程解析:
第一轮循环:
- 从全局任务开始,首先遇到同步任务 console.log(1),压栈并立即执行,执行完毕出栈。
- 遇到宏任务 setTimeout,将其交给 定时器触发线程,我们先记为 setTimeout1。
- Promise在实例化的时候为同步任务,所以立即执行同步任务 console.log(4),执行完毕出栈。
- 遇到微任务 Promise.then(),由 事件触发线程 将其加入微任务队列中,我们暂且记为 Promise.then1。
- 遇到同步任务 console.log(6),压栈并立即执行,执行完毕出栈。
- 主线程执行完成,第一轮循环结束,输出 1 4 6 。
此时的任务队列如下:
第二轮循环:
- 读取微任务队列中的 Promise.then1 任务。
- 遇到同步任务 console.log(5),压栈并立即执行,执行完毕出栈。
- 微任务队列执行完毕后,读取宏任务队列最靠前的任务,即 setTimeout1。
- 遇到同步任务 console.log(2),压栈并立即执行,执行完毕出栈。
- 遇到微任务 Promise.then(),加入微任务队列,暂且记为 Promise.then2。
- 宏任务 Promise.then1 执行完毕出栈,第二轮循环结束,输出 1 4 6 5 2 。
此时的任务队列如下:
第三轮循环:
- 读取微任务队列中的 Promise.thn2 任务。
- 遇到同步任务 console.log(3),压栈并立即执行,执行完毕出栈。
- 此时任务队列为空,第三轮循环结束,执行完毕。
- 最终控制台输出 1 4 6 5 2 3 。
结语:
至此整个事件循环就结束了,最后在将JavaScript事件循环机制总结一遍:
- 首先执行全局任务,同步任务会依次压栈并立即执行,执行完毕出栈。
- 当遇到异步任务,会由其他的线程进行处理,比如:setTimeout 会由定时器触发线程处理,http请求会由http请求线程处理,微任务会由事件触发线程加入到微任务队列。
- 当异步任务达到可执行状态,会由事件触发线程将其回掉函数加入任务队列,等待主线程任务执行完毕之后,依次读取执行。
- 当主线程上的同步任务全部执行完毕之后,会去读取任务队列中的任务。
- 任务队列中,首先读取微任务队列中的任务。
- 微任务队列中的任务执行完毕之后,会读取宏任务队列中最靠前的任务。
- 读取宏任务的过程中,如果遇到微任务,则加入到微任务队列中。
- 当该宏任务执行完毕之后,循环结束,进行下一轮循环。
整个流程图如下: