⚡⚡⚡ 学习事件循环,这篇文章值得你看看


theme: nico
highlight: atom-one-dark

JavaScript 是单线程的,这就意味着所有的任务需要排队,前一个任务执行,才会执行下一个任务。这样所导致的问题就是如果 JavaScript 执行的时间过长,这样就会造成页面的渲染不连贯,造成页面卡顿,导致页面渲染加载阻塞的情况。

为了解决这个问题,JavaScript 出现了同步和异步,同步就是在浏览器执行 JavaScript 代码的时候,将所有同步(也就是大部分的代码)的代码放到一个执行栈中当中,遇到异步代码就把异步代码放到任务队列中,这样就形成了异步操作,同步与异步的差别就在于这条流水线上各个流程的执行顺序不同。

但是浏览器是多线程的,当 JavaScript 需要执行异步任务时,浏览器会另外启动一个线程去执行该任务,例如主线程中需要发送一个 ajax 的网络请求,就把这个任务交给另一个浏览器线程,也就是 HTTP 请求线程真正去发送网络请求,当请求结果回来时,再将 callback 里需要执行的 JavaScript 回调交给 JavaScript 引擎线程去执行,而 JavaScript 只处理最后一部分。

所以这里的异步实际上不是 JavaScript 自身实现的,其实是浏览器为其提供的能力。

宏任务和微任务

事件循环 不仅仅包含事件队列,而是具有至少两个队列,除了事件,还要保持浏览器执行的其他操作。这些操作被称为任务,并且分为两类:宏任务微任务

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

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

我们都知道 settimeoutajaxclick这些是宏任务,但是 script 标签中的代码作为一个整体也是一个宏任务。

宏任务和微任务有哪些?

  • 宏任务: script 标签、settimeoutsetinterval事件网络请求、页面加载、键盘输入、件等;
  • 微任务: Promise.then(...)async/awaitprocess.nextTickDOM的改变等等;

事件循环

JavaScript 在执行一段代码的时候,会将同步代码按顺序安排在一个执行栈中,依次执行里面的代码,每当遇到一个函数调用都会将当前的函数弹到执行栈的栈顶,执行当前的函数,待执行完毕就弹出执行栈中。

当遇到一个异步任务时就交给其他线程处理,主线程之外还存在一个 任务队列,浏览器中的各种 Web Api 为异步的代码提供了一个单独的运行空间,当异步的代码运行完毕以后,会将代码中的回调送入到 任务队列 中。

一个 微任务 就是一个简短的函数,当创建该函数的执行之后,并且只有当 JavaScript 调用栈为空,而控制权尚未返回给 用户代理 用来驱动脚本执行环境的事件循环之前,该微任务才会执行。事件循环既可能是浏览器主事件循环也可能是被一个 web worker 所驱动的事件循环。这使得给定的函数在没有其他脚本执行干扰的情况下运行,也保证了微任务能在用户代理有机会对该微任务带来的行为做出反应之前运行。JavaScript 中 的 promises 使用微任务队列去运行它们的回调函数,但当能够推迟工作直到当前事件循环过程完结时,也是可以执行微任务的时机。

一旦主线程的栈中所有同步任务执行完毕后,调用栈为空时系统会将队列中的回调函数遵循先进先出的原则依次压入低入调用栈中执行,当调用栈为空时,仍然不断检测任务队列中是否有代码执行,这一个过程就是 事件循环 机制,也就是我们常说的 Event Loop

事件循环通常至少需要两个任务队列:宏任务队列和微任务队列。两种队列在同一时刻都只执行一个任务,可以通过下图来查看这厉害原则:

image.png

在一次迭代中,事件循环将首先检查宏任务队列,如果宏任务等待,则立即开始执行宏任务。直到该任务运行完成或者队列为空,事件循环将移动去处理微任务队列。如果有任务在该队列中等待,则事件循环将依次开始执行,完成一个后执行余下的微任务,直到队列中所有微任务执行完毕。注意处理宏任务和微任务队列之间的区别:单次循环迭代中,最多处理一个宏任务,其余的在队列中等待,而队列中的所有微任务都会被处理。

所有微任务会在下一次渲染之前执行完成,因为它们的目标是在渲染前更新应用程序状态。

当微任务队列处理完成并清空时,事件循环会检查是否需要更新 UI 渲染,如果是,则会重新渲染UI视图。至此,当前事件循环结束,之后将回到最初第一个环节,再次检查宏任务队列,并开启新一轮的事件循环。

一个事件循环过程模型如下:
当调用栈为空时,执行以下步骤:

  1. 选择任务队列中最旧的任务(队列是一个先进先出的队列,最旧的那个就是最先进的,这里是 任务A);
  2. 如果任务A为空(意味着任务队列为空),跳转到第6步;
  3. 将当前运行的任务设置为任务A;
  4. 运行任务A,意味着运行回调函数;
  5. 运行结束,将当前的任务设置为空,删除任务A;
  6. 执行微任务队列:
    1. 选择微任务队列中最早的任务X;
    2. 如果任务X,代表这微任务为空,跳转到步骤6;
    3. 将当前运行的任务设置为任务X,并运行该任务;
    4. 运行结束,将当前正在运行的任务设置为空,删除任务X;
    5. 选择微任务队列中下一个最旧的任务,可以理解为第n+1个入队的,跳转到步骤2;
    6. 完成微任务队列;
  7. 跳转到第1步;

这个事件循环过程模型如下图所示:

image.png

值得注意的是,当一个任务在宏任务队列中正在运行时,可能会注册新事件,因此可能会创建新任务,下面是两个新创建的任务:

  • Promise.then(...) 是一个回调任务:当 promisefulfilled/rejected:任务将被推入当前轮事件循环中的微任务队列;当promisepending:任务将在下一轮事件循环中被推入微任务队列(可能是下一轮);

案例

接下来我们通过一些案例来加深对事件循环的理解。

案例一

setTimeout(() => {
  console.log("time1");

  new Promise((resolve) => {
    resolve();
  }).then(() => {
    new Promise((resolve) => {
      resolve();
    }).then(() => {
      console.log("then4");
    });

    console.log("then2");
  });
});

new Promise((resolve) => {
  console.log("p1");
  resolve();
}).then(() => {
  console.log("then1");
});

最后的输出结果为 p1 then1 time1 then2 then4,下面就来分析一下这个结果的由来:

  1. 代码首先遇到settimeout,是一个宏任务,里面的代码不会被执行;
  2. 接着代码往下执行,遇到 new Promise(...)中的回调函数是一个同步任务,直接执行;
  3. 直接输出 "p1",调用 resolve(),Promise 的状态变为 fuifilled,当 promise 状态变为 fulfilled/rejected时,任务将被推入当前轮事件循环中的微任务队列,所以后面的 then(...) 会被加入到微任务队列里面;
  4. 主线程中的同步代码执行完,从微任务中取出最旧的那个任务,也就是 then(...),输出 then1,此时微任务队列为空;
  5. 继续执行宏任务,也就是这个 settimeout,代码从上往下执行,首先输出 time1;
  6. 在下面的代码中又遇到了 new Promise(...),并且调用了 resolve(),then(...)被加入到微任务队列中,此时的同步任务已经执行完毕,直接执行这个 then(...);
  7. 又是遇到 new Promise(....),又是调用的 resolve(),所以 then() 方法会被添加到微任务队列中,代码往下执行,输出 "then2",此时微任务then(...)中的代码全部执行完毕;
  8. 此时同步任务执行完毕,继续执行微任务中的 then(...),输出 "then4";
  9. 所有代码运行完毕,程序结束;

案例二

<script>
  console.log(1);

  setTimeout(() => {
    console.log(5);
  });

  new Promise((resolve) => {
    resolve();
  }).then(() => {
    console.log(3);
  });
  console.log(2);
</script>

<script>
  console.log(4);
</script>

这段代码的最后的输出结果是: 1 2 3 4 5,具体代码执行过程有以下步骤:
首先提醒一点,script 标签本身是一个宏任务,当页面出现多个 script 标签的时候,浏览器会把script 标签作为宏任务来解析。当前实例中两个 script 标签,它们会一次加入到宏任务队列中。

  1. console.log(...) 是同步代码,1首先会被输出,代码往下执行;
  2. 遇到 settimeout(),会被加入到宏任务队列中;
  3. then(...) 会被加入到微任务队列中,代码继续往下执行;
  4. console.log(...) 为同步认为输出 2;
  5. 此时同步任务执行完毕,转而执行微任务 then(...),输出 3;
  6. 当前宏任务执行完毕,此时同步任务和微任务都为空,取出最旧的宏任务,也就是第二个 script 标签;
  7. 输出 4,此时同步代码和微任务队列都为空,继续执行下一个宏任务,也就是 settimeout;
  8. 输出 5;

案例三

async function foo() {
  console.log("start");
  await bar();
  console.log("end");
}

async function bar() {
  console.log("bar");
}

console.log(1);

setTimeout(() => {
  console.log("time");
});

foo();

new Promise((resolve) => {
  console.log("p1");
  resolve();
}).then(() => {
  console.log("p2");
});

console.log(2);

这段代码的最后的输出结果是: 1 start bar p1 2 end p2 time,下面就来分析一下这段代码的执行过程:

  1. 前面两个是函数定义,不执行,遇到 console.log(),输出 1;
  2. 代码继续往下执行,遇到 settimeout(),代码加入到宏任务队列之中,代码往下执行;
  3. 调用 foo,输出 start;
  4. await 等待 bar() 调用的返回结果;
  5. 执行 bar() 函数,输出 bar;
  6. await 相当于 Promise.then(...),代码被加入到微任务队列中,所以 end 还不执行;
  7. 代码往下执行,遇到 new Promise(...),p1 直接输出,then() 又继续被加入到微任务队列中;
  8. 代码继续往下执行,遇到 console.log(2),输出 2;
  9. 此时主线程代码快为空,执行微任务队列中最旧的那个任务,继续执行 await 后续代码,输出 end;
  10. 执行 then() ,输出 p2;
  11. 最后执行 settimeout,输出 time;

案例四

Promise.resolve()
  .then(() => {
    console.log(0);
    return Promise.resolve(4);
  })
  .then((res) => {
    console.log(res);
  });

Promise.resolve()
  .then(() => {
    console.log(1);
  })
  .then(() => {
    console.log(2);
  })
  .then(() => {
    console.log(3);
  })
  .then(() => {
    console.log(5);
  })
  .then(() => {
    console.log(6);
  });

这个案例中,因为每一个 then() 都是一个微任务,所以首先执行的是0,代码继续往下执行,输出同级的 then(),也就是输出 1

如果 Promise 内返回的对象具有可调用的 then() 方法,则会在微任务队列中再插入一个任务,这就慢了一拍,如果这个 then() 方法是来源于 Promise 的,则因为是异步又慢了一拍,所以一共是慢了拍,所以 Promise.resolve(4) 的结果等到 23 输出完成,console.log(res) 的结果才会被输出;

所以该案例的最终结果输出的是 0 1 2 3 4 5 6

文章推荐

💯💯💯 Map、Set、WeakMap、WeakSet看这一篇就够

🍓 一文带你彻底搞懂JavaScript异步编程

一文让你彻底搞懂JS垃圾回收机制

💯💯💯 你是会 async/await,但是你知道它的原理以及如何捕捉到错误吗

结语

JavaScript 的事件循环是这门语言中非常重要且基础的概念。清楚的了解事件循环的执行顺序和每一个阶段的特点,可以使我们对一段异步代码的执行顺序有一个清晰的认识,从而减少代码运行的不确定性。合理的使用各种延迟事件的方法,有助于代码更好的按照其优先级去执行,提升程序的流畅性。

最后送大家一句话:年轻人,不要低头,世间最美的风景,在你途经的路上。在这途中,若是不幸遇见糟粕,请用一颗乐观的心,去笑对人生的坎坷;若是遇见风雨,请用一颗从容的心,去抚平时间的创伤;若是遇见善意,请用一颗温暖的心,去记住这些美好。

若是事与愿违,也请你保持最好的心态去相信,该来的,都在路上。当你尽心做好一切,你想要的,自会悄然来临。

© 版权声明
THE END
喜欢就支持一下吧
点赞5 分享
评论 抢沙发
头像
欢迎您留下宝贵的见解!
提交
头像

昵称

取消
昵称表情代码图片

    暂无评论内容