Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

通过两个例子再探 Event Loop #348

Open
toFrankie opened this issue Aug 11, 2024 · 0 comments
Open

通过两个例子再探 Event Loop #348

toFrankie opened this issue Aug 11, 2024 · 0 comments
Labels
2024 2024 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 前端 与 JavaScript、ECMAScript、Web 前端相关的文章

Comments

@toFrankie
Copy link
Owner

toFrankie commented Aug 11, 2024

配图源自 Freepik

提问

▼ 请问点击哪个按钮会导致页面卡死?

<article>
  <h1>蒹葭</h1>
  <p>蒹葭苍苍,白露为霜。所谓伊人,在水一方。溯洄从之,道阻且长。溯游从之,宛在水中央。</p>
  <p>蒹葭萋萋,白露未晞。所谓伊人,在水之湄。溯洄从之,道阻且跻。溯游从之,宛在水中坻。</p>
  <p>蒹葭采采,白露未已。所谓伊人,在水之涘。溯洄从之,道阻且右。溯游从之,宛在水中沚。</p>
  <p></p>
</article>

<button onclick="whileLoop()">点我</button>
<button onclick="timerLoop()">点我</button>
<button onclick="promiseLoop()">点我</button>

<script>
  function whileLoop() {
    while (true) {}
  }

  function timerLoop() {
    setTimeout(timerLoop, 0)
  }
  
  function promiseLoop() {
    Promise.resolve().then(promiseLoop)
  }
</script>

▼ 请问点击按钮红色 div 会闪吗?

<div id="box" style="width: 100px; height: 100px; background: red"></div>
<button onclick="clickme">点我</button>

<script>
  const box = document.getElementById('box')

  function clickme() {
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
  })
</script>

题目比较简单,相信大家都有答案了。

我们继续往下。

开始之前

对于 Event Loop,相信大家都有这样一张图:

接下来,将会更深入地了解 Event Loop,真的如上图所示吗?

没错,我也是从以下链接受益,并结合自己的理解,将其写下来而已。

为什么 JavaScript 设计成单线程?

最初 JavaScript 是为浏览器而设计的,旨在增强可交互性。

单线程,意味着同一时间只能做一件事情。

设想一下,有两个线程同时作用于某个元素,一个是修改样式,另一个是删除元素,如何响应呢?引入锁机制?

当时网页不如现在复杂,选择单线程是明智、合理、够用的,操作变得有序可控,且大大降低复杂度。

随着时代的发展,计算越来越复杂,单线程有点捉襟见肘,后来 HTML5 提供了 Web Worker 等 API 可主动创建新的线程运行一些复杂的运算。

什么是 Event Loop?

规范是这样定义的:

To coordinate events, user interaction, scripts, rendering, networking, and so forth.
协调事件、用户交互、脚本、渲染、网络等。

个人理解:它是让各种任务有序可控的一种机制。

用伪代码表示:

while (true) {
  task = taskQueue.pop()
  execute(task)
}

当然,实际没有这么简单,只是从简单说起,请继续往下。

它是无限循环的,7 × 24h 随时待命,直至浏览器 Tab 被关闭。

只要有任务,它就会不停地从队列中取出任务,执行任务。

在浏览器中,Event Loop 有 Window Event Loop、Worker Event Loop、Worklet Event Loop 三种,第一种是本文主要讨论的对象。当然 Node.js 也有 Event Loop 机制,但不太一样。

什么是 Task?

规范是这样定义的:

Formally, a task is a struct which has: steps, a source, a document, a script evaluation environment settings object set.
形式上,任务是一种 struct 结构体,包含 Steps、Source、Document、Script evaluation environment settings object set。

简单来说,任务就是一个包含 steps 等属性的对象,里面记录了任务的来源、所属 Document 对象、上下文等,以供后续调度。

常见的任务有:

  • 与用户发生交互而产生的所有事件回调(比如单击、文本选择、页面滚动、键盘输入等)
  • setTimeout、setInterval
  • 执行 script 块
  • I/O 操作

什么是 Task Queue?

常规意义的队列

队列(Queue)是一种基本的数据结构,遵循先进先出(FIFO, First In First Out)的原则。在队列中,最先插入的元素最先被移除,类似于排队等候的场景。

  • 入队(Enqueue):将一个元素添加到队列的尾部。
  • 出队(Dequeue):从队列的头部移除一个元素,并返回该元素。

Event Loop 中的任务队列

规范中提到:

An event loop has one or more task queues.
事件循环有一个或多个任务队列。

Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.'
任务队列是集合,而不是队列,因为事件循环处理模型从所选队列中获取第一个可运行的任务,而不是使第一个任务出队。

The microtask queue is not a task queue.
微任务队列不是任务队列。

前面提到,task 是有 source 的,比如来自鼠标点击等。排队时,同 source 的 task 会被放入与该 source 相关的 task queue 里。假设鼠标事件的任务要优于其他任务,Event Loop 就可以在对应 source 的 task queue 中取出任务优先执行。规范里 Event Loop 执行步骤并没有明确定义“出队”的规则,它取决于浏览器的实现。

Let taskQueue be one such task queue, chosen in an implementation-defined manner.

现在 Event Loop 用伪代码表示是这样的:

while (true) {
  queue = getNextQueue()
  task = queue.getFirstRunnableTask()
  execute(task)
}

😲 在此之前,我的认知是:一个 Event Loop 里有且只有一个任务队列,且它是一个常规意义的队列。虽说如此,如果只想了解 Event Loop 主要执行顺序,不深入浏览器究竟维护了多少个任务队列、浏览器如何决定下一任务,按原来的理解也问题不大。

什么时候重绘页面?

总不能只执行任务,不更新 DOM 吧。

本质上,网页就是给人看的,与人交互的,所以用户体验非常重要。假设任务队列有源源不断的任务产生,如果 Event Loop 只会一直循环执行队列里的任务,而不去更新页面,用户体验是非常糟糕的。

请问浏览器什么时候会更新页面?

浏览器是非常聪明的,没必要的工作它不会做。以 60Hz 屏幕为例,每秒刷新 60 次,约 16.7ms 刷新一次。只要满足该刷新频率的,显示就算是流畅的,因为再快的刷新频率对肉眼来说也不会有明显的感知。也就是说每 16.7ms 可获得一次渲染机会(rendering opportunity),这样浏览器就知道要更新 DOM 了。

假设一个任务耗时 3 ~ 5ms,远没到 16.7ms,对于浏览器来说,此时更新 DOM 是没有必要的,因此也不会获得一个渲染机会。相反地,如果一个任务执行超过 16.7ms,呈现出来的效果有可能是卡顿的。

注意,规范中不强制要求使用任何特定模型来选择渲染机会。但例如,如果浏览器尝试实现 60Hz 刷新率,则渲染机会最多每 60 秒出现一次(约 16.7ms)。如果浏览器发现 navigable 无法维持此速率,则该 navigable 可能会下降到更可持续的每秒 30 个渲染机会,而不是偶尔丢帧。类似地,如果 navigable 不可见,浏览器可能会决定将该页面降低到每秒 4 个渲染机会,甚至更少。

React 16 可中断的调度机制,就是为了可以执行优先级更高的任务(比如更新 DOM),以解决某些场景下页面卡顿的问题。

因此,一个任务执行完,如果有渲染机会先更新 DOM,接着才执行下一个任务。

现在 Event Loop 用伪代码表示是这样的:

while (true) {
  queue = getNextQueue()
  task = queue.getFirstRunnableTask()
  execute(task)
  
  if (hasRendringOpportunity()) repaint()
}

什么是 Microtask?

还没完,还没完...

规范中提到:

Each event loop has a microtask queue, which is a queue of microtasks, initially empty.

A microtask is a colloquial way of referring to a task that was created via the queue a microtask algorithm.

The microtask queue is not a task queue.

好,我们重新捋一下:

  • 一个 Event Loop 有一个或多个 task queue。
  • 一个 Event Loop 有且仅有一个 microtask queue。
  • task 是一个由特定属性的对象(规范中称为 struct)。
  • microtask 只是一种通俗的说法,它是通过特定算法创建的 task。
  • task queue 是一组 task 的集合,并不是队列
  • microtask 是常规意义的队列,遵循先进先出。
  • microtask queue 不是 task queue,前者是队列,后者是集合。

为便于区分理解,本文暂且将以下规范术语口语化(但注意,这种说法不一定准确)。

  • task:(宏)任务
  • task queue:(宏)任务队列
  • microtask:微任务
  • microtask queue:微任务队列

有哪些微任务?

在 JavaScript 里会产生微任务的大概有:

  • queueMicrotask(Window 或 Web Worker)
  • Promise 回调
  • MutationObserver 回调
  • Object.observe(Deprecated)

什么时候执行微任务?

从规范(Processing model)可知,只要 Event Loop 存在,就必须不断执行以下步骤:

  1. 从(宏)任务队列取出一个 task
  2. 执行该 task
  3. 执行微任务检查点(microtask checkpoint
    1. 如果检查点标志为真(初始值为 false),则返回(跳出微任务执行)。
    2. 将检查点标志设为 true
    3. 如果当前 Event Loop 里的微任务队列不为空,将一直循环直至微队列为空:
      1. 在微任务队列里取出的第一个微任务
      2. 执行微任务
    4. 将检查点标志设为 false
  4. 重复上述步骤

以上为简化后的步骤。

至此,文章开头的提问之一就有答案。由于它在执行微任务的过程中不停地产生新的微任务,因此将会在 3.iii 陷入死循环,自然页面就“卡死”了。

跟 task 的一些区别

请注意,无论是(宏)任务,还是微任务,执行过程中都可能产生“新”的(宏)任务或微任务。它们的执行顺序是有区别的:

  • (宏)任务执行时产生的新的(宏)任务,在下一轮或以后执行。
  • (宏)任务执行时产生的新的微任务,在当前(宏)任务执行完之后、更新 DOM 或下一轮(宏)任务之前执行。
  • 微任务执行时产生的新的(宏)任务,在下一轮或以后执行。
  • 微任务执行时产生的新的微任务,马上放入微任务队列,直到所有微任务队列执行完,才到更新 DOM 或执行下一轮(宏)任务。

现在 Event Loop 用伪代码表示是这样的:

while (true) {
  queue = getNextQueue()
  task = queue.getFirstRunnableTask()
  execute(task)
  
  while (microtaskQueue.hasTask() {
    microtask = microtaskQueue.pop()
    excute(microtask)
  }
  
  if (hasRendringOpportunity()) repaint()
}

什么是 requestAnimationFrame?

噢,还没完,还有一个 requestAnimationFrame,其回调函数会在页面重绘之前调用。

当浏览器检测到有渲染机会,会更新 DOM,具体执行顺序如下:

  1. 执行 requestAnimationFrame 回调
  2. 合成:计算样式,将 DOM Tree 和 CSSOM Tree 合成一个 Render Tree(Attachment)
  3. 重排:以确定每个节点所占空间、所在位置等(Layout)
  4. 重绘:以设置颜色等(Paint)

比较坑的是,Edge 和 Safari 将 requestAnimationFrame 回调放到 Paint 后面执行,这是非标准做法。也就是说,如果回调中涉及样式,用户要在下一帧才能看到变化。

Safari 是否已修复,待验证。

除了有 task queue(集合)、microtask queue(队列),还有一个 animation frame callbacks,它是一个 ordered map(映射)。

将 animation frame callbacks 简单理解为“队列”也不是不行,因为根据 run the animation frame callbacks 可以看到,也是从第一个开始遍历执行。

同样地,执行 callbacks 的过程中产生新的 callback,它们会放到下一次 Loop 执行,这点跟微任务是不一样的。

现在 Event Loop 用伪代码表示是这样的:

while (true) {
  queue = getNextQueue()
  task = queue.getFirstRunnableTask()
  execute(task)
  
  while (microtaskQueue.hasTask() {
    microtask = microtaskQueue.pop()
    excute(microtask)
  }
  
  if (hasRendringOpportunity()) {
    callbacks = animationFrameCallbacks.spliceAll()
    for (callback in callbacks) {
      execute(callback)
    }
    
    repaint()
  }
}

Node.js Event Loop 是怎样的呢?

相比之下,Node.js 里没有以下这些:

  • 没有 <script> 解析
  • 没有用户交互
  • 没有 DOM
  • 没有 requestAnimationFrame

Node.js 特有的是:

  • setImmediate
  • process.nextTick

Node.js 的 Event Loop 由 libuv 实现,包含以下阶段:

   ┌───────────────────────────┐
┌─>│           timers          │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │     pending callbacks     │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
│  │       idle, prepare       │
│  └─────────────┬─────────────┘      ┌───────────────┐
│  ┌─────────────┴─────────────┐      │   incoming:   │
│  │           poll            │<─────┤  connections, │
│  └─────────────┬─────────────┘      │   data, etc.  │
│  ┌─────────────┴─────────────┐      └───────────────┘
│  │           check           │
│  └─────────────┬─────────────┘
│  ┌─────────────┴─────────────┐
└──┤      close callbacks      │
   └───────────────────────────┘

The Node.js Event Loop

  • timers:执行 setTimeout、setInterval 的回调。
  • pending callbacks:执行一些系统操作的回调,比如 TCP 错误等。
  • idle, prepare:仅在内部使用。
  • poll:几乎所有异步回调都在这个阶段执行,除 setTimeout、setInterval 和 setImmediate 之外。
  • check:执行 setImmediate 的回调。
  • close callbacks:执行关闭事件,比如 socket 或 handle 突然关闭,会发出 close 事件。

在 Node.js 中,还有一个特殊的 process.nextTick() 方法。技术上,它不属于事件循环的一部分。当你在某个阶段调用时,传递给它的所有回调将在当前阶段执行完之后,下一个阶段执行之前执行。如果递归调用它,是会造成死循环的。

用伪代码表示是这样的:

while (tasksAreWaiting()) {
  queue = getNextQueue()
  
  while (queue.hasTask()) {
    task = queue.pop()
    execute(task)
    
    while (nextTickQueue.hasTask()) {
      callback = nextTickQueue.pop()
      excute(callback)
    }
    
    while (promiseQueue.hasTask() {
      promise = promiseQueue.pop()
      excute(promise)
    }
  }
}

Worker Event Loop 又是怎样的呢?

它更简单:

  • 没有 <script> 解析
  • 没有用户交互
  • 没有 DOM(Worker 不能直接操作 DOM)
  • 没有 requestAnimationFrame
  • 没有 process.nextTick
  • 没有 setImmediate

且线程之间相互独立,每个线程都有自己的 Event Loop,互不干扰。

现在 Event Loop 用伪代码表示是这样的:

while (true) {
  task = taskQueue.pop()
  execute(task)
  
  while (microtaskQueue.hasTask() {
    microtask = microtaskQueue.pop()
    excute(microtask)
  }
}

但注意,如果在 Web Worker 的线程向主线程传递消息,这个消息对于 Window Event Loop 来说属于一个 task,它仍受主线程的 Event Loop 控制,该排队还得排队。

思考题

先回到文章开头的题目。

点击哪个按钮会导致页面卡死?

<article>
  <h1>蒹葭</h1>
  <p>蒹葭苍苍,白露为霜。所谓伊人,在水一方。溯洄从之,道阻且长。溯游从之,宛在水中央。</p>
  <p>蒹葭萋萋,白露未晞。所谓伊人,在水之湄。溯洄从之,道阻且跻。溯游从之,宛在水中坻。</p>
  <p>蒹葭采采,白露未已。所谓伊人,在水之涘。溯洄从之,道阻且右。溯游从之,宛在水中沚。</p>
  <p></p>
</article>

<button onclick="whileLoop()">点我</button>
<button onclick="timerLoop()">点我</button>
<button onclick="promiseLoop()">点我</button>

<script>
  function whileLoop() {
    while (true) {}
  }

  function timerLoop() {
    setTimeout(timerLoop, 0)
  }
  
  function promiseLoop() {
    Promise.resolve().then(promiseLoop)
  }
</script>

答案:whileLoop、promiseLoop 会导致页面卡死,timerLoop 则不会。

whileLoop 分析:点击按钮,产生一个 task,进入 task queue 排队。轮到它的时候,执行 whileLoop() 方法,里面是一个无线循环的 while 语句,因此这个 task 会一直执行下去,且致使后面的 task、更新 DOM 等永远无法执行。页面就卡死了。

timerLoop 分析:点击按钮,产生一个 task,进入 task queue 排队。轮到它的时候,执行 timerLoop() 方法,又产生一个 task 并放入 task queue。执行完之后,如果有 rendering opportunity 会先更新 DOM,完了执行进行下一轮。尽管 timerLoop 里不停地产生新的 task,但用户仍然通过文本选择、页面滚动等产生其他 task 进入到 task queue 进行排队。因此页面是不会呈现卡死状态的。

promiseLoop 分析:点击按钮,产生一个 task,进入 task queue 排队。轮到它的时候,执行 promiseLoop() 方法,其中 Promise.resolve() 产生一个 microtask 并放入 microtask queue。当 task 执行完,接着从 microtask queue 里取出 microtask 执行,即执行 then(promiseLoop),它有又产生新的 microtask,所以 microtask queue 就一直有任务存在,因此会陷入死循环,致使后面的 task、更新 DOM 等永远无法执行。

它们会闪烁吗?

你有没有担心过这些代码会“闪”一下?

document.body.appendChild(element)
element.style.display = 'none'

当然,实际中更多是先设置样式再 appendChild,但效果是一样的。

请问点击按钮红色块会闪烁吗?

<div id="box" style="width: 100px; height: 100px; background: red"></div>
<button id="btn">Click me</button>

<script>
  const btn = document.getElementById('btn')
  const box = document.getElementById('box')

  btn.addEventListener('click', () => {
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    box.style.display = 'none'
    box.style.display = 'block'
    // ...
  })
</script>

CodePen

答案:都不会。

分析:上述点击事件产生一个 task(事件回调),只有执行完 task 里面的代码,才会执行后面的微任务或更新 DOM。也就是说渲染之前,实际只有最后一行的样式设置是起作用的,不管中间设了多少遍,浏览器只关心最后的样式如何。

它们的执行顺序是?

以下示例,一个按钮绑定了两个 click 事件:

<button id="btn">Click me</button>

<script>
  const btn = document.getElementById('btn')

  btn.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('microtask 1'))
    console.log('listener 1')
  })

  btn.addEventListener('click', () => {
    Promise.resolve().then(() => console.log('microtask 2'))
    console.log('listener 2')
  })
  
  // btn.click()
</script>

现触发 click 事件的方式有两种:一个是通过鼠标点击触发,另一个是通过 btn.click() 触发。这两种方式的执行顺序一样吗?

通过鼠标点击的结果是:

listener 1
microtask 1
listener 2
microtask 2

通过 btn.click() 的结果是:

listener 1
listener 2
microtask 1
microtask 2

原因分析:通过与用户交互而触发的事件,其监听器是异步调用的,而通过 btn.click() 触发,会同步派发事件,并以合适的顺序同步地调用监听器。

对于“鼠标”点击:由于 btn 注册了两个 click 监听器,鼠标点击一次,产生两个 task 进入 task queue,先后执行,因此得到前面的结果。

对于 btn.click() 模拟点击:当执行到 btn.click() 时,按顺序同步执行两个监听器。

The dispatchEvent() method of the EventTarget sends an Event to the object, (synchronously) invoking the affected event listeners in the appropriate order. The normal event processing rules (including the capturing and optional bubbling phase) also apply to events dispatched manually with dispatchEvent().

  1. 执行到第一个监听器 Promise.resolve() 时产生一个 microtask 入队到 microtask queue。
  2. 接着打印 listener 1。
  3. 注意,此时调用栈里还没执行完,所以接着并不是立马执行 microtask queue 里的任务。而是接着执行第二个监听器。同样地,它又产生一个 microtask 入队到 microtask queue。
  4. 接着打印 listener 2。
  5. 此时调用栈空了,接着从 microtask queue 取出任务,逐个执行,因此先后打印 microtask 1、microtask 2。

可以通过 event.isTrusted 来区分两种触发方式,用户与浏览器交互而产生的事件 isTrustedtrue,使用 JavaScript 来模拟点击等事件触发的 isTrustedfalse

References

@toFrankie toFrankie added 2024 2024 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 前端 与 JavaScript、ECMAScript、Web 前端相关的文章 labels Aug 11, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
2024 2024 年撰写 JS 与 JavaScript、ECMAScript 相关的文章 前端 与 JavaScript、ECMAScript、Web 前端相关的文章
Projects
None yet
Development

No branches or pull requests

1 participant