入门开发者必学篇之JS事件循环:为什么你的代码输出总翻车?

0 阅读5分钟

前言

   相信很多第一次接触 JS 异步的小伙伴很容易被搞懵,明明写在前面的定时器 setTimeout 为什么总是比后面的 Promise 回调执行晚?这与事件循环有着莫大的关系,我第一次也踩了不少坑,这篇文字带你五分钟学懂 JS 事件循环。

一、JS 线程

  让我们先来简单回顾一下 JS 线程的知识点

1.1 JS 默认单线程执行
1.2 代码中一定存在耗时任务和不耗时任务
1.3 为什么不设计成多线程模式?
  • 因为 JS 可以操作 DOM 结构,多线程执行可能会造成不安全的渲染

如果代码中存在耗时任务,将一个盒子设置为100×100,另一个设置盒子为 200×200,那就有可能碰上两条线程同时进行这一操作,那浏览器就不知道该执行哪个操作

  • 多线程需要锁,增加了难度,也增加了性能开销

为了避免这种情况,就需要锁,当两个线程撞上了,需要锁住其中一个,让另一个先执行,再进行解锁,而这无疑要增加不少难度

二、任务

2.1 任务分为耗时任务和不耗时任务
2.2 当代码碰上耗时任务(异步任务)时,会将耗时任务存放到队列中,先执行不耗时任务(同步任务)
2.3 耗时任务(异步任务)也被分为微任务和宏任务
  • 微任务:promise.then() [最常见] ,process.nextTick() , MutationObserver

  • 宏任务: setTimeout() , script , setInterval() , ajax【接口请求】, I/O【输入输出】, UI-rendering【页面渲染】

2.4 setTimeout() 补充
  • 所有的 setTimeout 共用同一份时间,先进宏任务队列的不一定先执行,而是根据计时 时间长短 来判断是否先执行

除了 setTimeout() ,其他的异步任务都满足队列先进先出

三、事件循环机制

  • 1. 先执行同步任务(script属于宏任务),这个过程中,遇到异步就存入对应的队列中

微任务存入到微任务的队列中, 宏任务存入对应的宏任务队列中

  • 2. 去微任务队列中查找微任务,并将微任务取出执行

满足队列先入先出,先存入的微任务先执行

  • 3. 有需要的情况下,就渲染页面

  • 4. 去宏任务队列中查找宏任务,并将宏任务取出执行(也是下一次循环的开始)

  接下来我们来看一段代码,理解运用一下上述规则

console.log(1);        //1

new Promise((resolve) => {
    console.log(2);    //2
    resolve()
}).then(() => {        //4
    console.log(3);
    setTimeout1(() => {    //5
        console.log(4);
    }, 0)
})

setTimeout2(() => {    //6
    console.log(5);
}, 1000)

console.log(6);   //3

// 输出顺序为 1  2  6  3  4  5

c代码从上往下,根据步骤来

  1. 碰到第一行 console.log(1) 同步代码执行输出1,第三行 new Promise(() =>{xxx}) 也是同步任务 执行内容 输出 2 ,碰到了 .then(()=>{xxx}) 微任务(异步任务) 存入微任务队列中,第十三行 setTimeout2(()=>{xxx}) 宏任务(异步任务) 存入宏任务队列中,第十七行 console.log(6); 同步任务 执行输出6

  2. 去微任务队列中查找微任务,发现有个 .then() 任务取出执行, console.log(3) 同步任务 执行输出3,紧接着发现 setTimeout1(()=>{xxx}) 宏任务(异步任务) ,存入宏任务队列中,微任务执行完毕,且没有其他微任务

  3. 无页面需要渲染

  4. 去宏任务队列中查找宏任务,发现有两个定时器,而又因为所有的 setTimeout 共用同一份时间,且定时器1耗时更短,所以先输出4,紧接着输出5,所以代码执行完毕

可以将宏任务先后执行理解为沙漏,当开始执行宏任务时,所有沙漏开始同时计时,耗时短的任务先漏完,开始执行,耗时长的后漏完再执行

image.png

画一个流程图更方便理解

graph LR
    A[主线程开始执行] --> B[执行同步代码]
    B --> C{异步代码?}
    C -->|微任务| D[存入微任务队列]
    C -->|宏任务| E[存入宏任务队列]
    D --> F{后续同步代码是否执行完?}
    E --> F
    F -->|否| B
    F -->|是| G[清空并执行所有微任务]
    G --> H[执行一个宏任务]

  这是一个完整的事件流程,但既然我们说的是事件循环,那循环体现在哪呢?

console.log(1);     //1

setTimeout1(() => {      //3
    console.log(2); 
    new Promise((resolve) => {  //4
        console.log(3); 
        resolve()
    }).then(() => {    //5
        console.log(4);
    })
    setTimeout2(() => {    //6
        console.log(5);
    }, 0)
}, 1000)

console.log(6);   //2

// 1 6 2 3 4 5
  1. 我们依旧按照事件循环的流程来,第一步 先执行同步任务,这个过程中,遇到异步就存入对应的队列中

console.log(1) 同步 输出 1 , setTimeout(()=>{xxx}) 异步存入宏队列 console.log(6) 同步 输出6

  1. 去微任务队列中查找微任务,并将微任务取出执行

无微任务

  1. 有需要的情况下,就渲染页面

无页面渲染

  1. 去宏任务队列中查找宏任务,并将宏任务取出执行

setTimeout1(()=>{xxx}) 找到setTimeout1执行,又开始按照先同步执行,再异步存入,再微任务宏任务的流程执行

  这是不是就相当于开启了第二世,依旧按照事件逻辑开始同步执行,同步执行,再异步存入,再微任务宏任务的流程执行,而下一个宏任务也依旧按照这套逻辑执行,这就是事件循环,而第一个宏任务的执行也代表第二世循环的开始,而第一世循环的开始则是 script 标签

image.png
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
</head>

<body>
    <script>   //第一世开始
        let a = 1
        console.log(a);
        new Promise((resolve) => {
            a = 2
            console.log(a);
            resolve()
        }).then(() => {
            a = 3
            console.log(a);
        })

        setTimeout(() => {   //第二世开始
            a = 4
            console.log(a);
        },1000)
    </script>

    <h2>hello</h2>   <!-- 先打印 1 2 3 再渲染 hello 最后 输出4 -->
</body>

</html>

所以整体逻辑为

graph LR
    A[主线程开始执行] --> B[执行同步代码]
    B --> C{异步代码?}
    C -->|微任务| D[存入微任务队列]
    C -->|宏任务| E[存入宏任务队列]
    D --> F{后续同步代码是否执行完?}
    E --> F
    F -->|否| B
    F -->|是| G[清空并执行所有微任务]
    G --> H[渲染页面]
    H --> I[执行宏任务]
    I --> B
补充 js引擎线程和渲染线程是互斥的,同一时间只执行其中一个线程,其他线程可同时进行

四、async/await

  • 1. 函数前面加 async 等同于函数内部 return 了一个 promise 对象
  • 2. await xxx(相当于.then() 在它后面的代码一定要等xxx执行完才能执行)
    • xxx 看成同步来执行
    • 将它的后续代码挤入微任务序列
console.log('script start');        //1  同步

async function async1() {
    await async2()
    console.log('async1 end');        //5  挤入微任务队列中 1号
}
async function async2() {
    console.log('async2 end');       //2  同步
}

async1()

setTimeout(() => {
    console.log('setTimeout');         //8  宏任务队列
}, 0)

new Promise((resolve, reject) => {
    console.log('promise');           //3 同步
    resolve()
})
    .then(() => {
        console.log('then1');           //6  微任务队列 2号
    })
    .then(() => {
        console.log('then2');           //7 微任务队列  3号
    });

console.log('script end');           //4  同步
image.png