JavaScript 模拟事件循环

178 阅读7分钟

前言

本文重点是要掌握 JavaScript 事件循环(Event Loop)的处理步骤 , 它是异步任务执行机制的核心 。

本文分为两部分讲解 :

  • 第一 : 回顾 JavaScript 中的任务类型 , 理解它们是如何调度的 ; 通过一个案例分析出任务的执行顺序和时机 ;
  • 第二 : 我们手搓一段简化版的代码来 模拟浏览器中的事件循环机制 , 更深入地理解事件循环的工作原理

一、JavaScript 任务类型有哪些,它们是如何调度的 ?

JavaScript 是一门单线程的语言 , 它通过区分同步任务和异步任务来管理执行流程 ; JavaScript 引入异步任务是为了避免阻塞 , 提高用户体验, 优化性能 ;

任务类型

同步任务 : 在主线程上按顺序执行, 必须等待前一个任务完成才能开始下一个。

异步任务 : 不直接在主线程上执行, 而是先放入"任务队列"。只有当主线程空闲时, 才会从任务队列中取出异步任务放到主线程中去执行 , 异步任务可以简单的分为宏任务微任务【具体的每个浏览器实现都不一致,任务队列会很多】。

宏任务

包含整个脚本的初始执行、事件(如点击、键盘输入)、setTimeoutsetInterval、I/O、UI 渲染等。这些任务在事件循环的不同阶段执行。

微任务 是更为轻量级的任务, 它们在当前宏任务结束后, 但在下一个宏任务开始之前执行。典型的微任务包括:

Promise.then.catch等 、async/await 的后续操作 , Ajax请求MutationObserverprocess.nextTick( Node.js 环境特有)

执行步骤

  1. 主任务执行
  • 初始化执行上下文:
    • 首次执行全局上下文中的代码 , 这是一个同步过程
    • 在这个阶段 , 变量声明、函数声明等被处理
  • 队列准备:
    • 在执行过程中 , 如果遇到异步操作(如 setTimeout、Promise 等) , 相关任务会被添加到宏任务队列或微任务队列。
    • 宏任务队列示例 : Macro = []  , (此处用数组模拟队列做展示)
    • 微任务队列示例 : Micro = []
  • 同步代码执行结束后 , 当主线程空闲时 , 优先处理微任务
  1. 事件循环开始
  • 微任务调度

    • 宏任务执行完毕 , 立即 清空微任务队列
    • 执行过程中新产生的宏任务和微任务 , 分别加入各自的队列
    • 注意 :本轮执行过程中新添加的微任务也会在本轮清空过程中执行
  • 宏任务调度

    • 微任务执行完毕后 , 从宏任务队列中 取出一个宏任务执行
    • 执行过程中新产生的宏任务和微任务 , 分别加入各自的队列
    • 每执行完一个宏任务 , 就需要去清空微任务队列
  1. 循环迭代

    • 重复【微任务调度 - 宏任务调度】的过程 , 直到两个队列均为空 , 循环结束

任务调度案例分析

console.log("--主任务--");
Promise.resolve().then(() => {
  console.log("--微任务1--");
});
setTimeout(() => {
  console.log("--宏任务1--");
  Promise.resolve()
    .then(() => {
      console.log("--微任务3--");
    })
    .then(() => {
      console.log("--微任务5--");
    });
  setTimeout(() => {
    console.log("--宏任务2--");
  }, 0);
  Promise.resolve().then(() => {
    console.log("--微任务4--");
  });
  setTimeout(() => {
    console.log("--宏任务3--");
  }, 0);
}, 0);
Promise.resolve().then(() => {
  console.log("--微任务2--");
});
console.log("--主任务1--");

分析过程 :

  1. 执行全局上下文中的同步代码
  • 输出 "--主任务--"
  • Micro: [微任务1]
  • Macro: [宏任务1]
  • Micro: [微任务1,微任务2]
  • 输出 "--主任务1--"
  • ----- 同步代码执行结束 , 开启事件循环去处理异步任务 -----
  1. 清空微任务队列
  • 输出 "--微任务1--"
  • 输出 "--微任务2--"
  1. 取出一条宏任务执行
  • 输出 "--宏任务1--"
  • Micro: [微任务3]
  • Macro: [宏任务2]
  • Micro: [微任务3,微任务4]
  • Macro: [宏任务2,宏任务3]
  1. 清空微任务队列
  • 输出 "--微任务3--"
  • Micro:[微任务4,微任务5]
  • 输出 "--微任务4--"
  • 输出 "--微任务5--" (本轮执行过程中新添加的微任务也会在本轮清空过程中执行)
  1. 取出一条宏任务执行
  • 输出 "--宏任务2--"
  1. 取出一条宏任务执行
  • 输出 "--宏任务3--"

二、手搓 JavaScript 代码模拟事件循环

我们手搓一段简化版的代码来模拟浏览器中的事件循环机制 , 包括宏任务 Macrotask和微任务Microtask的处理流程。通过此代码 , 我们可以更深入地理解事件循环的工作原理 , 并学习如何手动实现一个简化版的事件循环模型。

代码结构与功能

定义任务队列

// macros 和 micros 数组分别用于存储宏任务和微任务的回调函数。
const macros: Array<() => void> = [],
  micros: Array<() => void> = [];
  • 宏任务队列 (macros):存储所有宏任务的回调函数
  • 微任务队列 (micros):存储所有微任务的回调函数

使用数组简单模拟队列,保证先进先出规则

定义执行宏微任务的函数

function runMicro() {
  let func = micros.shift();

  do {
    func?.();
    func = micros.shift();
  } while (func);
}

function runMacro() {
  let func = macros.shift();

  do {
    func?.();
    runMicro();
    func = macros.shift();
  } while (func);
}

微任务执行的时候,我们只需要清空队列即可,但是到了宏任务执行的时候,我们就需要执行一个宏任务就清空一次微任务队列

这里用的 shift , 从头部取出一个数据,然后添加的时候我们只用push,这就简单实现了队列的先进先出原则

执行队列的代码稍微有点重复,我们可以进行简单优化一下

添加一个执行任务的 run函数

  • 功能:遍历并执行给定任务数组中的所有函数。可选地 , 在每个任务执行后调用一个回调函数,处理宏任务的特殊性
  • 参数:tasks - 待执行的任务数组 ; callback- 可选的每次执行后调用的回调
//  run 函数执行给定的任务数组。它会一直执行数组中的任务, 直到数组为空。如果提供了回调函数, 它会在每个任务执行后调用。
function run(tasks: Array<() => void>, callback?: () => void) {
  let func = tasks.shift();

  do {
    func?.();
    callback?.();
    func = tasks.shift();
  } while (func);
}

function runMicro() {
  run(micros);
}

function runMacro() {
  run(macros, runMicro);
}

事件循环核心逻辑 eventLoop

function eventLoop() {
  runMicro();

  runMacro();

  requestIdleCallback(eventLoop);
}
  • 先执行微任务
  • 接着执行宏任务 【单个宏任务执行结束后就会清空微任务队列,这个操作我们在runMacro中做了】
  • 使用 requestIdleCallback 注册 eventLoop, 确保在浏览器空闲时重复执行该方法 , 模拟持续的事件循环

添加任务到宏微任务的队列中

// pushMacroTask 和 pushMicroTask 函数分别用于向宏任务和微任务队列中添加新的任务。
function pushMacroTask(...tasks: Array<() => void>) {
  macros.push(...tasks);
}

function pushMicroTask(...tasks: Array<() => void>) {
  micros.push(...tasks);
}
  • pushMacroTask:向宏任务队列添加任务
  • pushMicroTask:向微任务队列添加任务

这里一定要添加到数组的尾部,所以用的push, 至于添加多个,可以要可不要

启动事件循环

通过 requestIdleCallback(eventLoop) 开启首次循环

eventLoop.ts

const macros: Array<() => void> = [],
  micros: Array<() => void> = [];

function run(tasks: Array<() => void>, callback?: () => void) {
  let func = tasks.shift();

  do {
    func?.();
    callback?.();
    func = tasks.shift();
  } while (func);
}

function runMicro() {
  run(micros);
}

function runMacro() {
  run(macros, runMicro);
}

function eventLoop() {
  runMicro();

  runMacro();

  requestIdleCallback(eventLoop);
}

function pushMacroTask(...tasks: Array<() => void>) {
  macros.push(...tasks);
}

function pushMicroTask(...tasks: Array<() => void>) {
  micros.push(...tasks);
}

requestIdleCallback(eventLoop);

export { pushMacroTask, pushMicroTask };

eventLoop.ts 在案例中使用

引入 eventLoop.js 后 , 就可以直接使用 pushMacroTaskpushMicroTask 方法向任务队列中添加宏任务和微任务了 ; 下面这个案例的执行步骤和最终输出结果同上文中的案例一样 。

import { pushMacroTask, pushMicroTask } from "./eventLoop.js";

console.log("--主任务--");
pushMicroTask(function () {
  console.log("--微任务1--");
});
pushMacroTask(function () {
  console.log("--宏任务1--");
  pushMicroTask(function () {
    console.log("--微任务3--");
    pushMicroTask(function () {
      console.log("--微任务5--");
    });
  });
  pushMacroTask(function () {
    console.log("--宏任务2--");
  });
  pushMicroTask(function () {
    console.log("--微任务4--");
  });
  pushMacroTask(function () {
    console.log("--宏任务3--");
  });
});
pushMicroTask(function () {
  console.log("--微任务2--");
});
console.log("--主任务1--");

Git

模拟的 Git 项目地址