JS基础 - 模拟事件循环任务队列机制

前言

众所周知,JS 在主线程上是单线程执行的,并通过事件循环(Event Loop)机制来实现异步操作的处理。

本文将梳理事件循环机制的执行流程,并尝试模拟其中对任务队列的处理机制。

事件循环

JS 在设计之初的目的是用于操作 DOM,为了避免多个线程同时操作 DOM 引发冲突,所以 JS 就被设计为单线程。

单线程执行的特点,使 JS 无法单独处理异步操作(如setTimeout、UI交互)。而借助事件循环机制,单线程的JS就可以处理多个任务,实现异步编程。所以事件循环机制本质上是一种异步编程模型

事件循环机制将异步操作分为两大类,分别是宏任务微任务,任务队列则是宏任务队列微任务队列的统称。

宏任务与微任务的具体分类如下:

  • 宏任务:setTimeout、setInterval、UI 交互事件
  • 微任务:Promise、MutationObserver

事件循环机制的执行流程如下:

  1. 执行主任务(也可称"执行同步任务"),遇到宏任务时,将其加入宏任务队列的队尾(微任务同理)。
  2. 执行微任务:按顺序清空微任务队列(处理宏任务、微任务的逻辑与第一步相同)。
  3. 判断是否需要渲染页面(由浏览器自动判断)。
  4. 执行宏任务:取出宏任务队列的第一个任务,执行之(处理宏任务、微任务的逻辑与第一步相同)。
  5. 重复循环第二、第三、第四步,直至任务队列为空。

示意图:

如上图所示,"宏任务 - 微任务 - 判断是否需要渲染页面"这种循环执行的机制就称为事件循环机制。

拓展:页面帧与事件循环

只谈论事件循环机制似乎有点抽象,因为目前来说它有点"看不清摸不着",不知道它处于整个 web 应用中的"哪个部分"。而我们看结合 页面帧 的概念,用更具象的方式去理解事件循环机制。

所谓的"帧"可理解为"周期",举个例子:如果当前页面每秒刷新 60 次,那么它的刷新率就是 60hz,即每秒 60 个周期/帧。也就是说(刷新率为 60hz 时)每帧的时间为 1s / 60 = 16.7ms。

每一帧是一个周期循环,浏览器需要在每一帧中执行重复的任务,以保持页面能正常展示与使用。

具体来说,浏览器每一帧需要做的事可按执行顺序归纳为:

  1. 运行事件循环机制(尽可能多地执行宏任务与微任务)
  2. 执行 requestAnimationFrame
  3. 页面渲染
  4. (如有空闲时间)执行 requestIdleCallback

示意图:

可以发现,页面每一帧的第一件事就是运行事件循环机制,它并非是一个抽象的机制,而是切实地发生在每一个 16.7 毫秒中。希望结合页面帧,能让大家对事件循环机制有更具象的理解。

模拟事件循环机制

接下来我们将尝试编写代码模拟事件循环中对任务队列的处理机制。

目标

模仿浏览器事件循环机制,实现:

  1. 执行主任务。
  2. 清空微任务队列。
  3. 取出第一个宏任务并执行。
  4. 循环第二、第三步,直至任务队列为空。

为了使整个模拟逻辑能走通,现使用自定义的 pushMacropushMicro 方法分别代表宏任务和微任务,使用方式如下所示:

javascript 复制代码
// 模拟宏任务
pushMacro(() => {
  console.log("hello");
});
// 类比原生宏任务setTimeout
setTimeout(() => {
  console.log("hello");
});

// 模拟微任务
pushMicro(() => {
  console.log("hello");
});
// 类比原生微任务then函数
Promise.resolve().then(() => {
  console.log("hello");
});

任务

具体编码任务为:

  1. 完善 pushMacro 和 pushMicro 方法,维护宏任务队列和微任务队列。
  2. 编写 worker 函数,接收主任务 main 函数作为参数。其功能为模拟 JS 主线程执行主任务 mian 函数,并使事件循环机制能正常执行。

需求代码如下所示:

javascript 复制代码
// 添加微任务(需完善)
function pushMicro() {}
// 添加宏任务(需完善)
function pushMacro() {}
// 模拟JS主线程(需完善)
function worker() {}

// 主任务
const main = () => {
  console.log("a");
  pushMicro(() => {
    console.log("b");
    pushMicro(() => {
      console.log("c");
    });
    pushMacro(() => {
      console.log("d");
    });
  });
  pushMacro(() => {
    console.log("e");
  });
  console.log("f");
};

worker(main); // 运行后输出:a, f, b, c, e, d

具体实现

维护任务队列

首先我们需要模拟出宏任务队列和微任务队列,但正因为它们是队列,所以我们直接使用数组就可以简单模拟它们的特性了。

其次是如何添加宏任务和微任务,参考 setTimeout 和 Promise 的 then 函数,可以发现它们都是接受一个函数作为参数,然后在适当的时机再调用。那么我们的 pushMicro 和 pushMacro 也同样接收回调函数作为参数,并将它们添加到对应的队列中。这样就完成了任务队列的维护了。

具体代码如下所示:

javascript 复制代码
//微任务队列
const microQueue = [];
// 宏任务队列
const macroQueue = [];

// 添加微任务
function pushMicro(fn) {
  microQueue.push(fn);
}
// 添加宏任务
function pushMacro(fn) {
  macroQueue.push(fn);
}

处理任务队列

接下来需要编写worker函数去模拟JS主线程,完成以下两个任务:

  1. 执行主任务main函数
  2. 在合适的时机以合适的方式处理宏任务和微任务

其中执行main函数这点并没有什么特殊,就是接受一个回调函数并执行它而已。

而对于任务队列的处理上,我们需要编写一个handleQueue函数,递归调用自身,以实现循环处理的效果。

需要注意的点是,在清空微任务队列时,如果某个微任务中又添加了微任务,那么新添加的微任务会排在队尾,并且会在同一轮中执行

具体代码如下所示:

javascript 复制代码
function worker(callback) {
  // 处理任务队列
  function handleQueue() {
    // 处理微任务
    while (microQueue.length) {
      // 清空微任务队列
      const fn = microQueue.shift();
      fn();
    }

    // 处理宏任务
    if (macroQueue.length) {
      // 执行宏任务队列的第一个任务
      const fn = macroQueue.shift();
      fn();
    }

    // 如任务队列中还有任务,则递归循环处理
    if (microQueue.length || macroQueue.length) handleQueue();
  }

  callback();
  handleQueue();
}

上述代码需要注意的是,清空微任务队列时使用了while循环,每次都重新读取微任务队列的长度。而不能直接使用 forEach 等数组遍历方法,因为这些方法的遍历次数在刚开始时已经确定了,如果在清空微任务的过程中有新的微任务加入,则它们就不会在本轮被执行。

完整代码

javascript 复制代码
//微任务队列
const microQueue = [];
// 宏任务队列
const macroQueue = [];

// 添加微任务
function pushMicro(fn) {
  microQueue.push(fn);
}
// 添加宏任务
function pushMacro(fn) {
  macroQueue.push(fn);
}

function worker(callback) {
  // 处理任务队列
  function handleQueue() {
    // 处理微任务
    while (microQueue.length) {
      // 清空微任务队列
      const fn = microQueue.shift();
      fn();
    }

    // 处理宏任务
    if (macroQueue.length) {
      // 执行宏任务队列的第一个任务
      const fn = macroQueue.shift();
      fn();
    }

    // 如任务队列中还有任务,则递归循环处理
    if (microQueue.length || macroQueue.length) handleQueue();
  }

  callback();
  handleQueue();
}

// 主任务
const main = () => {
  console.log("a");
  pushMicro(() => {
    console.log("b");
    // 微任务中添加了新的微任务,也会在同一轮的事件循环中一起被清空
    pushMicro(() => {
      console.log("c");
    });
    pushMacro(() => {
      console.log("d");
    });
  });
  pushMacro(() => {
    console.log("e");
  });
  console.log("f");
};

worker(main); // 输出:a, f, b, c, e, d

进一步封装

上面展示的代码其实已经完成了我们的需求了,但大家应该也发现了,microQueue、macroQueue、pushMicro、pushMacro 都是全局的,显得比较"散装"。

接下来我们将对 worker 函数改装成一个类(为了不和原生的 Worker 类重名,将改名为 MyWorker ),然后把上述四个变量和函数作为它的属性和方法,以达到封装的效果。

具体设计如下:

  1. microQueue 和 microQueue 是全局唯一,且不对外暴露的任务列表,所以将作为 MyWorker 的静态私有属性
  2. pushMacro 和pushMicro 其实就是普通的宏任务和微任务,所以把它们作为类的静态方法,可供外界直接调用。

具体代码如下所示:

javascript 复制代码
class MyWorker {
  // 静态私有属性,全局唯一不对外暴露
  static #microQueue = []
  static #macroQueue = []

  // 静态方法,可访问宏任务队列和微任务队列,同时可被外界直接调用。
  static pushMicro(fn) {
    this.#microQueue.push(fn);
  }
  static pushMacro(fn) {
    this.#macroQueue.push(fn);
  }

  // 代替原worker函数
  run(callback) {
    function handleQueue() {
      while (MyWorker.#microQueue.length) {
        const fn = MyWorker.#microQueue.shift();
        fn();
      }

      if (MyWorker.#macroQueue.length) {
        const fn = MyWorker.#macroQueue.shift();
        fn();
      }

      if (MyWorker.#microQueue.length || MyWorker.#macroQueue.length) handleQueue();
    }

    callback();
    handleQueue();
  }

}

const main = () => {
  console.log("a");
  MyWorker.pushMicro(() => {
    console.log("b");
    // 微任务中添加了新的微任务,也会在同一轮的事件循环中一起被清空
    MyWorker.pushMicro(() => {
      console.log("c");
    });
    MyWorker.pushMacro(() => {
      console.log("d");
    });
  });
  MyWorker.pushMacro(() => {
    console.log("e");
  });
  console.log("f");
};

const worker = new MyWorker();
worker.run(main); // 输出:a, f, b, c, e, d

总结

文本主要归纳了事件循环机制的作用及其流程。结合页面帧的概念,在更高层面上具象化地展示了事件循环机制的运行。并尝试编写函数模拟JS主线程对事件循环机制的处理。

相关推荐
杨荧1 分钟前
基于爬虫技术的电影数据可视化系统 Python+Django+Vue.js
开发语言·前端·vue.js·后端·爬虫·python·信息可视化
BD_Marathon17 分钟前
IDEA中创建Maven Web项目
前端·maven·intellij-idea
waillyer24 分钟前
taro跳转路由取值
前端·javascript·taro
凌辰揽月35 分钟前
贴吧项目总结二
java·前端·css·css3·web
代码的余温36 分钟前
优化 CSS 性能
前端·css
在雨季等你1 小时前
奋斗在创业路上的老开发
android·前端·后端
yume_sibai1 小时前
Vue 生命周期
前端·javascript·vue.js
阿廖沙10241 小时前
前端不改后端、不开 Node,彻底搞定 Canvas 跨域下载 —— wsrv.nl 野路子实战指南
前端
讨厌吃蛋黄酥1 小时前
🌟 React Router Dom 终极指南:二级路由与 Outlet 的魔法之旅
前端·javascript
花颜yyds1 小时前
three.js学习
前端·three.js