宏任务,微任务和事件循环

在学习这部分知识点之前先了解两个概念,什么是进程?什么是线程?

进程和线程

进程:资源分配的最小单位

线程:CPU调度的最小单位

线程是依附于进程的,一个进程可有多个线程

区别:进程是系统中正在运行的一个程序,而线程是属于进程的,一个进程可以拥有多个线程,线程是程序中的执行者;进程之间不会共享任何的状态,而线程之间可以共享变量。

我们经常说JS是单线程的(下文中会进行解释),浏览器是多进程的。怎么去验证浏览器是多进程的呢?

以谷歌浏览器为例,打开更多工具-任务管理器:

可以看到,每一项任务后都有一列"进程ID",这就说明浏览器是有多个进程的。简单来看一下,第一行的"浏览器"代表的是主进程,接下来是GPU进程,网络进程、存储进程、音视频进程、渲染进程(通常情况下一个tab页就是一个渲染进程)、插件进程。

浏览器中的进程都有什么作用呢?

  • 浏览器主进程:负责控制浏览器除标签页( 渲染进程)外的界面,地址栏,状态栏,前进后退,刷新等等;
  • 浏览器渲染进程:负责界面渲染 ,脚本执行 ,事件处理等等。默认情况下,每个Tab会创建一个渲染进程;
  • 网络进程:负责网络资源加载(例如css、js文件的加载);
  • GPU进程:负责浏览器界面的渲染,比如3D绘制;

浏览器是多进程多线程的

渲染进程中的线程

  • JS引擎线程:负责解析和执行JS。JS引擎线程和GUI渲染线程是互斥的,同时只能一个在执行;
  • GUl 渲染线程:解析html和CSS,构建DON树,CSSOM树,(Render)這染树、和绘制页面等;
  • 事件触发线程:主要用于控制事件循环。比如计时器(setTimeout/setinterval),异步网络请求等等,会把任务添加到事件触发线程,当任务符合触发条件触发时,就把任务添加到待处理队列的队尾,等JS引擎线程去处理;
  • 定时触发器线程:setTimieout和setlnteval时的线程。定时的计时并不是由JS引擎线程负责的,JS引擎线程如果阻塞会影响计时的准确性。
  • 异步http请求线程:ajax的异步请求,fetch请求等;(注意:ajax同步请求,没有产生异步任务)

在此,大家需要额外了解一个知识点。什么是异步操作?

异步操作一般是浏览器的两个或者两个以上线程共同完成的。 例如ajax异步请求 = 异步http请求线程 + JS引擎线程;setTimeout = 定时触发器线程 + JS引擎线程。

在异步任务里,对于异步类型还有进一步的划分,那就是接下来我们要讲的宏任务微任务 ,切记微任务比宏任务先执行

宏任务

概念:执行一段程序、执行一个事件回调或一个 interval/timeout 被触发之类的标准机制而被调度的任意 JavaScript 代码;


举个例子

Events:典型的就是用户交互事件,要注意并不是所有的事件都会走宏任务,有些是走其他队列

Parsing:执行HTML解析

Callbacks:执行专门的回调函数

Using a resource:使用一项资源,比如网络请求,文件读取等

Reacting to DOM manipulation:响应DOM解析之类


通俗来讲,我们正常执行的一些代码都可以认为是宏任务,更需要关注的实际上是微任务。

微任务

在浏览器中的微任务就比较少了,大概只有两个:

  • Promise
  • MutationObserver

Promise大家肯定都知道了,简单介绍一下MutationObserver

MutationObserver

概念

MutationObserver 用来监视 DOM 变动。DOM 的任何变动,比如节点的增减、属性的变动、文本内容的变动都会触发 MutationObserver 事件。 但是,它与事件有一个本质不同:事件是同步触发,也就是说,DOM 的变动立刻会触发相应的事件;MutationObserver 则是异步触发,DOM 的变动并不会马上触发,而是要等到当前所有 DOM 操作都结束才触发。

特点

MutationObserver 有以下特点:

  • 它等待所有脚本任务完成后,才会运行(即异步触发方式)。
  • 它把 DOM 变动记录封装成一个数组进行处理,而不是一条条个别处理 DOM 变动。
  • 它既可以观察 DOM 的所有类型变动,也可以指定只观察某一类变动。

MutationObserve 小例子

html 复制代码
<body>
  <div id="container"></div>
  <button id="btnAdd" type="button">添加子节点</button>

  <script>
    const containerEl = document.getElementById('container');
    //观察器的配置(需要观察什么变动)
    const config = { attributes: true, childList: true, subtree: true };
    //当观察到变动时执行的回调函数
    const callback = function (mutationsList, observer) {
      for (let mutation of mutationsList) {
        if (mutation.type === 'childList') {
          console.log(`A child node has been added or removed. ${performance.now()}`);
        } else if (mutation.type === 'attributes') {
          console.log('The ' + mutation.attributeName + ' attribute was modified.');
        }
      }
    };

    //创建一个观察器实例并传入回调函数
    const observer = new MutationObserver(callback);
    //以上述配置开始观察目标节点
    observer.observe(containerEl,config);

    btnAdd.onclick = function(){
      setTimeout(function() {
        console.log("setTimeout callback:", performance.now());
      });
      containerEl.append(`added node: ${performance.now()}`)
    }
  </script>
</body>

运行结果:

第13行代码先于第27行代码执行,前者是MutationObserver中回调函数的语句,是微任务。后者是定时器,是宏任务。微任务比宏任务先执行

浏览器事件循环机制

  • 一次循环执行任务队列一个宏任务
  • 然后执行所有的微任务

同源窗口之间共享事件循环

刚刚在上文中提到,一般一个tab页面就是一个渲染进程,而一个渲染进程就会有一个事件循环。但这个结论有种特殊情况,同源窗口之间会共享事件循环。

  • 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环
  • 如果窗口是包含在 <iframe>中,则它可能会和包含它的窗口共享一个事件循环
  • 在多进程浏览器中多个窗口碰巧共享了同一个进程

其中第三点情况比较复杂,在浏览器中,渲染进程是有数量限制的。当数量达到一定程度时,渲染进程可能会复用。

验证同源窗口之间会共享事件循环

我们选择第一种情况进行验证,设想这样一个场景:

  1. index.html 点击按钮之后打开 other.html
  2. index.html 3000ms之后,执行一个长达6000ms的任务(阻塞)
  3. 同时,other.html打开之后先打印当前时间 ,5000ms之后再打印当前时间

开始验证:

  • 如果两个窗口事件循环是独立的:other的页面两次打印的时间间隔是5000ms左右
  • 反之,other页面第二次输出时间应该是:3000 + 6000 = 9000ms左右
js 复制代码
//index.html
<body>
  <button id="btnOpen">打开新窗口</button>
  <script>
    function printTime() {
      console.log("index.html:", new Date().toLocaleTimeString());
    }

    function asyncSleep(duration) {
      const now = Date.now();
      while(now + duration > Date.now()) {}
    }

    btnOpen.onclick = function() {
      window.open("./other.html");
      printTime()
      console.log("index.html:3000ms之后执行")
      setTimeout(function(){
        asyncSleep(6000)
      },3000)
    }
  </script>
</body>
js 复制代码
//other.html
<body>
  <div>Other页面</div>
  <script>
    function printTime() {
      console.log("other.html:", new Date().toLocaleTimeString());
    }
    printTime();
    console.log("Other.html: 5000ms之后执行")
    setTimeout(()=>{
      printTime();
    },5000)
  </script>
</body>

显然,other页面的第二次时间输出是在 9s之后的,所以 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环 验证成功。

思考一个问题,如果不想让这两个窗口共用一个事件循环,需要怎么做呢?

设想现在有两个页面:一个页面做正常的展示,另一个页面做大量的图形绘制,这必然会消耗cpu,容易造成页面卡顿。一个页面会影响其他页面的展示,这肯定需要优化,把该页面独立出来独用一个渲染进程,这该怎么做呢?

还是以上述的两个页面为例,把打开页面的按钮换为a标签,关键点在于 rel="noopener"

html 复制代码
<!-- index.html -->
<body>
  <!-- <button id="btnOpen">打开新窗口</button> -->
  <a id="btnOpen" target="_blank" href="./other.html" rel="noopener">打开新窗口</a>
</body>

现在两次时间输出间隔为 5s左右,说明该页面独享一个进程。

NodeJS的事件循环

注:每个方框代表事件循环中的一个阶段

阶段总览

  • timers:此阶段执行由 setTimeout() 和 setlnterval()调度的回调;
  • pending calbacks:执行延迟到下一个循环迭代的 i/o 回调。 为什么是下一个,因为每个队列单次有最大数量的限制,不能保证全部执行完,只能下次;
  • dle, prpare:仅仅内部使用;
  • pol:检索新的 i/o 事件;执行与 i/o 相关的回调(除了close回调、由计时器调度的回调和setImmediate() );适当时,节点将在此处阻塞;
  • check: etlmmediate() 回调在这里被调用;
  • close callbacks: 一些关闭的回调,例如 socket.on (" close" , ...);

queueMicrotask

node.js的事件循环要比浏览器的事件循环复杂的多,在上文中提到微任务由两种方式产生,分别是Promise 和 MutationObserve,这里再学习第三种,我们可以人为的添加一个微任务。

WindowWorker 接口的 queueMicrotask() 方法,将微任务加入队列以在控制返回浏览器的事件循环之前的安全时间执行。

  • 在事件循环结束前插入一个微任务,比 setTimeout(fn,0)更快;
  • 注意,添加的微任务,未提供取消的手段;
  • 语法:queueMicrotask(function);

queueMicrotask: 示例1

js 复制代码
<body>
  <button id="btn" type="button">queueMicrotask + setTimeout</button>
  <script>
    btn.onclick = function () {
      //宏任务
      setTimeout(function() {
        console.log("setTimeout:callback",performance.now())
      },0)
      //微任务
      queueMicrotask(()=>{
        console.log("queueMicrotask:callback",performance.now())
      })
    }
  </script>
</body>

微任务先于宏任务执行

queueMicrotask: 示例2

js 复制代码
<body>
  <button id="btn" type="button">queueMicrotask + setTimeout</button>
  <script>
    btn.onclick = function() {
      //同步的代码
      console.log("onclick:start",performance.now());
      //产生微任务,.then内都是微任务,此时微任务队列:promise:callback
      Promise.resolve().then(function() {
        console.log("promise:callback",performance.now());
      })
      //产生宏任务,此时宏任务队列:setTimeout:callback
      setTimeout(function() {
        console.log("setTimeout:callback",performance.now())
      },0)
      //产生微任务,追加,此时微任务队列:promise:callback,queueMicrotask:callback
      queueMicrotask(()=>{
        console.log("queueMicrotask:callback",performance.now())
      })
      //同步的代码
      console.log("onclick:end", performance.now())
      //此时宏任务队列:setTimeout:callback
      //此时微任务队列:promise:calback,queueMicrotask:callback
    }
  </script>
</body>

queueMicrotask polyfill(垫片)

在不支持 queueMicrotask 方法的浏览器里面,我们需要使用 polyfill 模拟实现

下面的代码是一份 queueMicrotask() 的 polyfill。它通过使用立即 resolve 的 promise 创建一个微任务(microtask),如果无法创建 promise,则回落(fallback)到使用setTimeout()。

js 复制代码
if (typeof window.queueMicrotask !== "function") {
  window.queueMicrotask = function (callback) {
    Promise.resolve()
      .then(callback)
      .catch(e => setTimeout(() => { throw e; }));
  };
}

无尽微任务

因为微任务自身可以入列更多的微任务,且事件循环会持续处理微任务直至队列为空,那么就存在一种使得事件循环无尽处理微任务的真实风险。如何处理递归增加微任务是要谨慎而行的。

js 复制代码
<body>
  <div id="container" style="height: 100px;border:1px solid #333;"></div>
  <button id="btn" type="button">queueMicrotask + setTimeout</button>
  <script>
    let count = 0;
    function addMicroTask() {
      console.log("queueMicrotask:callback",performance.now());
      count++
      //if(count < 100) {
        queueMicrotask(addMicroTask);
      //}
    }
    btn.onclick = function() {
      //产生宏任务,此时宏任务队列:setTimeout:callback
      setTimeout(function() {
        const message = "setTimeout:callback" + performance.now();
        container.innerHTML = message;
        console.log(message)
      },0)
      addMicroTask()
    }
  </script>
</body>

如果没有条件加以控制,无尽微任务持续占用了资源,阻塞宏任务的执行。

如果可能的话,大部分开发者并不应该过多的使用微任务。在基于现代浏览器的 JavaScript 开发中有一个高度专业化的特性,那就是允许你调度代码跳转到其他事情之前,而那些事情原本是处于用户计算机中一大堆等待发生的事情集合之中的。滥用这种能力将带来性能问题。

js 复制代码
if(count < 100) {
    queueMicrotask(addMicroTask);
}

给微任务加以条件限制,宏任务顺利执行

为什么我们需要这个 queueMicrotask api?

从微任务本身的概念来说的话,就是当我们期望某段代码,不阻塞当前执行的同步代码,同时又期望它尽可能快地执行时,我们就需要它。

js 复制代码
setTimeout(() => {
    console.log('setTimeout');
}, 0);
 

queueMicrotask(() => {
    console.log('queueMicrotask');
});

有其他方法能代替 queueMicrotask 吗

通过下面的代码,会得到和上面代码一样的运行结果:

js 复制代码
setTimeout(() => {
    console.log('setTimeout');
}, 0);

Promise.resolve().then(() => {
    console.log('queueMicrotask');
});

通过 promise 去创建微任务是有风险的。举例来说,当使用 promise 创建微任务时,由回调抛出的异常被报告为 rejected promises 而不是标准异常;同时,创建和销毁 promise 带来了事件和内存方面的额外开销。通过引入 queueMicrotask(),可以避免这些风险。

下期再见

相关推荐
范文杰2 小时前
AI 时代如何更高效开发前端组件?21st.dev 给了一种答案
前端·ai编程
拉不动的猪2 小时前
刷刷题50(常见的js数据通信与渲染问题)
前端·javascript·面试
拉不动的猪3 小时前
JS多线程Webworks中的几种实战场景演示
前端·javascript·面试
FreeCultureBoy3 小时前
macOS 命令行 原生挂载 webdav 方法
前端
uhakadotcom4 小时前
Astro 框架:快速构建内容驱动型网站的利器
前端·javascript·面试
uhakadotcom4 小时前
了解Nest.js和Next.js:如何选择合适的框架
前端·javascript·面试
uhakadotcom4 小时前
React与Next.js:基础知识及应用场景
前端·面试·github
uhakadotcom4 小时前
Remix 框架:性能与易用性的完美结合
前端·javascript·面试
uhakadotcom4 小时前
Node.js 包管理器:npm vs pnpm
前端·javascript·面试
LaoZhangAI5 小时前
2025最全GPT-4o图像生成API指南:官方接口配置+15个实用提示词【保姆级教程】
前端