最近在学习Vue
源码,恰好学到虚拟DOM的异步更新,这里就涉及到JavaScript
中的事件循环Event Loop
。以前对这个概念仍是比较模糊,大概知道是什么,但一直没有深刻学习。恰好借此机会,回过头来学习一下Event Loop
。javascript
事件循环Event Loop
,这是目前浏览器和NodeJS
处理JavaScript
代码的一种机制,而这种机制存在的背后,就有由于JavaScript
是一门单线程的语言。html
单线程和多线程最简单的区别就是:单线程同一个时间只能作一件事情,而多线程同一个时间能作多件事情。java
而JavaScript
之所谓设计为单线程语言,主要是由于它做为浏览器脚本语言,主要的用途就是与用户互动,操做Dom
节点。web
而在这个情景设定下,假设JavaScript
同时有两个进程,一个是操做A节点,一个是删除A节点,这时候浏览器就不知道要以哪一个线程为准了。面试
所以为了不这类型的问题,JavaScript
从一开始就属于单线程语言。json
在JavaScript
运行的时候,主线程会造成一个栈,这个栈主要是解释器用来最终函数执行流的一种机制。一般这个栈被称为调用栈Call Stack
,或者执行栈(Execution Context Stack
)。api
调用栈,顾名思义是具备LIFO(后进先出,Last in First Out)的结构。调用栈内存放的是代码执行期间的全部执行上下文。promise
如今用个小案例来演示一下调用栈。浏览器
function a() {
console.log('a');
}
function b() {
console.log('b');
}
function c() {
console.log('c');
a();
b();
}
c();
/** * 输出结果:c a b */
复制代码
执行这段代码的时候,首先调用的是函数c()
。所以function c(){}
的执行上下文就会被放入调用栈中。性能优化
而后开始执行函数c
,执行的第一个语句是console.log('c')
。
所以解释器也会将其放入调用栈中。
当console.log('c')
方法执行完后,控制台打印了'c'
,调用栈就会将其移除。
接着就是执行a()
函数。
解释器就将function a() {}
的执行上下文放入调用栈中。
紧接着就执行a()
中的语句——console.log('a')
。
当函数a
执行结束后,调用栈就将执行上下文移除。
而后接着执行c()
函数剩下的语句,也就是执行b()
函数,所以它的执行上下文就加入调用栈中。
紧接着就执行b()
中的语句——console.log('b')
。
b()
执行完后,调用栈就将其移出。
这时c()
也执行结束了,调用栈也将其移出栈。
这时候,咱们这段语句就执行结束了。
上面的案例简单的介绍了关于JavaScript
单线程的执行方式。
但这其中会存在一些问题,就是若是当一个语句也须要执行很长时间的话,好比请求数据、定时器、读取文件等等,后面的语句就得一直等着前面的语句执行结束后才会开始执行。
显而易见,这是不可取的。
所以,JavaScript
将全部执行任务分为了同步任务和异步任务。
其实咱们每一个任务都是在作两件事情,就是发起调用和获得结果。
而同步任务和异步任务最主要的差异就是,同步任务发起调用后,很快就能够获得结果,而异步任务是没法当即获得结果,好比请求接口,每一个接口都会有必定的响应时间,根据网速、服务器等等因素决定,再好比定时器,它须要固定时间后才会返回结果。
所以,对于同步任务和异步任务的执行机制也不一样。
同步任务的执行,其实就是跟前面那个案例同样,按照代码顺序和调用顺序,支持进入调用栈中并执行,执行结束后就移除调用栈。
而异步任务的执行,首先它依旧会进入调用栈中,而后发起调用,而后解释器会将其响应回调任务放入一个任务队列,紧接着调用栈会将这个任务移除。当主线程清空后,即全部同步任务结束后,解释器会读取任务队列,并依次将已完成的异步任务加入调用栈中并执行。
这里有个重点,就是异步任务不是直接进入任务队列的。
这里举一个简单的例子。
console.log(1);
fetch('https://jsonplaceholder.typicode.com/todos/1')
.then(response => response.json())
.then(json => console.log(json))
console.log(2);
复制代码
很显然,fetch()
就是一个异步任务。
但执行到console.log(2)
以前,其实fetch()
已经被调用且发起请求了,可是还未响应数据。而响应数据和处理数据的函数then()
此时已经在任务队列中,等候console.log(2)
执行结束后,因此同步任务清空后,再进入调用栈执行响应动做。
前面聊到同步任务和异步任务的时候,说起到了任务队列。
在任务队列中,其实还分为宏任务队列(Task Queue)和微任务队列(Microtask Queue),对应的里面存放的就是宏任务和微任务。
首先,宏任务和微任务都是异步任务。
而宏任务和微任务的区别,就是它们执行的顺序,这也是为何要区分宏任务和微任务。
在同步任务中,任务的执行都是按照代码顺序执行的,而异步任务的执行也是须要按顺序的,队列的属性就是先进先出(FIFO,First in First Out),所以异步任务会按照进入队列的顺序依次执行。
但在一些场景下,若是只按照进入队列的顺序依次执行的话,也会出问题。好比队列先进入一个一小时的定时器,接着再进入一个请求接口函数,而若是根据进入队列的顺序执行的话,请求接口函数可能须要一个小时后才会响应数据。
所以浏览器就会将异步任务分为宏任务和微任务,而后按照事件循环的机制去执行,所以不一样的任务会有不一样的执行优先级,具体会在事件循环讲到。
这里还有一个知识点,就是关于任务入队。
任务进入任务队列,其实会利用到浏览器的其余线程。虽说JavaScript
是单线程语言,可是浏览器不是单线程的。而不一样的线程就会对不一样的事件进行处理,当对应事件能够执行的时候,对应线程就会将其放入任务队列。
setInterval
、setTimeout
等待时间结束后,会把执行函数推入任务队列中;click
、mouse
等UI交互事件发生后,将要执行的回调函数放入到事件队列中。这个其实就能够解释了下列代码为何后面的定时器会比前面的定时器先执行。由于后者的定时器会先被推动宏任务队列,而前者会以后到点了再被推入宏任务队列。
setTimeout(() => {
console.log('a');
}, 10000);
setTimeout(() => {
console.log('b');
}, 100);
复制代码
浏览器 | Node | |
---|---|---|
总体代码(script) | ✅ | ✅ |
UI交互事件 | ✅ | ❌ |
I/O | ✅ | ✅ |
setTimeout | ✅ | ✅ |
setInterval | ✅ | ✅ |
setImmediate | ❌ | ✅ |
requestAnimationFrame | ✅ | ❌ |
浏览器 | Node | |
---|---|---|
process.nextTick | ❌ | ✅ |
MutationObserver | ✅ | ❌ |
Promise.then catch finally | ✅ | ✅ |
其实宏任务队列和微任务队列的执行,就是事件循环的一部分了,因此放在这里一块儿说。
事件循环的具体流程以下:
这里有几个重点:
script
放入宏任务队列中,所以事件循环是从第一个宏任务开始的;接下来,经过一个常见的面试题例子来模拟一下事件循环。
console.log("a");
setTimeout(function () {
console.log("b");
}, 0);
new Promise((resolve) => {
console.log("c");
resolve();
})
.then(function () {
console.log("d");
})
.then(function () {
console.log("e");
});
console.log("f");
/** * 输出结果:a c f d e b */
复制代码
首先,当代码执行的时候,总体代码script
被推入宏任务队列中,并开始执行该宏任务。
按照代码顺序,首先执行console.log("a")
。
该函数上下文被推入调用栈,执行完后,即移除调用栈。
接下来执行setTimeout()
,该函数上下文也进入调用栈中。
由于setTimeout
是一个宏任务,所以将其callback
函数推入宏任务队列中,而后该函数就被移除调用栈,继续往下执行。
紧接着是Promise
语句,先将其放入调用栈,而后接着往下执行。
执行console.log("c")
和resolve()
,这里就很少说了。
接着来到new Promise().then()
方法,这是一个微任务,所以将其推入微任务队列中。
这时new Promise
语句已经执行结束了,就被移除调用栈。
接着作执行console.log('f')
。
这时候,script
宏任务已经执行结束了,所以被推出宏任务队列。
紧接着开始清空微任务队列了。首先执行的是Promise then
,所以它被推入调用栈中。
而后开始执行其中的console.log("d")
。
执行结束后,检测到后面还有一个then()
函数,所以将其推入微任务队列中。
此时第一个then()
函数已经执行结束了,就会移除调用栈和微任务队列。
此时微任务队列还没被清空,所以继续执行下一个微任务。
执行过程跟前面差很少,就很少说了。
此时微任务队列已经清空了,第一个事件循环已经结束了。
接下来执行下一个宏任务,即setTimeout callback
。
执行结束后,它也被移除宏任务队列和调用栈。
这时候微任务队列里面没有任务,所以第二个事件循环也结束了。
宏任务也被清空了,所以这段代码已经执行结束了。
ECMAScript2017中添加了async functions
和await
。
async
关键字是将一个同步函数变成一个异步函数,并将返回值变为promise
。
而await
能够放在任何异步的、基于promise
的函数以前。在执行过程当中,它会暂停代码在该行上,直到promise
完成,而后返回结果值。而在暂停的同时,其余正在等待执行的代码就有机会执行了。
下面经过一个例子来体验一下。
async function async1() {
console.log("a");
const res = await async2();
console.log("b");
}
async function async2() {
console.log("c");
return 2;
}
console.log("d");
setTimeout(() => {
console.log("e");
}, 0);
async1().then(res => {
console.log("f")
})
new Promise((resolve) => {
console.log("g");
resolve();
}).then(() => {
console.log("h");
});
console.log("i");
/** * 输出结果:d a c g i b h f e */
复制代码
首先,开始执行前,将总体代码script
放入宏任务队列中,并开始执行。
第一个执行的是console.log("d")
。
紧接着是setTimeout
,将其回调放入宏任务中,而后继续执行。
紧接着是调用async1()
函数,所以将其函数上下文放置到调用栈。
而后开始执行async1
中的console.log("a")
。
接下来就是await
关键字语句。
await
后面调用的是async2
函数,所以咱们将其放入调用栈。
而后开始执行async2
中的console.log("c")
,并return
一个值。
执行完成后,async2
就被移出调用栈。
这时候,await
会阻塞async2
的返回值,先跳出async1
进行往下执行。
须要注意的是,如今async1
中的res
变量,仍是undefined
,没有赋值。
紧接着是执行new Promise
。
执行console.log("i")
。
这时,async1
外面的同步任务都执行完成了,所以就从新回到前面阻塞的位置,进行往下执行。
这时res
成功赋值了async2
的结果值,而后往下执行console.log("b")
。
这时候async1
才算是执行结束,紧接着再将其调用的then()
函数放入微任务队列中。
这时script
宏任务已经所有执行完了,开始准备清空微任务队列了。
第一个被执行的微任务队列是promise then
,也就是将执行其中的console.log("h")
语句。
执行完Promise then
微任务后,紧接着开始执行async1
的promise then
微任务。
这时候微任务队列已经清空了,即开始执行下一个宏任务。
最后来说将事件循环中的页面更新渲染,这也是Vue
中异步更新的逻辑所在。
每次当一次事件循环结束后,即一个宏任务执行完成后以及微任务队列被清空后,浏览器就会进行一次页面更新渲染。
一般咱们浏览器页面刷新频率是60fps,也就是意味着16.67ms要刷新一次,所以咱们也要尽可能保证一次事件循环控制在16.67ms以内,这也是咱们须要作代码性能优化的一个缘由。
接下来仍是经过一个案例来看一下。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Event Loop</title>
</head>
<body>
<div id="demo"></div>
<script src="./src/render1.js"></script>
<script src="./src/render2.js"></script>
</body>
</html>
复制代码
// render1
const demoEl = document.getElementById('demo');
console.log('a');
setTimeout(() => {
alert('渲染完成!')
console.log('b');
},0)
new Promise(resolve => {
console.log('c');
resolve()
}).then(() => {
console.log('d');
alert('开始渲染!')
})
console.log('e');
demoEl.innerText = 'Hello World!';
复制代码
// render2
console.log('f');
demoEl.innerText = 'Hi World!';
alert('第二次渲染!');
复制代码
根据HTML
的执行顺序,第一个被执行的JavaScript
代码是render1.js
,所以解释器将其推入宏任务队列,并开始执行。
第一个被执行的是console.log("a")
。
其次是setTimeout
,并将其回调加入宏任务队列中。
紧接着执行new Promise
。
一样,将其then()
推入微任务队列中去。
紧接着执行console.log("e")
。
最后,修改DOM节点的文本内容,可是这时候页面还不会更新渲染。
这时候script
宏任务也执行结束了。
紧接着,开始清空微任务队列,执行Promise then
。
这时候,alert
一个通知,而这个语句结束后,则微任务队列清空,表明第一个事件循环结束,即将要开始渲染页面了。
当点击关闭alert
后,事件循环结束,页面也开始渲染。
渲染结束后,就开始执行下一个宏任务,即setTimeout callback
。
紧接着执行console.log("b")
。
这时候宏任务队列已清空了,可是html
文件还没执行结束,所以进入render2.js
继续执行。
首先执行console.log('f')
。
紧接着,再次修改节点的文本信息,此时依旧不会更新页面渲染。
接着执行alert
语句,当关闭alert
通知后,该宏任务结束,微任务队列也为空,所以该事件循环也结束了,这时候就开始第二次页面更新。
但若是将全部JavaScript
代码使用内嵌方式的话,浏览器会先把两个script
丢到宏任务队列中去,所以执行的顺序也会不同,这里就不一一推导了。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Event Loop</title>
</head>
<body>
<div id="demo"></div>
<script> const demoEl = document.getElementById('demo'); console.log('a'); setTimeout(() => { alert('渲染完成!') console.log('b'); },0) new Promise(resolve => { console.log('c'); resolve() }).then(() => { console.log('d'); alert('开始渲染!') }) console.log('e'); demoEl.innerText = 'Hello World!'; </script>
<script> console.log('f'); demoEl.innerText = 'Hi World!'; alert('第二次渲染!'); </script>
</body>
</html>
复制代码
输出:a c e d "开始渲染!" f "第二次渲染!" "渲染完成!" b
复制代码