【浅谈JS执行过程】

576 阅读5分钟

引言

在我们日常开发中,有很多时候会遇到下面这些情况

1 咦,怎么先执行这个呢?
2 怎么会 undefined 的呢? 
3 怎么定时器不准时生效呢? 
4 异步代码怎么改为同步代码呢?
5 两个异步代码互相依赖要怎么执行呢?  

JavaScript 对前端而言,是多么熟悉的语言,但却又很陌生
(学无止境,你永远也不能说你精通JavaScript,除非你是类尤大的框架发明者)
那你对JS的执行顺序了解吗?

以上的问题多数是自己对JavaScript的执行过程不熟悉,下面来浅谈一下JavaScript执行过程

一道面试题

async function async1() {
    console.log('async1 start');
    await async2();
    console.log('async1 end');
}

async function async2() {
    console.log('async2');
}

console.log('script start');

setTimeout(function() {
    console.log('setTimeout');
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1');
    resolve();
}).then(function() {
    console.log('promise2');
});

console.log('script end');

首先,以上的面试题包括了我们开发中经常遇到的逻辑,
我们先不要去纠结答案是什么,在揭晓答案之前,我们先厘清几个概念。

队列

首先我们要引入一个概念:队列。
队列的特点:先进先出

单线程的JS

我们都知道,js是单线程的,基于事件循环,所谓单线程也就是说,我这一刻,能且只能执行一个任务,当我执行完当前任务,才能继续去执行下一个任务。

js的单线程好比是一个队列,队列是先进先出的,排在前面的先执行,排在后面的后执行。

function fn(){
    console.log('start');
    setTimeout(()=>{
        console.log('setTimeout');
    },0);
    console.log('end');
}
fn() 
// 输出 start end setTimeout

然而单线程不应该是自上而下按照顺序执行的吗?为什么不是输出 start setTimeout end 呢?

JS中的同步代码和异步代码

同步代码:let a = 1,console.log('ok'),for in, forEach……

异步代码:seTimeout,setInterval,dom事件,ajax,Promise.,await,process.nextTick等函数

Promise 和 async 立即执行

我们知道Promise中的异步体现在then和catch中,所以写在Promise中的代码是被当做同步任务立即执行的;

当执行resolve()或者reject()才会执行相应的回调函数。

而在async/await中,在出现await出现之前,其中的代码也是立即执行的。

await时候发生了什么呢?

从字面意思上看await就是等待,await 等待的是一个表达式,这个表达式的返回值可以是一个 promise 对象也可以是其他值。

await 其实是一个让出线程的标志,await 后面的表达式会先执行一遍,将await后面的代码加入到 microtask 中,

然后就会跳出整个 async 函数来执行后面的代码。所以 await 后面的代码相当于是 Promise.then()

JS事件循环

因为单线程,所以代码自上而下执行,所有代码被放到执行栈中执行;

除了主线程这个队列,js还有宏任务队列(macrotask queue)和微任务队列(microtask queue)

1 执行全局Script同步代码,这些同步代码有一些是同步语句,有一些是异步语句(比如setTimeout等);

2 遇到异步函数将回调函数添加到任务队列里面;

3 当执行栈任务执行完之后,执行栈会清空;

4 从微队列中取出位于队首的回调任务,放入执行栈中执行,执行完后微队列长度减15 继续循环取出位于微队列的任务,放入执行栈中执行,以此类推,直到直到把微队列任务执行完毕。

注意,如果在执行微任务的过程中,又产生了微任务,那么会加入到微队列的末尾,也会在这个周期被调用执行;

6 微队列中的所有微任务都执行完毕,此时微队列为空队列,执行栈也为空;

7 取出宏队列中的任务,放入执行栈中执行;

8 执行完毕后,执行栈为空;

9 重复第4-8个步骤;

如此往复,称为事件循环;

回到面试题中

1 首先,事件循环从宏任务队列开始,初始化时,宏任务队列中,只有一个script任务,将它放到执行栈中

2 然后定义了两个函数async1(),saync2(),遇到了console语句,直接打印出 script start

3 任务继续,遇到了 setTimeout 函数,因为执行时间为0,会立即被放到宏任务队列中

4 任务继续,执行async1()函数,进入了 async 内 await 前,会立即打印出 async1 start。遇到了await,会将await后面的表达式执行一遍,所以会立即执行async2()内部的同步代码,打印出 async2,然后 aync 内 await 之后的代码 console.log('async1 end') 会被加入到 microtask 队列中,接着 await 会让出线程,跳出async1函数,执行后面的代码

5 任务继续,遇到了 Promise,会直接执行内部函数,打印出 promise1,然后 resolve(),then( console.log('promise2') ) 的代码会加入到 microtask 队列中

6 任务继续,遇到了最后一行,打印出 script end,至此,执行栈里的任务全部执行完

7 因为执行栈没有任务,会查看 microtask,发现此时 microtask 中存在 console.log('async1 end') 和 console.log('promise2') 两个任务,按顺序执行

8 第二轮事件循环,回去轮询任务队列,也是从宏任务开始,发现宏任务中有 setTimeout 函数,将它放入执行栈执行,输出 setTimeout

每执行完一个宏任务,就会去检查是否存在 microtask,如果有,则执行 microtask 中任务,直至microtask为空

async function async1() {
    console.log('async1 start'); // 2
    await async2();
    console.log('async1 end'); // 6
}

async function async2() {
    console.log('async2'); // 3
}

console.log('script start'); // 1

setTimeout(function() {
    console.log('setTimeout'); // 8
}, 0)

async1();

new Promise(function(resolve) {
    console.log('promise1'); // 4
    resolve();
}).then(function() {
    console.log('promise2'); // 7
});

console.log('script end'); // 5

// 答案:
/*
script start
async1 start
async2
promise1
script end
async1 end
promise2
setTimeout
*/

结语

只要懂得原理,按着流程一步步来,JavaScript也是很友好的