讲事件循环之前先了解一下js的运行机制
上图就是js的运行机制,先来解释上图中出现的几个单词所表达的含义。
那就是堆(Heap)、栈(Stack)、队列(Queue)、事件轮训(Event Loop)这四个名词。
js中的堆、栈、队列
- 堆(Heap)
动态分配的内存,大小不定。存放引用类型,指那些可能由多个值构成的对象,保存在堆内存中,包含引用类型的变量,实际上保存的不是变量本身,而是指向该对象的指针,指针是一个十六进制的内存地址。可以简单理解为存储代码块。
堆的使用方式: 怎么放的就怎么拿,无序的"key-value"键值对存储方式。
举个栗子:书架存书
我们想要在书架上找到想要的书,最直接的方式就是通过查找书名,书名就是我们的key。拿着这把key,就可以轻松检索到对应的书籍。
堆的作用:存储引用类型值的数据.
举个栗子:
let Obj = {
name: '北歌',
puslic: '前端学习'
}
let func = function() {
console.log('hello world')
}
- 栈
js中的栈准确来讲应该叫调用栈(EC Stack),会自动分配内存空间,会自动释放,存放基本类型
栈的特点是"LIFO,即后进先出(Last in, first out)"。数据存储时先进入的数据被压入栈底,最后的数据在栈顶,需要读数据的时候从栈顶开始弹出数据。
栈的作用:
-
存储基本类型值
-
提供代码执行的环境
- 队列
队列特殊之处在于它只允许在表的前端(front)进行删除操作,而在表的后端(rear)进行插入操作,队列是一种操作受限制的线性表。进行插入操作的端称为队尾,进行删除操作的端称为队头。 队列中没有元素时,称为空队列。
队列的数据元素又称为队列元素。在队列中插入一个队列元素称为入队,从队列中删除一个队列元素称为出队。因为队列只允许在一端插入,在另一端删除,所以只有最早进入队列的元素才能最先从队列中删除,故队列又称为先进先出(FIFO: first-in-first-out)。
如图所示:
js中的队列可以叫做任务队列或异步队列,任务队列里存放各种异步操作所注册的回调,里面分为两种任务类型,宏任务(macroTask)和微任务(microTask)。
- 宏任务
宏任务包含以下几种:
-
script全部代码
-
setTimeout
-
setInterval
-
setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN)
-
I/O
-
UI Rendering
- 微任务
微任务包含以下几种:
-
Promise.then()、catch()、finally()里的回调
-
Process.nextTick(Node独有,优先级大于promise)
-
Object.observe(废弃)
-
MutationObserver
- 浏览器中的Event Loop
Javascript 有一个 main thread 主线程和 call-stack 调用栈(执行栈),所有的任务都会被放到调用栈等待主线程执行。
- JS调用栈
调用栈是一种栈结构,它用来存储计算机程序执行时候其活跃子程序的信息。它是一种LIFO的数据结构,将记录代码运行时的执行上下文。当遇到某个函数的调用语句时,它将会记录当前的执行上下文,将函数入栈,并为其创建一个新的执行上下文。(比如什么函数正在执行,什么函数正在被这个函数调用等等信息)。调用栈是解析器的一种机制.
JS调用栈采用的是后进先出的规则,当函数执行的时候,会被添加到栈的顶部,当执行栈执行完成后,就会从栈顶移出,直到栈内被清空。
总的来说,javascript是如何处理处理函数的调用关系的?答案是——调用栈
- 同步任务和异步任务
Javascript单线程任务被分为同步任务和异步任务,同步任务会在调用栈中按照顺序等待主线程依次执行,异步任务会在异步任务有了结果后,将注册的回调函数放入任务队列中等待主线程空闲的时候(调用栈被清空),被读取到栈内等待主线程的执行。
先总结一下JS运行机制:
代码执行开启一个全局调用栈(主栈)提供代码运行的环境,在执行过程中同步任务的代码立即执行,遇到异步任务将异步的回调注册到任务队列中,等待同步代码执行完毕查看异步是否完成,如果完成将当前异步任务的回调拿到主栈中执行。
为什么会出现Event Loop
总所周知JS是一门单线程的非阻塞脚本语言,Event Loop就是为了解决JS异步编程的一种解决方案.
那问题来了,JS为什么是单线程语言,那它是怎么实现异步编程(非阻塞)运行的?
1. JavaScript的诞生就是为了处理浏览器网页的交互(DOM操作的处理、UI动画等), 设计成单线程的原因就是不想让浏览器变得太复杂,因为多线程需要共享资源、且有可能修改彼此的运行结果;
2. JavaScript是单线程的但它所运行的宿主环境—浏览器是多线程,浏览器提供了各种线程供Event Loop调度来协调JS单线程运行时不会阻塞
JS的单线程
js的单线程指的是javaScript引擎只有一个线程。
单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。js 引擎执行异步代码而不用等待,是因有为有任务队列和事件轮询。
-
任务队列:任务队列是一个先进先出的队列,它里面存放着各种任务回调。
-
事件轮询:事件轮询是指主线程重复从任务队列中取任务、执行任务的过程。
线程和进程
进程:进程是 CPU 资源分配的最小单位(是能拥有资源和独立运行的最小单位)
线程:线程是 CPU 调度的最小单位(线程是建立在进程的基础上的一次程序运行单位)
用操作系统来作个例子:
线程依赖进程,一个进程可以有一个或者多个线程,但是线程只能是属于一个进程。
浏览器的多线程
浏览器是一个进程,存在多个线程:
1. GUI 渲染线程
- 绘制页面,解析 HTML、CSS,构建 DOM 树,布局和绘制等
- 页面重绘和回流
- 与 JS 引擎线程互斥,也就是所谓的 JS 执行阻塞页面更新
2. JS 引擎线程
- 负责 JS 脚本代码的执行
- 负责准执行准备好待执行的事件,即定时器计数结束,或异步请求成功并正确返回的事件
- 与 GUI 渲染线程互斥,执行时间过长将阻塞页面的渲染
3. 事件触发线程
- 负责将准备好的事件交给 JS 引擎线程执行
- 多个事件加入任务队列的时候需要排队等待(JS 的单线程)
4. 定时器触发线程
- 负责执行异步的定时器类的事件,如 setTimeout、setInterval
- 定时器到时间之后把注册的回调加到任务队列的队尾
5. HTTP 请求线程
- 负责执行异步请求
- 主线程执行代码遇到异步请求的时候会把函数交给该线程处理,当监听到状态变更事件,如果有回调函数,该线程会把回调函数加入到任务队列的队尾等待执行
回到正题!通过上面一系列的讲述终于清楚了。
事件轮询就是解决javaScript单线程对于异步操作的一些缺陷,让 javaScript做到既是单线程,又绝对不会阻塞的核心机制,是用来协调各种事件、用户交互、脚本执行、UI 渲染、网络请求等的一种机制。
事件轮训的步骤
Processing model[1]规范定义了Eveent Loop的循环过程:
一个Eveent Loop只要存在,就会不断执行下边的步骤:
1. 在tasks(任务)队列中选择最老的一个task,用户代理可以选择任何task队列,如果没有可选的任务,则跳到下边的microtasks步骤。
2. 将上边选择的task设置为正在运行的task。
3. Run: 运行被选择的task。
4. 将Eveent Loop的currently running task变为null。
5. 从task队列里移除前边运行的task。
6. Microtasks: 执行microtasks任务检查点(也就是执行microtasks队列里的任务)。
7. 更新渲染(Update the rendering):可以简单理解为浏览器渲染。
8. 如果这是一个worker event loop,但是没有任务在task队列中,则销毁Eveent Loop,中止这些步骤。
9. 返回到第一步。
Eveent Loopp会不断循环上面的步骤,概括说来:
-
Eveent Loop会不断循环的去取tasks队列的中最老的一个task(可以理解为宏任务)推入栈中执行,并在当次循环里依次执行并清空microtask队列里的任务。
-
执行完microtask队列里的任务,有可能会渲染更新。(浏览器很聪明,在一帧以内的多次dom变动浏览器不会立即响应,而是会积攒变动以最高60HZ(大约16.7ms每帧)的频率更新视图)--引申react16版本的fiber架构
通俗来说的执行顺序:
1. 代码从开始执行调用一个全局执行栈,script标签作为宏任务执行。
2. 执行过程中同步代码立即执行,异步代码放到任务队列中,任务队列存放有两种类型的异步任务,宏任务队列,微任务队列。
3. 同步代码执行完毕也就意味着第一个宏任务执行完毕(script)。
4. 先查看任务队列中的微任务队列是否存在宏任务执行过程中所产生的微任务。
5. 有的话就将微任务队列中的所有微任务清空。
6. 微任务执行过程中所产生的微任务放到微任务队列中,在此次执行中一并清空。
7. 如果没有再看看宏任务队列中有没有宏任务,有的话执行,没有的话事件轮询第一波结束。
8. 执行过程中所产生的微任务放到微任务队列。
9. 完成宏任务之后执行清空微任务队列的代码。
看图讲解: