toFrankie / blog

种一棵树,最好的时间是十年前。其次,是现在。
22 stars 1 forks source link

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

Open toFrankie opened 2 months ago

toFrankie commented 2 months ago

配图源自 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 对象、上下文等,以供后续调度。

常见的任务有:

什么是 Task Queue?

常规意义的队列

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

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.

好,我们重新捋一下:

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

有哪些微任务?

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

什么时候执行微任务?

从规范(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 的一些区别

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

现在 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 里没有以下这些: