前言
想要了解一门语言,最好的办法就是了解它的运行机制。掌握了运行机制,能够让我们在开发中少走许多弯路,写出高质量的代码。本文从一道题浅谈
JavaScript
运行机制,给刚刚接触JavaScript
的小白一个初步的了解,为将来打好基础。
//请写出输出内容
async function async1() {
console.log('2 async1 start')
await async2()
console.log('6 async1 end')
}
async function async2() {
console.log('3 async2')
}
console.log('1 script start')
setTimeout(function() {
console.log('8 setTimeout')
}, 0)
async1()
new Promise(function(resolve) {
console.log('4 promise1')
resolve()
}).then(function() {
console.log('7 promise2')
})
console.log('5 script end')
在`Chrome 80`和`node v12`中,正确输出是:
1 script start
2 async1 start
3 async2
4 promise1
5 script end
6 async1 end
7 promise2
undefined
8 setTimeout
分析:
- console.log('1 script start')是同步任务,直接打印
1 script start
- setTimeout 是异步任务,且是宏任务源,放到宏任务队列中,等待下次 Event Loop 才会执行;
- 执行 async1(),await 之前的代码立即执行,打印
2 async1 start
,await 后面的表达式执行一遍,打印3 async2
,await 后面的代码是 console.log('6 async1 end') 是微任务源,放到微任务队列中,接着跳出 async1 函数来执行后面的代码。 - new Promise 是同步任务,直接执行,打印
4 promise1
- Promise.then 是微任务,将 console.log('7 promise2')放到微任务队列
- console.log('5 script end')是同步任务,直接执行,打印
'5 script end
7.此时主线程任务执行完毕,检查微任务队列中,此时,微任务中, Promise 队列有的两个任务 console.log('6 async1 end') 和 console.log('7 promise2'),因此按先后顺序输出6 async1 end
,7 promise2
。 - 微任务执行完毕,第一次循环结束;
- 第二轮循环依旧从宏任务队列开始。此时宏任务中只有一个 setTimeout,取出直接输出
8 setTimeout
,至此整个流程结束。
这道题主要考察的是事件循环中函数执行顺序的问题,其中包括 async ,await,setTimeout,Promise 函数。下面来说一下本题中涉及到的知识点。
Promise 和 async 中的立即执行
- Promise 中的异步体现在 then 和 catch 中
- Promise 中的代码是被当做同步任务立即执行的。
- await 出现之前,代码也是立即执行的
- await 是一个让出线程的标志。await 后面的表达式会先执行一遍,将 await 后面的代码加入到 microtask 中,然后就会跳出整个 async 函数来执行后面的代码。
因此
await async2()
可以改成以下代码:
return Promise.resolve(async2()).then(res => {
console.log('6 async1 end')
})
进程与线程
- 进程是 CPU 资源分配的基本单位,进程是由一个或多个线程组成
- 线程是 CPU 调度和分配的基本单位,同个进程之中的多个线程之间是共享该进程资源的
多线程的浏览器内核
GUI 渲染线程
- 渲染页面、解析 DOM,页面重绘时会调起该线程
- 和 JS 引擎线程互斥,当 JS 引擎线程在工作时,GUI 渲染线程挂起,GUI 更新被放 入在 JS 任务队列中,等待 JS 引擎线程空闲的时候继续执行。
JS 引擎线程
- 单线程工作、解析和运行 js 脚本
事件触发线程
- 当一个事件被触发时,该线程会把事件添加到待处理队列的队尾,等待 JS 引擎的处理。
定时器触发线程
- 浏览器定时计数器并不是由 JS 引擎计数的,阻塞会导致计时不准确。
- 开启定时器触发线程来计时并触发计时,计时完成后会被添加到任务队列中,等待 JS 引擎处理。
http 请求线程
- http 请求的时候会开启一条请求线程。
- 请求完成有结果了之后,将请求的回调函数添加到任务队列中,等待 JS 引擎处理。
单线程的 JS 引擎
JS 单线程原因:如果多线程,假如有 2 个线程,同时对一个 dom 操作,一个删除,另一个编辑,浏览器不能确定 dom 状态,无法下达 2 个矛盾的命令,所以,js 设计之初就是单线程。
好处:简单,没有线程切换维护开销,省内存。
任务队列和事件循环
- JS 分为同步任务和异步任务;
- 同步任务都在主线程上执行,形成一个执行栈;
- 主线程之外,事件触发线程管理着一个任务队列,当异步任务有了运行结果后,将注册的回调函数放在任务队列之中。
- 一旦执行栈中的所有同步任务执行完毕(此时 JS 引擎空闲),系统就会读取任务队列,将可运行的异步任务添加到可执行栈中,开始执行。
- 一直循环读取任务队列,并执行的操作,就形成了事件循环
根据规范,事件循环是通过任务队列的机制来进行协调的。一个 Event Loop 中,可以有一个或者多个任务队列(task queue),一个任务队列便是一系列有序任务(task)的集合;每个任务都有一个任务源(task source),源自同一个任务源的 task 必须放到同一个任务队列,从不同源来的则被添加到不同队列。 setTimeout/Promise 等 API 便是任务源,而进入任务队列的是他们指定的具体执行任务。
定时器
- 定时器会开启一条定时器触发线程来触发计时
- 定时器会在等待了指定的时间后将事件放入到任务队列中等待读取到主线程执行。
- 定时器指定的延时毫秒数其实并不准确,因为定时器只是在到了指定的时间时将事件放入到任务队列中,必须要等到同步的任务和现有的任务队列中的事件全部执行完成之后,才会去读取定时器的事件到主线程执行,中间可能会存在耗时比较久的任务,那么就不可能保证在指定的时间执行。
宏任务
(macro)task(又称之为宏任务),可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)。
浏览器为了能够使得 JS 内部(macro)task 与 DOM 任务能够有序的执行,会在一个(macro)task 执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:
(macro)task->渲染->(macro)task->...
(macro)task 主要包含:script(整体代码)、setTimeout、setInterval、I/O、UI 交互事件、postMessage、MessageChannel、setImmediate(Node.js 环境)
微任务
microtask(又称为微任务),可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前 task 任务后,下一个 task 之前,在渲染之前。
所以它的响应速度相比 setTimeout(setTimeout 是 task)会更快,因为无需等渲染。也就是说,在某一个 macrotask 执行完后,就会将在它执行期间产生的所有 microtask 都执行完毕(在渲染前)。
microtask 主要包含:Promise.then、MutaionObserver、process.nextTick(Node.js 环境)
运行机制
在事件循环中,每进行一次循环操作称为 tick,每一次 tick 的任务处理模型是比较复杂的,但关键步骤如下:
- 执行一个宏任务(栈中没有就从事件队列中获取)
- 执行过程中如果遇到微任务,就将它添加到微任务的任务队列中
- 宏任务执行完毕后,立即执行当前微任务队列中的所有微任务(依次执行)
- 当前宏任务执行完毕,开始检查渲染,然后 GUI 线程接管渲染
- 渲染完毕后,JS 线程继续接管,开始下一个宏任务(从事件队列中获取)
流程图如下:
代码示例
第一个例子
console.log(1)
setTimeout(function(){
console.log(2)
},0)
console.log(3)
执行结果: 1 3 2
分析:
- console.log(1)是同步任务,直接打印 1;
- setTimeout 是异步任务,且是宏函数,放到宏函数队列中,等待下次 Event Loop 才会执行;
- console.log(3)是同步任务,直接打印 3;
- 主线程执行完毕,没有微任务,那么执行第二个宏任务 setTimeout,打印 2;
- 结果:1,3,2
第二个例子
setTimeout(function(){
console.log(1)
});
new Promise(function(resolve){
console.log(2);
for(var i = 0; i < 10000; i++){
i == 9999 && resolve();
}
}).then(function(){
console.log(3)
});
console.log(4);
执行结果:2 4 3 1
分析:
- setTimeout 是异步,且是宏函数,放到宏函数队列中;
- new Promise 是同步任务,直接执行,打印 2,并执行 for 循环;
- promise.then 是微任务,放到微任务队列中;
- console.log(4)同步任务,直接执行,打印 4;
- 此时主线程任务执行完毕,检查微任务队列中,有 promise.then,执行微任务,打印 3;
- 微任务执行完毕,第一次循环结束;从宏任务队列中取出第一个宏任务到主线程执行,打印 1;
- 结果:2,4,3,1
第三个例子
console.log(1);
setTimeout(function() {
console.log(2);
}, 0);
Promise.resolve().then(function() {
console.log(3);
}).then(function() {
console.log('4.我是新增的微任务');
});
console.log(5);
执行结果:1 5 3 4.我是新增的微任务 2
分析:
- console.log(1)是同步任务,直接执行,打印 1;
- setTimeout 是异步,且是宏函数,放到宏函数队列中;
- Promise.resolve().then 是微任务,放到微任务队列中;
- console.log(5)是同步任务,直接执行,打印 5;
- 此时主线程任务执行完毕,检查微任务队列中,有 Promise.resolve().then,执行微任务,打印 3;
- 此时发现第二个.then 任务,属于微任务,添加到微任务队列,并执行,打印 4.我是新增的微任务;
- 这里强调一下,微任务执行过程中,发现新的微任务,会把这个新的微任务添加到队列中,微任务队列依次执行完毕后,才会执行下一个循环;
- 微任务执行完毕,第一次循环结束;取出宏任务队列中的第一个宏任务 setTimeout 到主线程执行,打印 2;
- 结果:1,5,3,4.我是新增的微任务,2
第四个例子
function add(x, y) {
console.log(1)
setTimeout(function() { // timer1
console.log(2)
}, 1000)
}
add();
setTimeout(function() { // timer2
console.log(3)
})
new Promise(function(resolve) {
console.log(4)
setTimeout(function() { // timer3
console.log(5)
}, 100)
for(var i = 0; i < 100; i++) {
i == 99 && resolve()
}
}).then(function() {
setTimeout(function() { // timer4
console.log(6)
}, 0)
console.log(7)
})
console.log(8)
执行结果:1 4 8 7 3 6 5 2
分析:
- add()是同步任务,直接执行,打印 1;
- add()里面的 setTimeout 是异步任务且宏函数,记做 timer1 放到宏函数队列;
- add()下面的 setTimeout 是异步任务且宏函数,记做 timer2 放到宏函数队列;
- new Promise 是同步任务,直接执行,打印 4;
- Promise 里面的 setTimeout 是异步任务且宏函数,记做 timer3 放到宏函数队列;
- Promise 里面的 for 循环,同步任务,执行代码;
- Promise.then 是微任务,放到微任务队列;
- console.log(8)是同步任务,直接执行,打印 8;
- 此时主线程任务执行完毕,检查微任务队列中,有 Promise.then,执行微任务,发现有 setTimeout 是异步任务且宏函数,记做 timer4 放到宏函数队列;
- 微任务队列中的 console.log(7)是同步任务,直接执行,打印 7;
- 微任务执行完毕,第一次循环结束;
- 检查宏任务 Event Table,里面有 timer1、timer2、timer3、timer4,四个定时器宏任务,按照定时器延迟时间得到可以执行的顺序,即 Event Queue:timer2、timer4、timer3、timer1,取出排在第一个的 timer2;
- 取出 timer2 执行,console.log(3)同步任务,直接执行,打印 3;
- 没有微任务,第二次 Event Loop 结束; 15.取出 timer4 执行,console.log(6)同步任务,直接执行,打印 6;
- 没有微任务,第三次 Event Loop 结束;
- 取出 timer3 执行,console.log(5)同步任务,直接执行,打印 5;
- 没有微任务,第四次 Event Loop 结束;
- 取出 timer1 执行,console.log(2)同步任务,直接执行,打印 2;
- 没有微任务,也没有宏任务,第五次 Event Loop 结束;
- 结果:1,4,8,7,3,6,5,2
第五个例子
setTimeout(function() { // timer1
console.log(1);
setTimeout(function() { // timer3
console.log(2);
})
}, 0);
setTimeout(function() { // timer2
console.log(3);
}, 0);
执行结果:1 3 2
分析:
- 第一个 setTimeout 是异步任务且宏函数,记做 timer1 放到宏函数队列;
- 第三个 setTimeout 是异步任务且宏函数,记做 timer2 放到宏函数队列;
- 没有微任务,第一次 Event Loop 结束;
- 取出 timer1,console.log(1)同步任务,直接执行,打印 1;
- timer1 里面的 setTimeout 是异步任务且宏函数,记做 timer3 放到宏函数队列; 6. 没有微任务,第二次 Event Loop 结束;
- 取出 timer2,console.log(3)同步任务,直接执行,打印 3;
- 没有微任务,第三次 Event Loop 结束;
- 取出 timer3,console.log(2)同步任务,直接执行,打印 2;
- 没有微任务,也没有宏任务,第四次 Event Loop 结束;
- 结果:1,3,2