事件的循环机制

98 阅读16分钟

前言

首先我们了解事件的循环机制前得先了解## 浏览器的进程模型

浏览器的进程模型

何为进程

  • 定义:进程是指在系统中正在运行的一个应用程序,是操作系统进行资源分配和调度的基本单位。每个进程都有自己独立的地址空间、内存、数据栈以及其他用于跟踪执行的辅助数据结构。

  • 特点

    • 独立性:进程之间相互独立,它们拥有各自独立的资源,一个进程的崩溃通常不会影响到其他进程。
    • 并发性:多个进程可以在同一时间内并发执行,操作系统会通过调度算法在多个进程之间分配 CPU 时间片,让它们交替执行,从而实现宏观上的同时运行。
    • 动态性:进程从创建、运行到结束,会经历不同的状态变化,如就绪、运行、阻塞等。

何为线程

  • 定义:线程是进程中的一个执行单元,是操作系统能够进行运算调度的最小单位。它可以与同进程中的其他线程共享进程的资源,如内存空间、文件描述符等,但拥有自己独立的栈空间和程序计数器。

  • 特点

    • 轻量级:创建和销毁线程的开销相对较小,切换速度也比进程快得多。
    • 共享性:同一进程内的线程可以方便地共享进程的资源,这使得它们之间的通信和数据交换更加高效。
    • 并发性:多个线程可以在同一进程内并发执行,从而实现更细粒度的并行处理,提高程序的执行效率。

进程和线程的关系与占据空间

描述可能不准确意思差不多

  • 一个进程包含多个线程 且必须有一个及以上
  • 每个进程默认互不干扰 都占据一块空间
  • 而一个进程中的多个线程 在一块空间
  • 每个进程中又有多个任务
  • 进程提供了资源的分配和隔离环境,而线程在进程的环境中负责具体的执行任务。

浏览器中的进程和线程

而浏览器中为了防止连环崩溃 也就是为了互不干扰 ,当启动浏览器后,它会自动启动多个进程

我们一般使用的就是浏览器进程 网络进程 渲染进程 当然还有插件进程 GPU进程不过不需要详细了解 每个标签页就是一个渲染进程

一些插件也会占用 屏幕截图 2025-06-12 221445.png

  • 浏览器进程

负责浏览器这个应用的一些展示如回退前进按钮上方标签页刷新按钮等 用户交互如回退 查看控制台 查看点击上方标签页

和子进程管理 如渲染进程 网络进程同时浏览器内部会启动多个线程处理不同的任务

  • 网络进程

负责加载网络资源。网络进程内部会启动多个线程来处理不同的网络任务。

  • 渲染进程(前端基础重点)

渲染进程启动后,会开启一个渲染主线程,主线程负责执行Html 、CSS、js代码。

默认情况下,浏览器会为每个标签页开启一个新的渲染进程,以保证不同的标签之间不相互影响

渲染主线程是如何工作的?

  • 解析HTML
  • 解析CSS
  • 计算样式
  • 布局
  • 处理图层
  • 每秒把页面画60次
  • 执行全局js代码
  • 执行事件处理函数
  • 执行计时器的回调函数

.....

  1. 解析 HTML(HTML Parsing)
  • 主线程从网络或缓存获取 HTML 字节数据,按编码(如 UTF-8)转换为字符。
  • 通过词法分析将字符解析为标记(Tokens),如、等。
  • 将标记转换为 DOM 节点,构建 DOM 树(Document Object Model)。
  1. 解析 CSS(CSS Parsing)
  • 并行下载 CSS 资源,解析为 CSS 对象模型(CSSOM)树。
  • CSS 规则按选择器优先级(如内联样式 > ID 选择器 > 类选择器)组织。
  1. 计算样式(Style Calculation)
  • 将 CSS 规则应用到 DOM 节点,计算每个元素的最终样式(包括继承、层叠规则)。
  • 解析相对值(如em、%)为绝对值(如px)。
  1. 布局(Layout)
  • 根据元素的盒模型(宽高、边距、定位等),计算每个元素在视口中的位置和尺寸。
  • 创建包含坐标信息的布局树(Layout Tree),不包含不可见元素(如display: none)。
  1. 处理图层(Layer Compositing)
  • 将布局树分解为多个渲染层(Render Layers),基于:
  • 3D 变换(transform: translateZ())
  • 透明度(opacity < 1)
  • CSS 滤镜(filter)
  • 滚动容器等。
  • 为每个图层生成绘制指令(Paint Orders)。
  1. 绘制(Painting)
  • 将绘制指令转换为位图(Bitmap),通常由合成线程(Compositor Thread)完成。
  • 主线程仅生成绘制指令,实际绘制可在 GPU 加速下进行。
  1. 合成(Compositing)
  • 合成线程将多个图层的位图按正确顺序合并(如处理层叠上下文z-index)。
  • 结果发送到显示器,通常以 60 帧 / 秒(60fps)的频率刷新,即每 16.6ms 更新一次。

JavaScript 执行与渲染的关系

全局 JS 代码:在 DOM 解析过程中遇到

事件处理函数:在事件触发(如点击)时执行,可能修改 DOM 或 CSSOM。

计时器回调:setTimeout/setInterval的回调在主线程空闲时执行,可能延迟。

关键注意点

  • 渲染阻塞:

    CSS 加载会阻塞渲染(除非标记为async
    ​
    无defer/asyncJS 会阻塞 DOM 解析
    ​
    重排(Reflow)与重绘(Repaint):
    ​
    修改布局属性(如宽高边距)触发重排 + 重绘
    ​
    修改非布局属性(如颜色透明度)仅触发重绘
    
流程图

exported_image.png HTML 字节 → 字符 → 标记 → DOM 树 ↓

CSS 字节 → 字符 → CSSOM 树 → 样式计算 → 布局树 → 图层处理 → 绘制指令 → 合成 → 屏幕

JS 修改 DOM/CSSOM

渲染进程不采用多个线程来处理所有问题,主要有以下几方面原因:

  1. 避免复杂的线程同步和交互问题
  • 数据一致性维护困难:多个线程同时访问和修改 DOM、CSSOM 等共享数据结构时,容易出现数据不一致的情况。例如,一个线程正在修改某个元素的样式,另一个线程同时尝试读取该元素的样式,可能会得到不一致的结果。为了保证数据的一致性,需要使用复杂的锁机制或其他同步手段,但这会增加开发的复杂性和性能开销。

  • 渲染结果不确定性:多个线程并发执行渲染操作,可能导致渲染结果的不确定性。因为线程的执行顺序是不可预测的,不同的执行顺序可能会导致不同的渲染结果,这给调试和维护带来了极大的困难。

2.避免性能开销增加

  • 线程创建和销毁开销:创建和销毁线程需要消耗一定的系统资源和时间。如果频繁地创建和销毁线程来处理渲染任务,会增加系统的负担,降低整体性能。

  • 上下文切换开销:多个线程之间的切换需要保存和恢复线程的上下文信息,这也会消耗一定的时间和资源。在渲染过程中,如果线程切换过于频繁,会导致 CPU 时间浪费在上下文切换上,而不是实际的渲染工作,从而影响性能。

3.符合单线程编程模型的特点

  • JavaScript 语言特性:JavaScript 是单线程语言,在浏览器环境中,它主要用于操作 DOM 和处理用户交互等任务。单线程的执行模型使得 JavaScript 代码的执行顺序是确定的,开发者可以更容易地理解和控制代码的执行流程。如果渲染进程采用多个线程来处理所有问题,就需要对 JavaScript 的执行模型进行重大改变,这会给开发者带来很大的困扰,也不利于现有代码的兼容性。

  • 事件驱动编程模型:浏览器的渲染过程是基于事件驱动的,例如用户的点击、滚动等操作会触发相应的事件,然后由渲染进程来处理这些事件。单线程的事件循环机制可以很好地处理这些事件,按照顺序依次执行事件处理函数,保证了事件处理的正确性和一致性。如果采用多个线程来处理事件,可能会导致事件处理的顺序混乱,出现不可预测的结果。

问题

那么如果这么多的任务,主线程遇到的问题:如何调度任务

比如:

  • l 我正在执行一个js函数,执行到一半的时候用户点击了按钮,我应该立即去执行点击事件的处理函数吗?
  • l 我正在执行一个js函数,执行到一半的时候某个计时器到达了时间,我该立即去执行他的回调吗
  • l 浏览器进程通知我’用户点击了按钮,于此同时,某个计时器也到达了时间,我应该处理哪一个呢
    ```
<!DOCTYPE html>
<html>
<head>
    <title>JavaScript事件执行机制</title>
</head>
<body>
    <button id="myButton">点击我</button>
    
    <script>
        // 情况1:函数执行过程中用户点击按钮
        function longRunningFunction() {
            alert("开始执行长时间运行的函数");
            
            // 模拟耗时操作
            let start = Date.now();
            while (Date.now() - start < 5000) {
                // 这会阻塞主线程5秒
            }
            
            alert("长时间运行的函数执行完毕");
        }
        
        // 情况2:函数执行过程中计时器到达时间
        function timerDuringFunction() {
            alert("开始执行函数");
            
            // 设置一个2秒后触发的计时器
            setTimeout(() => {
                alert("计时器回调执行 - 会在函数执行完毕后才执行");
            }, 2000);
            
            // 模拟耗时操作
            let start = Date.now();
            while (Date.now() - start < 5000) {
                // 这会阻塞主线程5秒
            }
            
            alert("函数执行完毕");
        }
        
        // 情况3:同时有按钮点击和计时器事件
        function concurrentEvents() {
            // 设置一个2秒后触发的计时器
            setTimeout(() => {
                alert("计时器事件触发");
            }, 2000);
            
            alert("点击按钮来触发点击事件");
        }
        
        // 为按钮添加事件监听器
        document.getElementById('myButton').addEventListener('click', function() {
            // 这里可以选择执行哪个情况的函数
            // longRunningFunction();
            // timerDuringFunction();
            concurrentEvents();
        });
    </script>
</body>
</html>
解决办法

渲染主线程想出了一个绝妙的主意来处理这个问题:排队

  1. 在最开始的时候,渲染主线程会进入一个无限循环
  2. 每一次循环会检查消息队列中是否有任务存在,如果有,就取出第一个任务执行,等执行完成后进入下一次循环,如果没有,则进入休眠状态
  3. 其他所有线程(包括其他进程的线程)可以随时向消息队列添加任务。新任务会加到消息队列的末尾,在添加新任务时,如果主线程是休眠状态,则会将其唤醒以继续循环拿取任务

image.png 在浏览器的事件循环机制中,微任务 > 宏任务 > 渲染的执行顺序是保证高效渲染和响应性的关键规则。下面从执行机制、典型场景和实际应用三个维度详细解析 一、执行机制解析

1. 微任务(Microtask)

  • 定义:高优先级的异步任务,在当前任务执行结束后立即执行,直到微任务队列清空

  • 触发方式

    • Promise.then/catch/finally
    • MutationObserver(DOM 变化监听)
    • process.nextTick(Node.js)
  • 执行时机

    • 每个宏任务执行后
    • 渲染之前(如果微任务队列在渲染周期前被触发)

2. 宏任务(Macrotask)

  • 定义:低优先级的异步任务,按入队顺序执行,每次只从队列中取出一个任务。

  • 触发方式

    • setTimeout/setInterval
    • DOM 事件(如点击、滚动)
    • requestAnimationFrame(特殊宏任务,在渲染前执行)
    • I/O 操作(如网络请求)
  • 执行时机

    • 执行栈为空时从宏任务队列取出一个任务执行

3. 渲染(Render)

  • 触发条件

    • 满足浏览器的刷新频率(通常 60fps,即每 16.6ms 一次)
    • 执行栈和微任务队列为空
  • 执行时机

    • 在一轮事件循环的微任务清空后,且满足渲染周期时触发

假设一个任务需要 3.5 个 16.6ms 周期(约 58.1ms):

  • 前 3 个周期(50ms)用于执行任务,第 4 个周期开始时,剩余 8.3ms 不足以完成一次完整渲染(因渲染本身需一定时间),导致该周期浪费,最终表现为帧率下降至约 30fps(每 33.2ms 渲染一次)。不过优化就不清楚了我也是只知道这个优化点

何为异步?

代码在执行过程中,会遇到一些无法处理的任务,比如:

  • 计算完成后需要执行的任务--setTimeout、setInterval
  • 网络通信完成后需要执行的任务--XHR、Fetch
  • 用户操作后需要执行的任务--addEventListener

如果让渲染主线程等待这些任务的时机到达,就会导致主线程长期处于阻塞的状态,从而导致浏览器卡死 image.png 渲染主线程承担着极其主要的任务,无论如何都不能阻塞!

因此,浏览器选择异步来解决这个问题

使用异步的方式,渲染主线程永不阻塞

image.png 在相应任务中通过其他线程开始计时 计时结束后再将对应任务(回调函数)放入消息队列末尾 在渲染主线程中还是一遍遍渲染并将消息队列中的第一个任务(队列特点先进先出)获取并执行

总结

JS是一门单线程的语言,这是因为它运行在浏览器的渲染主线程中,而渲染主线程只有一个。 而渲染主线程承担着诸多的工作,渲染页面(一秒60帧)、执行JS,执行事件处理函数都在其中运行

如果使用同步的方式,就极可能导致渲染主线程产生阻塞,从而导致消息队列中的很多其他任务无法执行

这样一来,一方面会导致繁忙的主线白白的消耗时间,另一方面导致页面无法及时更新,给用户造成卡死现象

所以浏览器采用异步的方式来避免.具体做法事当某些任务发生时,比如计时器、网络、事件监听、主线程将任务交给其他线程去处理,自身结束任务的执行,转而执行后续代码,其他线程完成时,将事先传递的回调函数包装成任务,加入到消息队列的末尾排队,等待主线程调度执行。 在这种异步模式下,浏览器永不阻塞,从而最大限度的保证了单线程的流畅运行

补充信息

队列划分

  • 延时队列,优先级[中]
    1.定义与作用
    • 宏任务队列的一种:专门处理由定时器(setTimeoutsetInterval)设置的回调函数。
    • 延时执行机制:当调用setTimeout(callback, delay)时,浏览器会在指定的delay时间后,将callback放入延时队列。但实际执行时间取决于主线程是否空闲。
    2. 特点
    • 非精确计时:由于主线程可能被其他任务阻塞,定时器的回调可能会延迟执行(例如,在长任务执行期间,即使定时器时间到达,回调也会等待)。
    • 最小延迟限制:HTML 标准规定,嵌套层级超过 5 层的定时器,最小延迟为 4ms(防止浏览器被频繁唤醒)。
  • 交互队列,优先级[高]
    1. 定义与作用
    • 宏任务队列的一种:专门处理用户交互事件(如点击、滚动、键盘输入)的回调函数。
    • 高优先级特性:为了保证良好的用户体验,交互事件通常具有较高的优先级,会尽快被处理。
    2. 特点
    • 即时响应需求:用户期望界面能立即响应用户操作,因此交互队列中的任务会被优先调度。
    • 防抖(Debounce)与节流(Throttle) :为避免频繁触发(如快速连续点击),开发者通常需要手动控制事件处理频率。
  • 微队列,优先级[最高]
    1. 定义与作用
    • 独立于宏任务队列:用于处理微任务(microtasks),这些任务需要在当前宏任务执行结束后立即执行,通常用于实现异步操作的 “原子性”。
    • 执行时机:在每个宏任务执行完成后,浏览器会清空微队列中的所有任务,再执行下一个宏任务。
    2. 常见微任务类型
    • Promise 回调Promise.then()Promise.catch()中的回调函数。
    • async/awaitawait表达式会暂停当前async函数的执行,将后续代码包装成微任务。
    • MutationObserver:监听 DOM 变化的回调函数。
    • queueMicrotask() :手动将函数加入微队列的 API。
    3. 特点
    • 插队执行:微任务会在当前宏任务结束后立即执行,可能会影响后续宏任务的开始时间。
    • 用于关键异步操作:如 DOM 变更后的批量更新、Promise 链式调用的连续性 console.log('1. 主线程同步代码');
    // 微任务:Promise回调
Promise.resolve().then(() => {
  console.log('3. 微任务队列中的Promise回调');
});

// 交互事件(模拟点击)
document.querySelector('button').addEventListener('click', () => {
  console.log('4. 交互队列中的点击事件回调');
});

// 延时任务
setTimeout(() => {
  console.log('5. 延时队列中的定时器回调');
}, 0);

console.log('2. 主线程同步代码继续');
    
    
1. 主线程同步代码
2. 主线程同步代码继续
3. 微任务队列中的Promise回调
// 用户点击按钮后:
4. 交互队列中的点击事件回调
// 定时器时间到达后:
5. 延时队列中的定时器回调

有错误的地方希望指出互相学习

代码试验

出一些思考题有兴趣在评论区留下自己的答案并说明一下

题目1


async function asyncFunc() {
  console.log('asyncFunc start');
  await Promise.resolve();
  console.log('asyncFunc after await');
}

console.log('script start');
asyncFunc();
setTimeout(() => {
  console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
  console.log('promise1');
});
console.log('script end');

题目2

console.log('start');

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

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

console.log('end');

题目三

async function async1() {
  console.log('async1 start');
  await async2();
  console.log('async1 end');
}

async function async2() {
  console.log('async2');
  await Promise.resolve();
  console.log('async2 end');
}

console.log('script start');
async1();
setTimeout(() => {
  console.log('setTimeout');
}, 0);
Promise.resolve().then(() => {
  console.log('promise1');
});
console.log('script end');

题目四

async function asyncMain() {
  console.log('asyncMain start');
  await Promise.resolve();
  console.log('asyncMain end');
  setTimeout(() => {
    console.log('asyncMain timeout');
  }, 0);
}

console.log('script start');
asyncMain();
setTimeout(() => {
  console.log('global timeout');
  Promise.resolve().then(() => {
    console.log('global promise');
  });
}, 0);
Promise.resolve().then(() => {
  console.log('script promise');
});
console.log('script end');