JS学习笔记-事件循环(event loop)原理

1,521 阅读6分钟

小试牛刀

 <script>
        document.addEventListener('click', function () {
            Promise.resolve().then(() => console.log(1));
            console.log(2);
        })

        document.addEventListener('click', function () {
            Promise.resolve().then(() => console.log(3));
            console.log(4);
        })
    </script>
打印顺序是 2 1 4 3
问:为什么打印顺序不是 1 2 3 4?

1.事件循环是什么?为什么有事件循环?

一句话来说就是指浏览器或Node的一种解决JS单线程运行时不会阻塞的一种机制。

我们都知道 JavaScript 引擎是单线程,也就是说每次只能执行一项任务,其他任务都得按照顺序排队等待被执行,只有当前的任务执行完成之后才会往下执行下一个任务,但是一些高耗时操作就带来了进程阻塞问题。为了协调事件、用户交互、脚本、UI 渲染和网络处理等行为,用户引擎必须使用 event loops

1.1浏览器线程:

(1)GUI 渲染线程:

负责渲染页面,解析 HTML,CSS 构成 DOM 树等,当页面重绘或者由于某种操作引起回流都会调起该线程。和 JS 引擎线程是互斥的,当 JS 引擎线程在工作的时候,GUI 渲染线程会被挂起,GUI 更新被放入在 JS 任务队列中,等待 JS 引擎线程空闲的时候继续执行。

(2)JS 引擎线程:

单线程工作,负责解析运行 JavaScript 脚本。和 GUI 渲染线程互斥,JS 运行耗时过长就会导致页面阻塞。

(3)事件触发线程:

当事件符合触发条件被触发时,该线程会把对应的事件回调函数添加到任务队列的队尾,等待 JS 引擎处理。

(4)定时器触发线程:

浏览器定时计数器并不是由 JS 引擎计数的,阻塞会导致计时不准确。开启定时器触发线程来计时并触发计时,计时完成后会被添加到任务队列中,等待 JS 引擎处理。

(5)http 请求线程:

http 请求的时候会开启一条请求线程,请求完成有结果了之后,将请求的回调函数添加到任务队列中,等待 JS 引擎处理。

1.2事件循环(Event Loop):

具体来说,就是执行栈中的同步任务都执行完毕,栈内被清空了,就代表主线程空闲了,这个时候就会去任务队列中按照顺序读取一个任务放入到栈中执行。每次栈内被清空,都会去读取任务队列有没有任务,有就读取执行,一直循环读取-执行的操作,就形成了事件循环。

(1)执行栈:JS执行栈( call-stack )是一种后进先出的数据结构。所有的任务都会被放到执行栈等待主线程执行。当函数被调用时,会被添加到栈中的顶部,执行完成之后就从栈顶部移出该函数,直到栈内被清空。
(2)同步任务与异步任务:JavaScript 单线程中的任务分为同步任务和异步任务。同步任务会在调用栈中按照顺序排队等待主线程执行,异步任务则会在异步有了结果后将注册的回调函数添加到任务队列(消息队列)中等待主线程空闲的时候,也就是栈内被清空的时候,被读取到栈中等待主线程执行。任务队列是先进先出的数据结构。

2.任务队列

事件循坏机制.png

任务队列:一块内存空间,用于存放执行时机到达的异步函数。当JS引擎空闲(执行栈没有可执行的上下文),它会从队列里拿出第一个函数执行。分为微队列和宏队列。

微队列:存放微任务对应的函数;

宏队列:存放宏任务对应的队列;

3.微任务(microTask)是什么?宏任务(macroTask)又是什么?

微任务:Process.nextTick(Node独有)、Object.observe(废弃)、Promise(then/catch/finally)、queueMicrotask、MutationObserver(具体使用方式查看MDN)(JS自身发起);
宏任务:script全部代码、setTimeout、setInterval、setImmediate(浏览器暂时不支持,只有IE10支持,具体可见MDN)、I/O、UI Rendering。(宿主发起)

4.script 整体代码是一个宏任务如何理解?

#### 实际上如果同时存在两个 script 代码块,会首先在执行第一个 script 代码块中的同步代码,如果这个过程中创建了微任务并进入了微任务队列,第一个 script 同步代码执行完之后,会首先去清空微任务队列,再去开启第二个 script 代码块的执行。所以这里应该就可以理解 script(整体代码块)为什么会是宏任务。

(1)代码通过script脚本引入

 <script src="./script1.js"></script>
 <script src="./script2.js"></script> 

script1.js

console.log('代码块1', '同步代码1');
setTimeout(() => {
  console.log('代码块1的setTimeout');  
}, 0);
Promise.resolve('代码块1的promise').then((data)=>{
    console.log(data);
})
console.log('代码块1','同步代码2');

script2.js

console.log('代码块2','同步代码1' );
setTimeout(() => {
  console.log('代码块2的setTimeout');  
}, 0);
Promise.resolve('代码块2的promise').then((data)=>{
    console.log(data);
})
console.log('代码块2', '同步代码2');
执行顺序

代码块执行顺序.png

小试牛刀
<script>
        console.log('script start')

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

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

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

        console.log('script end')
    </script>
执行顺序

屏幕快照 2021-06-06 下午4.45.57.png

(2)代码整体作为setTimeout回调函数内容放入

 <script>
        setTimeout(() => {
            console.log('代码块1', '同步代码1');
            setTimeout(() => {
                console.log('代码块1的setTimeout');
            }, 0);
            Promise.resolve('代码块1的promise').then((data) => {
                console.log(data);
            })
            console.log('代码块1', '同步代码2');
        }, 0);
        setTimeout(() => {
            console.log('代码块2', '同步代码1');
            setTimeout(() => {
                console.log('代码块2的setTimeout');
            }, 0);
            Promise.resolve('代码块2的promise').then((data) => {
                console.log(data);
            })
            console.log('代码块2', '同步代码2');
        }, 0);
        setTimeout(() => {
            console.log(1);
            setTimeout(() => {
                console.log(2);
            }, 0);
            setTimeout(() => {

            }, 0);
            Promise.resolve(3).then((data) => {
                console.log(data);
            })
            console.log(4);
        }, 0);
        
    </script>
执行顺序

屏幕快照 2021-06-06 下午4.54.24.png

5.微队列与宏队列如何执行(事件循环机制)

在事件循环中,每进行一次循环操作称为tick,每一次 tick 的任务处理模型是比较复杂的,其关键的步骤可以总结如下:

(1)在此次 tick 中选择最先进入队列的任务( oldest task ),如果有则执行(一次);
(2)检查是否存在 Microtasks ,如果存在则不停地执行,直至清空Microtask Queue;

图片1.png ##### 总的结论就是,执行宏任务,然后执行该宏任务产生的微任务,若微任务在执行过程中产生了新的微任务,则继续执行微任务,微任务执行完毕后,再回到宏任务中进行下一轮循环。

(3)更新 render;

(4)主线程重复执行上述步骤;

图片2.png

小试牛刀
 <script>
        console.log(1);
        Promise.resolve(2).then(data => {
            console.log(data);
        })
        const p = new Promise((resolve, reject) => {
            console.log(3);
            resolve(4);
        })
        setTimeout(() => {
            console.log(5);
            p.then(data => {
                console.log(data)
            });
        }, 0);

        function one(data) {
            return new Promise((resolve, reject) => {
                console.log(6);
                console.log(data);
                resolve(); // 不推向已决,then方法不会执行
            });
        }

        async function two(data) {
            console.log(data);
            one(7).then((data1) => {
                const result = data1;
                console.log(8);
            })
        }
        setTimeout(() => {
            console.log(9);
            two(10);
            console.log(11);
        }, 0);
    </script>

屏幕快照 2021-06-06 下午5.10.23.png

6 总结:

由此我们了解事件循环的机制,同时了解了任务队列、JS主线程、异步操作之间的相互协作;

同时认识了两种任务队列:macrotask queue、microtask queue,它们由不同的标准制定,microtask queue对应ECMAScript的promise属性(ES6)和 DOM3的MutationObserver,

文中说明了两者在事件循环中的运行情况及区别;在今后的异步操作中,通过灵活运用不同的任务队列,提升用户交互性能,给出更加的响应和视觉体验;同时,通过JS的事件循环机制,可以更清楚JS代码的执行流,从而更好的控制代码,更有效、更好的为业务服务。