浅谈js的EventLoop

JavaScript的单线程语言

单线程和多线程最简单的区别: 单线程同一个时间只能做一件事,而多线程同一个能做多件事.

JavaScript之所以设计为单线程语言,主要是因为它作为浏览器脚本语言,主要的用途就是与用户互动,操作Dom节点。

而在这个情景设定下,假设JavaScript同时有两个进程,一个是操作A节点,一个是删除A节点,这时候浏览器就不知道要以哪个线程为准了。

因此为了避免这类型的问题,JavaScript从一开始就属于单线程语言。

而事件循环Event Loop,这是目前浏览器和NodeJS处理JavaScript代码的一种机制,而这种机制存在的背后,就有因为JavaScript是一门单线程的语言。

调用栈 Call Stack

JavaScript运行的时候, 主线程会形成一个栈,这个栈主要是解释器用来最终函数执行流的一种机制.通常这个栈被称为调用栈Call Stack,或者执行栈(Ececution Context Stack)

调用栈,顾名思义是具有LIFO(后进先出, Last in First Out)的结构.调用栈内存放的是代码执行期间的所有执行上下文.

执行上下文: 就是当前 JavaScript 代码被解析和执行时所在环境的抽象概念, JavaScript 中运行任何的代码都是在执行上下文中运行。

  • 每调用一个函数,解释器就会把该函数的执行上下文添加到调用栈并开始执行;
  • 正在调用栈中执行的函数如果还调用了其他函数,那么新函数也会被添加到调用栈,并且立即执行;
  • 当前函数执行完毕后,解释器会将其执行上下文清除调用栈,继续执行剩余执行上下文中的剩余代码;
  • 但分配的调用栈空间被占满,会引发"堆栈溢出"的报错

现在用给小案例来演示一下调用栈

javascript 复制代码
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将所有执行任务分为了同步任务和异步任务。

同步任务和异步任务

其实我们每个任务都是在做两件事情,就是发起调用和得到结果。

而同步任务和异步任务最主要的差别就是,同步任务发起调用后,很快就可以得到结果,而异步任务是无法立即得到结果,比如请求接口,每个接口都会有一定的响应时间,根据网速、服务器等等因素决定,再比如定时器,它需要固定时间后才会返回结果。

因此,对于同步任务和异步任务的执行机制也不同。

同步任务的执行,其实就是跟前面那个案例一样,按照代码顺序和调用顺序,支持进入调用栈中并执行,执行结束后就移除调用栈。

而异步任务的执行,首先它依旧会进入调用栈中,然后发起调用,然后解释器会将其响应回调任务放入一个任务队列,紧接着调用栈会将这个任务移除。当主线程清空后,即所有同步任务结束后,解释器会读取任务队列,并依次将已完成的异步任务加入调用栈中并执行。

这里有个重点,就是异步任务不是直接进入任务队列的。

这里举一个简单的例子。

ini 复制代码
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),因此异步任务会按照进入队列的顺序依次执行。

但在一些场景下,如果只按照进入队列的顺序依次执行的话,也会出问题。比如队列先进入一个一小时的定时器,接着再进入一个请求接口函数,而如果根据进入队列的顺序执行的话,请求接口函数可能需要一个小时后才会响应数据。

因此浏览器就会将异步任务分为宏任务和微任务,然后按照事件循环的机制去执行,因此不同的任务会有不同的执行优先级,具体会在事件循环讲到。

宏任务

常见的宏任务

  • script(整体代码)

  • setTimeout setInterval

  • I/O UI交互事件

  • requestAnimationFrame

  • setImmediate(Node.js 环境)

  • UI交互事件

微任务

常见微任务

  • MutationObserver
  • process.nextTick(node环境)
  • Promise.then catch finally

事件循环 Event Loop

其实宏任务队列和微任务队列的执行,就是事件循环的一部分了,所以放在这里一起说。

事件循环的具体流程如下:

  1. 从宏任务队列中,按照入队顺序,找到第一个执行的宏任务,放入调用栈,开始执行;
  2. 执行完该宏任务下所有同步任务后,即调用栈清空后,该宏任务被推出宏任务队列,然后微任务队列开始按照入队顺序,依次执行其中的微任务,直至微任务队列清空为止;
  3. 当微任务队列清空后,一个事件循环结束;
  4. 接着从宏任务队列中,找到下一个执行的宏任务,开始第二个事件循环,直至宏任务队列清空为止。

这里有几个重点:

  • 当我们第一次执行的时候,解释器会将整体代码script放入宏任务队列中,因此事件循环是从第一个宏任务开始的;
  • 如果在执行微任务的过程中,产生新的微任务添加到微任务队列中,也需要一起清空;微任务队列没清空之前,是不会执行下一个宏任务的。
javascript 复制代码
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。
  • 执行结束后,它也被移除宏任务队列和调用栈。
  • 这时候微任务队列里面没有任务,因此第二个事件循环也结束了。宏任务也被清空了,因此这段代码已经执行结束了

接下来看下面的例子

javascript 复制代码
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 
*/

这里需要先理解async/awaitasync/await 在底层转换成了 promisethen 回调函数。 也就是说,这是 promise 的语法糖。 每次我们使用 await, 解释器都创建一个 promise 对象,然后把剩下的 async 函数中的操作放到 then 回调函数中。 async/await 的实现,离不开 Promise。从字面意思来理解,async 是"异步"的简写,而 awaitasync wait 的简写可以认为是等待异步方法执行完成。

javascript 复制代码
async function f() {
  await p
  console.log('ok')
}

简化理解为:

javascript 复制代码
function f() {
  return RESOLVE(p).then(() => {
    console.log('ok')
  })
}
  • 首先,开始执行前,将整体代码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微任务。

  • 这时候微任务队列已经清空了,即开始执行下一个宏任务。

最后输出的结果为:d a c g i b h f e

接下来再看一个例子

javascript 复制代码
Promise.resolve()
  .then(() => { // 微1-1
    console.log('promise1');
    return new Promise((resolve, reject) => {
        setTimeout(() => { // 宏3 
          console.log('timer2') 
          resolve() // TODO 因为是在异步确认Promise的状态, 
                    // TODO 所以当状态还没有确定之前下面的then中的回调都不会注册当异步任务中
        }, 0)
    })
      .then(async () => { // 微3-1
        // TODO 先看着
        // await 表达式简单理解为'异步等待'获取结果, 
        // await是为了优化'Promise'的then链写法 直接帮你去异步等待拿到结果
        // 可以理解为 await foo() => Promise.then((res) => res) 
        // await表达式后面的代码可以看成 Promise.then((res) => res).then(() => {return new Error('error1')})

        console.log( await foo()) // TODO 注: 对于await解释下面第四天题的分析很透彻了,不知道可以往下看
        // TODO 再看着
        // await 表达式需要异步去等待获取,await表达式下面的代码相当于挂在到异步队列微任务中
        // 但前提是需要异步等待获取结果之后
        return new Error('error1') // 微4-1
      }) 
      .then((ret) => {  // 微4-2
        setTimeout(() => { // 宏5
          console.log(ret);
          Promise.resolve()
          .then(() => { // 微5-1
            return new Error('error!!!')
          })
          .then(res => { // 微5-2
            console.log("then: ", res)
          })
          .catch(err => {
            console.log("catch: ", err)
          })
        }, 1 * 3000)
      }, err => {
        console.log(err);
      })
      .finally((res) => { // 微3-2 前面状态不确定,但是finally不管状态如何都执行且不接受任务参数
        console.log(res);
        throw new Error('error2')
      })
      .then((res) => { // 微3-3
        console.log(res);
      }, err => {
        console.log(err);
      })
  })
  .then(() => { // 微3-4
    console.log('promise2');
  })

async function foo() {
  setTimeout(() => { // 宏4
    console.log('async1');
  }, 2 * 1000);
  return Promise.resolve(1)
}

setTimeout(() => { // 宏2
  console.log('timer1')
  Promise.resolve()
    .then(() => { // 微2-1
      console.log('promise3')
    })
}, 0)

console.log('start');

再这里就不再一一讲解了 最后得出的答案为

// start ->promise1 -> timer1 -> promise3 -> timer2 -> undefined -> error2 -> // promise2 -> async1 -> error1 -> then: error!!!

解析如下:

markdown 复制代码
/**
 * TODO 微1-表示第一次轮询中的微任务
 * 
 * 第一次轮询
 * 代码首次加载script作用宏任务执行: 
 *  挂载异步任务: 微1-1 宏2
 *  输出: start
 * 
 * 宏任务执行完毕: 开始执行微任务列表, 微1-1
 *  挂载异步任务: 宏3
 *  输出: promise1
 * 
 * 
 * 
 * 第二次轮询
 * 首先执行宏任务: 宏2
 * 挂载异步任务: 微2-1
 * 输出: timer1
 * 
 * 宏任务执行完毕: 开始执行微任务列表, 微2-1
 * 挂载异步任务: 无
 * 输出: promise3
 * 
 * 
 * 
 * 
 * 第三次轮询
 * 首先执行宏任务: 宏3
 * 挂载异步任务: 微3-1 微3-2 微3-3 微3-4
 * 输出: timer2  
 * 
 * 宏任务执行完毕: 开始执行微任务列表, 微3-1 微3-2 微3-3 微3-4
 * 挂载异步任务: 宏4
 * 输出: undefined error2 promise2
 * 
 * 
 * 
 * 第四次轮询
 * 首先执行宏任务: 宏4
 * 挂载异步任务: 微4-1
 * 输出: async1
 * 
 * 宏任务执行完毕: 开始执行微任务列表, 微4-1 
 * 挂载异步任务: 微4-2
 * 输出: 没有输出, 现在的回调在没有确定状态都注册过且在轮询中被调用过, 很好的说明了挂在异步时候的是callBack
 * 
 * 
 * 第五次轮询
 * 首先执行宏任务: 宏5
 * 挂载异步任务: 微4-1
 * 输出: error1 then: error1!!!
 */

理解不到位或者有错误的还请不吝赐!

参考文章:

相关推荐
Myli_ing1 小时前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
I_Am_Me_1 小时前
【JavaEE进阶】 JavaScript
开发语言·javascript·ecmascript
℘团子এ1 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z2 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
前端百草阁2 小时前
【TS简单上手,快速入门教程】————适合零基础
javascript·typescript
彭世瑜2 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
Backstroke fish2 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
zwjapple2 小时前
typescript里面正则的使用
开发语言·javascript·正则表达式
小五Five2 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript
临枫5412 小时前
Nuxt3封装网络请求 useFetch & $fetch
前端·javascript·vue.js·typescript