Skip to content

事件循环

事件循环

首先我们需要知道一个概念: JS 代码分为初始化代码和回调代码。

在 JS 初始化的时候其从上到下顺序执行当前执行上下文的代码,然后遇到如 定时器、事件绑定、http 请求,会将定时器推到定时器模块、事件绑定推到事件响应模块、http 推到 HTTP 处理模块。其中如定时器会在推到定时处理模块时开始计算时间,到截止时间就直接将回调函数推到 Callback Queue。 然后在当前执行上下文执行完成后 按照先进先出的顺序 处理 Callback Queue 中的回调方法

js
setTimeout(function () {
  console.log("2000")
}, 1000)
let startDate = new Date().getTime()
for (let index = 0; index < 100000; index++) {
  document.createElement("div")
  for (let index = 0; index < 100; index++) {
    document.createElement("div")
  }
}
setTimeout(function () {
  console.log("0")
}, 0)
console.log("chushihua")
console.log(new Date().getTime() - startDate) //

// 结果为 :   chushihua  ->  4677  ->  2000  -> 0

所以在上面这个例子中并不是 先输出 0 而是先输出 2000。 因为初始化执行上下文时间较长在 推出 2000 定时器后 还最少用了 4677 秒,而这时候定时器时间已到,所以其将回调函数 推到 Callback Queue,然后 执行到 setTimeout(0) 这时候先推到定时器线程,然后立即 推到 Callback Queue, 所以结果是先 2000 再 0

那么另外一种

js
setTimeout(function () {
  console.log("2000")
}, 1000)
setTimeout(function () {
  console.log("0")
}, 0)
let startDate = new Date().getTime()
for (let index = 0; index < 100000; index++) {
  document.createElement("div")
  for (let index = 0; index < 100; index++) {
    document.createElement("div")
  }
}
console.log("chushihua")
console.log(new Date().getTime() - startDate) //

// 结果为 :   chushihua  ->  4677  -> 0  ->  2000

上面这种情况就是 先执行 setTimeout(1000) 将定时器推到定时器线程模块,然后执行 setTimeout(0) 也将此推到定时器线程模块 然后立即将 log(0) 回调函数推到 Callback Queue。然后继续执行初试代码,在执行中(即离 setTimeout(1000)过去了 1000),定时器线程会将 log(2000) 回调函数推到 Callback Queue。这时候 Callback Queue 就是[ log(0) , log(2000) ]。所以结果就是 0 -> 2000。

下面我们看一个更详细的

事件队列

堆和栈

堆和栈都是运行时内存中分配的一个数据区,其存储的数据类型和处理速度不同。

  • 堆(heap)用于给复杂数据类型(引用类型)分配空间,如对象、数组等等,其运行时动态分配内存,所以存取速度较慢。

  • 栈(stack)用于存放基本类型变量和引用类型的地址,其优势就是存取数据比堆块,并且栈里的数据是可共享的,缺点是分配给栈中数据的大小是确定的。

  1. 栈的规则

栈的一个重要的规则: 栈中的数据可以共享,如:

js
var a = 3
var b = 3
a = 4

JS 引擎会先处理 var a = 3; 首先在栈中创建一个变量为 a 的引用,然后在栈中查找是否存在 3 这个值,如果没有就将 3 存放进来,然后将 a 指向 3。 接着处理 var b = 3; 在创建 b 的引用变量后,查找到栈中存在 3 这个值,所以直接将 b 指向 3。这样就出现了 a 与 b 同时指向 3 的情况。这时候 再执行 a = 4; 这时候 JS 引擎会查找栈中是否存在 4 这个值, 如果没有 就将 4 存进来,然后令 a 指向 4。 所以 a 值的修改不会影响 b 的值。

任务队列

JS 中存在两类任务队列: 宏任务队列(macro tasks)微任务队列(micro tasks) 。 宏任务队列可以存在多个,微任务队列只有一个。

  • 宏任务队列(一般由渲染进程提供) :
    • script(全局任务)
    • setTimeout 、 setInterval 、 setImmediate
    • UI rendering(元素以非阻塞方式插入文档)
    • 事件回调
    • IndexDB 数据库操作等 I/O
    • histroy.back()
    • XHR 回调
  • 微任务队列 (一般由 JS 执行引擎提供):
    • process.nextTick
    • Promise
    • MutationObserver

那么浏览器是如何处理这些任务的?

  1. 宏任务队列中存在 Task,那么就取一个宏任务来执行到完毕

  2. 判断微任务队列中是否存在微任务。 如果存在就取一个微任务来执行,执行完毕后,再取一个微任务来执行。直到微任务队列为空。

  3. 进入渲染阶段: 这时候判断上述代码是否导致浏览器的重新渲染,

    • 需要重新渲染: 进入到 4
    • 不需要重新渲染: 就不执行 4 及后面的流程
  4. 判断渲染操作是否导致 窗口的大小发生了变化,执行监听的 resize 方法。

  5. 判断渲染操作是否导致 页面发生滚动,执行监听的 scroll 方法。

  6. 判断是否存在 帧动画回调,也就是 requestAnimationFrame 的回调,如果存在就执行回调函数

  7. 判断渲染操作是否导致监听的 DOM 元素内容发生改变,存在的话就 执行 IntersectionObserver 的回调。

  8. 进行渲染操作

  9. requestIdleCallback 的回调函数

    • 判断之前的回调函数是否产生新的 Task,如果存在就先执行这些 Task
    • 判断是否存在空闲的调度时间,存在才执行

结论

  1. resize 方法 和 scroll 回调
  • 上面回调监听的resize 方法 和 scroll 方法,是指 JS 操作 BOM 对象导致浏览器产生对应的事件回调(不是用户鼠标操作导致的)。
  • 那么对于用户操作导致 resize 方法 和 scroll ,那么浏览器一方面会立即滚动视图或者放大浏览器,等到事件循环中排到对应的回调方法才会执行
  1. 每一轮 Event Loop 都会伴随着渲染吗?

不一定,这得判断你的回调事件中是否触发了 DOM 的渲染操作

  1. requestAnimationFrame 在哪个阶段执行,在渲染前还是后?在 microTask 的前还是后?

requestAnimationFrame 是在渲染之前执行的,microTask之后。

但是需要注意的是: 我们每一次的事件循环不一定会触发requestAnimationFrame(如果不触发渲染更新条件),而是到固定的刷新频率去触发

  1. resizescroll 这些事件是何时去派发的。

这就分为两种情况。

  • JS 操作导致的 resize 或者 scroll 的触发,那么对于这种就在此次的事件循环过程中触发
  • 对于用户操作导致: 回调事件会放到 Task 任务队列中,到其执行的时候去回调
  1. 渲染的

例子

下面我们根据几个例子去理解一下

  1. 闪烁动画
ts
setTimeout(() => {
  document.body.style.background = "red"
  setTimeout(() => {
    document.body.style.background = "blue"
  })
})

结果是: 1. 大概率的情况我们看到的就是显式 blue, 2. 如果 red 的时候正好是浏览器的重绘时机,那就看到 red => blue

解释:

按照正常的理解其情况应该是: setTimeout 的宏任务执行,发现需要重绘(重新渲染)使得背景为 red => 下次的 setTimeout 的宏任务执行然后背景为 blue。

但是实际情况因为两个宏任务的间隔时间太小(12ms),导致其渲染任务进行了合并只显示最后的一次背景 blue

只有当我们正好第一次宏任务执行完成到渲染的时候发现到了浏览器的刷新频率,那么就渲染为 red => blue

所以我们可以将 setTimepout 的执行时机变长一点,那么就可以看到闪缩的情况

还有一种办法就是借助于 requestAnimationFrame,他是在每一次渲染时机执行之前触发的

ts
let i = 10
let req = () => {
  i--
  requestAnimationFrame(() => {
    document.body.style.background = "red"
    requestAnimationFrame(() => {
      document.body.style.background = "blue"
      if (i > 0) {
        req()
      }
    })
  })
}

req()

image-20231012212154120

  1. 定时器的合并

按照一些常规的理解来说,宏任务之间理应穿插渲染,而定时器任务就是一个典型的宏任务,看一下以下的代码:

ts
setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})
setTimeout(() => {
  console.log("sto")
  requestAnimationFrame(() => console.log("rAF"))
})

queueMicrotask(() => console.log("mic"))
queueMicrotask(() => console.log("mic"))

从直觉上来看,顺序是不是应该是:

ts
mic
mic
sto
rAF
sto
rAF

也就是每一个宏任务之后都紧跟着一次渲染。

实际上不会,浏览器会合并这两个定时器任务:

ts
mic
mic
sto
sto
rAF
rAF

EventLoop 规范

  1. 从 task 中(一个或者多个)取出最老的一个 task 执行。

  2. 执行 microTask 检查点。即会顺序执行 microTask 中所有的微任务,直到队列为空(如果 microTask 中又添加了新的 microTask,那么直接添加到当前 microTasks 队列尾部)

js
console.log("A")
setTimeout(() => {
  console.log("B")
}, 0)
Promise.resolve()
  .then(() => {
    console.log("C")
  })
  .then(() => {
    console.log("D")
  })
Promise.resolve()
  .then(() => {
    console.log("E")
  })
  .then(() => {
    console.log("F")
  })
console.log("G")
// 执行的结果是 A G C E D F B
  1. 执行 UI rendering 操作:

    • 判断 document 在此时间点渲染是否会『获益』。
      • 浏览器只需保证 60Hz 的刷新率即可(在机器负荷重时还会降低刷新率),
      • 若 eventloop 频率过高,即使渲染了浏览器也无法及时展示。所以并不是每轮 eventloop 都会执行 UI Render
      • 触发resizescroll等事件
      • 建立媒体查询、运行 CSS 动画等等
    • 执行 animation frame callbacks
    • 执行 IntersectionObserver callback
    • 渲染 UI

下面我们以一个例子来验证上面的结果:

例子 1

js
const box = document.getElementById("box")
const btn = document.getElementById("btn")

box.addEventListener("click", function () {
  console.log("box is click")
  setTimeout(() => {
    console.log("box setTimeout")
  }, 0)
  Promise.resolve()
    .then(() => {
      console.log("box Promise 1")
    })
    .then(() => {
      console.log("box Promise 2")
    })
})
btn.addEventListener("click", function () {
  console.log("btn is click")
  setTimeout(() => {
    console.log("btn setTimeout")
  }, 0)
  Promise.resolve()
    .then(() => {
      console.log("btn Promise 1")
    })
    .then(() => {
      console.log("btn Promise 2")
    })
})
setTimeout(() => {
  console.log("script setTimeout 1")
}, 0)
setTimeout(() => {
  console.log("script setTimeout 2")
}, 1000)

Promise.resolve()
  .then(() => {
    console.log("script Promise 1")
  })
  .then(() => {
    console.log("script Promise 2")
  })
console.log("G")

requestAnimationFrame(function () {
  console.log("requestAnimationFrame")
})
requestIdleCallback(function () {
  console.log("requestIdleCallback")
})

结果为:

js
G
script Promise 1
script Promise 2
requestAnimationFrame
script setTimeout 1
requestIdleCallback
script setTimeout 2
btn is click
btn Promise 1
btn Promise 2
box is click
box Promise 1
box Promise 2
btn setTimeout
box setTimeout

从上面的结果可以看出: 对于 script 内容其也是一个 task 宏任务,requestAnimationFrame 的回调函数作为一个微任务,所以在 script 执行完成后,立即执行了 微任务script Promise 1 -> script Promise 2 -> UI

参考

深入解析你不知道的 EventLoop 和浏览器渲染、帧动画、空闲回调(动图演示)