js--事件循环机制

前言

  我们知道JavaScript 是单线程的编程语言,只能同一时间内做一件事,按顺序来处理事件,但是在遇到异步事件的时候,js线程并没有阻塞,还会继续执行,这又是为什么呢?本文来总结一下js 的事件循环机制。

正文

  浏览器进程,浏览器是⼀个多进程多线程的应⽤程序。其中,最主要的进程有:

  a. 浏览器进程主要负责界⾯显示、⽤户交互、⼦进程管理等。浏览器进程内部会启动多个线程处理不同的任务。
  b. ⽹络进程负责加载⽹络资源。⽹络进程内部会启动多个线程来处理不同的⽹络任务。
  c. 渲染进程渲染进程启动后,会开启⼀个渲染主线程,主线程负责执⾏ HTML、CSS、JS 代码。默认情况下,浏览器会为每个标签⻚开启⼀个新的渲染进程,以保证不同的标签⻚之间不相互影响。

  1、JavaScript是单线程的

  JavaScript 是一种单线程的编程语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个,而渲染主线程承担着诸多工作,渲染页面、执行 JS 都在其中执行,因此只有一个调用栈,决定了它在同一时间只能做一件事。在代码执行的时候,通过将不同函数的执行上下文压入执行栈中来保证代码的有序执行。在执行同步代码的时候,如果遇到了异步事件,js 引擎并不会一直等待其返回结果,而是会将这个事件挂起,继续执行执行栈中的其他任务。因此JS又是一个非阻塞、异步、并发式的编程语言。

   2、同步和异步

  同步和异步的关系就类似于我们在餐厅排队吃饭的时候,每个人必须挨个的排队来进行买饭这个操作,而在这个过程中十分无聊,这时候我们可以边排着队边玩下手机,不需多久就排到了我们买饭。这个排队过程就是JS中的一个同步操作,玩手机就像一个异步操作。同步和异步的差别就在于排队买饭和玩手机这两个任务的执行顺序的不同。

   同步: 指的是在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务。可以理解为在执行完一个函数或方法之后,一直等待系统返回值或消息,这时程序是处于阻塞的,只有接收到返回的值或消息后才往下执行其他的命令。

  异步: 指的是不进入主线程,某个异步任务可以执行了,该任务才会进入主线程执行。执行完函数或方法后,不必阻塞性地等待返回值或消息,只需要向系统委托一个异步过程,那么当系统接收到返回值或消息时,系统会自动触发委托的异步过程,从而完成一个完整的流程。

console.log(1);
setTimeout(() => {
  console.log(2);
}, 0);
setTimeout(() => {
  console.log(3);
}, 0);
setTimeout(() => {
  console.log(4);
}, 0);
console.log(5);
  上面的代码会打印  1 》 5 》 2 》 3 》4,为什么会产生这样的结果,我们来看下事件循环。

   3、事件循环

  事件循环过程可以简单描述为:

  a、函数入栈,当 Stack 中执行到异步任务的时候,就将他丢给 WebAPIs ,接着执行同步任务,直到 Stack 为空;

  b、在此期间 WebAPIs 完成这个事件,把回调函数放入 CallbackQueue (任务队列)中等待;

  c、当执行栈为空时,Event Loop 把 Callback Queue中的一个任务放入Stack中,回到第1步。

  事件循环(Event Loop) 是让 JavaScript 做到既是单线程,又绝对不会阻塞的核心机制,也是 JavaScript 并发模型(Concurrency Model)的基础,是用来协调各种事件、用户交互、脚本执行、UI 渲染、网络请求等的一种机制。在执行和协调各种任务时,Event Loop 会维护自己的消息队列。

  消息队列是一个存储着待执行任务的队列,其中的任务严格按照时间先后顺序执行,排在队头的任务将会率先执行,而排在队尾的任务会最后执行。消息队列每次仅执行一个任务,在该任务执行完毕之后,再执行下一个任务,一个任务开始后直至结束,不会被其他任务中断。执行栈则是一个类似于函数调用栈的运行容器,当执行栈为空时,JS 引擎便检查事件队列,如果不为空的话,消息队列便将第一个任务压入执行栈中运行。

  任务类型:在JavaScript中,每个任务都有⼀个任务类型,同⼀个类型的任务必须在⼀个队列,不同类型的任务可以分属于不同的队列。在⼀次事件循环中,浏览器可以根据实际情况从不同的队列中取出任务执⾏。异步任务被分为两种,一种宏任务(MacroTask)也叫Task,一种叫微任务:

  宏任务的例子很多,包括创建主文档对象、解析HTML、执行主线(或全局)JavaScript代码,更改当前URL以及各种事件,如页面加载、输入、网络事件和定时器事件。从浏览器的角度来看,宏任务代表一个个离散的、独立工作单元。运行完任务后,浏览器可以继续其他调度,如重新渲染页面的UI或执行垃圾回收。

  而微任务是更小的任务。微任务更新应用程序的状态,但必须在浏览器任务继续执行其他任务之前执行,浏览器任务包括重新渲染页面的UI。微任务的案例包括promise回调函数、DOM发生变化等。微任务需要尽可能快地、通过异步方式执行,同时不能产生全新的微任务。微任务使得我们能够在重新渲染UI之前执行指定的行为,避免不必要的UI重绘,UI重绘会使应用程序的状态不连续。

  当当前执行栈中的事件执行完毕后,js 引擎首先会判断微任务对列中是否有任务可以执行,如果有就将微任务队首的事件压入栈中执行。当微任务对列中的任务都执行完成后再去判断宏任务对列中的任务。每次宏任务执行完毕,都会去判断微任务队列是否产生新任务,若存在就优先执行微任务,否则按序执行宏任务。

  事件循环通常至少需要两个任务队列:宏任务队列和微任务队列。两种队列在同一时刻都只执行一个任务。

因此:

  事件循环是异步的实现方式;单线程是异步产生的原因; 

console.log("script start");

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

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

console.log("script end");

  按照上面的内容,分析执行步骤:

  1、宏任务:执行整体代码(相当于<script>中的代码):

    输出: script start

    遇到 setTimeout,加入宏任务队列,当前宏任务队列(setTimeout)

    遇到 promise,加入微任务,当前微任务队列(promise1)

    输出:script end

  2、微任务:执行微任务队列(promise1)

    输出:promise1,then 之后产生一个微任务,加入微任务队列,当前微任务队列(promise2)

    执行 then,输出promise2

    执行渲染操作,更新界面。

    宏任务:执行 setTimeout

    输出:setTimeout

  注意:new Promise(..)中的代码,也是同步代码,会立即执行。只有then之后的代码,才是异步执行的代码,是一个微任务。

console.log("script start");

setTimeout(function () {
  console.log("timeout1");
}, 10);

new Promise((resolve) => {
  console.log("promise1");
  resolve();
  setTimeout(() => console.log("timeout2"), 10);
}).then(function () {
  console.log("then1");
});

console.log("script end");

   步骤解析:

   当前任务队列:微任务: [], 宏任务:[<script>]

   宏任务:

    输出: script start

    遇到 timeout1,加入宏任务

    遇到 Promise,输出promise1,直接 resolve,将 then 加入微任务,遇到 timeout2,加入宏任务。

    输出script end

  宏任务第一个执行结束

  当前任务队列:微任务[then1],宏任务[timeou1, timeout2]

  微任务:

    执行 then1,输出then1

  微任务队列清空

  当前任务队列:微任务[],宏任务[timeou1, timeout2]

  宏任务:

     输出timeout1

    输出timeout2

  当前任务队列:微任务[],宏任务[timeou2]

  微任务:

    为空跳过

  当前任务队列:微任务[],宏任务[timeou2]

  宏任务:

    输出timeout2

  注意:async 和 await 其实就是 Generator 和 Promise 的语法糖。async 函数和普通 函数没有什么不同,他只是表示这个函数里有异步操作的方法,并返回一个 Promise 对象

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
// Promise 写法
async function async1() {
  console.log("async1 start");
  Promise.resolve(async2()).then(() => console.log("async1 end"));
}

  下面例子:

async function async1() {
  console.log("async1 start");
  await async2();
  console.log("async1 end");
}
async function async2() {
  console.log("async2");
}
async1();
setTimeout(() => {
  console.log("timeout");
}, 0);
new Promise(function (resolve) {
  console.log("promise1");
  resolve();
}).then(function () {
  console.log("promise2");
});
console.log("script end");

   步骤解析:

  当前任务队列:宏任务:[<script>],微任务: []

  宏任务:

     输出:async1 start

    遇到 async2,输出:async2,并将 then(async1 end)加入微任务

    遇到 setTimeout,加入宏任务。

    遇到 Promise,输出:promise1,直接 resolve,将 then(promise2)加入微任务

    输出:script end

  当前任务队列:微任务[async1 end,promise2],宏任务[timeout]

  微任务:

    输出:async1 end

    async1 end 出队

    输出:promise2

    promise2 出队

    微任务队列清空

  当前任务队列:微任务[],宏任务[timeout]

  宏任务:

    输出:timeout

    timeout 出队,宏任务清空

 

  补充:
   1、随着浏览器的复杂度急剧提升,W3C 不再使⽤宏队列的说法在⽬前 chrome 的实现中,⾄少包含了下⾯的队列:
    延时队列:⽤于存放计时器到达后的回调任务,优先级「中」
    交互队列:⽤于存放⽤户操作后产⽣的事件处理任务,优先级「⾼」
    微队列:⽤户存放需要最快执⾏的任务,优先级「最⾼」
  2、JS 中的计时器能做到精确计时吗?为什么?
    不⾏,因为:
    a. 计算机硬件没有原⼦钟,⽆法做到精确计时
    b. 操作系统的计时函数本身就有少量偏差,由于 JS 的计时器最终调⽤的是操作系统的函数,也就携带了这些偏差
    c. 按照 W3C 的标准,浏览器实现计时器时,如果嵌套层级超过 5 层,则会带有 4 毫秒的最少时间,这样在计时时间少于 4 毫秒时⼜带来了偏差
    d. 受事件循环的影响,计时器的回调函数只能在主线程空闲时运⾏,因此⼜带来了偏差

写在最后

  以上就是本文的全部内容,希望给读者带来些许的帮助和进步,方便的话点个关注,小白的成长之路会持续更新一些工作中常见的问题和技术点。

posted @ 2022-03-30 17:49  zaisy'Blog  阅读(1768)  评论(2编辑  收藏  举报