likes
comments
collection
share

浏览器与Node事件循环机制解析

作者站长头像
站长
· 阅读数 15

基本概念

聊一下事件循环机制,在开始这篇文章之前,先明确一个概念,js本身是没有事件循环这个定义的。是js被嵌入相应的执行环境(浏览器 / Nodejs),配合当前事件循环的组成部分,具体来说分下面两部分:

  1. 在浏览器环境中,事件循环是HTML标准中定义的,用于协调浏览器端的多种事件源的一种机制
  2. 在Nodejs环境中,遵循的事件循环是来源于Libuv

上面说的事件源是指各种交互,当我们打开一个网页可能触发各种各样的事件源,比如:

  • 用户交互: 鼠标,键盘,摄像头
  • 脚本:js
  • 渲染:HTML, DOM, CSS,图片
  • 网络请求: ajax 这在HTML标准定义中可以查到相关资料: 浏览器与Node事件循环机制解析 而事件循环的目的其实就是提供了一种机制为了解决在当前环境中各种事件源之间怎么协作的问题。 比如在浏览器端,在用户的浏览器中既有UI渲染,也有用户交互(鼠标,键盘),也有Ajax请求各种资源,等等。那怎么能让这些事情有条不紊的协作起来。让用户能够正常符合预期的使用我们开发的界面。 在Nodejs服务端也是一样,有各种I/O操作,各种定时任务,服务请求,各种各样的计算,要让这么多任务有条不紊并高效的执行。 这就是事件循环解决的问题,具体怎么在技术上实现,是各自浏览器厂商各自实现的。

浏览器中的事件循环

我们都知道,在js中随着运行主线程中的代码,而主进程中的代码会不断调用外部浏览器的各种API,Ajax或者是代码中的定时器等各种异步事件,肯定有⼀个先来后到的排队问题。决定这些事件如何排队触发的机制,就是事件循环。这个排队⾏为以 JavaScript 开发者的⻆度来看,主要是分成两个队列,即下文所说的任务队列和微任务队列。 需要注意的是,虽然为了好理解我们管这个叫队列 (Queue),但是本质上是有序集 合 (Set) 浏览器整体事件循环模型: 浏览器与Node事件循环机制解析

由上图我们知道除了主线程之上的任务,在异步队列中也有微任务队列和(宏)任务队列(因为html标准文档说的是Task Queue所以下面说的是任务队列,可以类比就是宏任务队列)在浏览器中的事件循环模型中任务队列的模型如下如所示: 浏览器与Node事件循环机制解析 由上图我们可知:

  1. 每次循环都只会处理一个任务队列中的任务
  2. 每次都会清空微任务队列中所有任务
  3. 至于是所有内容都渲染完,还是渲染到一半切出来回到第一步得看浏览器的具体实现。
  4. 对于一段js代码而言,js执行的第一个任务队列中的任务就是当前这个代码本身(即第一次是执行脚本本身) 注意:微任务不一定在任务队列中的任务之前执行的,一定是先执行一次脚本任务(首次任务队列中的任务,即脚本本身),然后才会清空当前的微任务队列中的任务

任务队列

我看很多文章都称之为“宏任务”,可能是相对于下面所说的“微任务”而言的,我看HTML标准协议中的描述是task queues,这里就统一称之为任务队列。主要包括下面这些事件:

  1. DOM 操作 (⻚⾯渲染)
  2. ⽤户交互 (⿏标、键盘)
  3. ⽹络请求 (Ajax 等)
  4. History API 操作
  5. 定时器 (setTimeout 等)
  6. 其他...

可以观察到,这些外部的事件源可能很多,为了⽅便浏览器⼚商优化,HTML 标准中明确指出⼀个事件循环由⼀个或多个任务队列,⽽每⼀个任务事件源都有⼀个对应的队列。不同事件源的队列可以有不同的优先级(例如在⽹络事件和⽤户交互之间,浏览器可以优先处理⿏标⾏为,从⽽让⽤户感觉更加流程)。

微任务队列

在 HTML 标准中,并没有明确规定这个队列的事件源,通常认为有以下⼏种: • Promise 的成功 (.then) 与失败 (.catch) • MutationObserver • Object.observe (已废弃)

示例

Demo1

setTimeout(() => console.log(1), 0)

比如整个代码就只有这一行。那么背后按照时间模型执行的逻辑为: 第一次循环:

  1. 执行js脚本本身:调用浏览器的setTimeout这个API,将其注册的callBack函数(这里是() => console.log(1))注册到任务队列中。
  2. 取出所有微任务队列中任务并执行:这里无
  3. 浏览器渲染:这里无

第二次循环

  1. 取出任务队列中的回调函数(() => console.log(1))执行
  2. 取出所有微任务队列中任务并执行:这里没有
  3. 浏览器渲染:这里无

整个任务就结束了

Demo2

<html>

<body>
  <pre id="render"></pre>
</body>
<script>
  const main = document.querySelector('#render');
  const callback = (i, fn) => () => {
    main.innerText += fn(i);
  };
  let i = 1;
  // 第一遍循环,外部任务执行script 中的脚本:i从0加到1000,并将1000个任务添加到任务队列中
  while (i++ < 1000) {
  	// 后续第n次循环分别执行一次对应的回调任务
    setTimeout(callback(i, (i) => '\n setTimeout> ' + i + ''))
  }
//  // 第一遍循环,外部任务执行script 中的脚本:i从1000加到2000,并将1000个任务添加到微任务队列中
  while (i++ < 2000) {
    Promise.resolve().then(callback(i, (i) => i + ','))
  }
  console.log(i)
  main.innerText += '[start ' + i + ' ]\n'
</script>

</html>

浏览器与Node事件循环机制解析

Demo3

// 函数定义
async function async1 () {
  // 第一遍循环外部任务时打印
  console.log('async1 start')
  // 第一遍循环外部任务时指定async2函数
  await async2()
  // await 后面的代码相当于 Promose.resolve().then() 中.then里面的函数
  // 在第一遍事件循环执行外部任务时加入到微任务队列
  console.log('async1 end')
}

// 函数定义
async function async2 () {
  // 第一遍循环外部任务时打印
  console.log('async2')
  // 如果这里有await 就会在第一遍事件循环执行外部任务时加入到微任务队列
}

// 第一遍循环外部任务时打印
console.log('script start')

// 加入到外部任务队列中,等待第二遍事件循环时执行
setTimeout(function () {
  console.log('setTimeout')
}, 0)

// 第一遍循环外部任务执行
async1()

new Promise(function (resolve) {
  // 第一遍循环外部任务时打印
  console.log('promise1')
  resolve()
  //resolve正常执行后打印promise2
  //.then之前的都会被打印出, 
  console.log('promise2')
}).then(function () {
  // .then函数和await 后面的内容会被加到对应的微任务队列中
  console.log('promise3')
})
console.log('script end')

结果为: script start -> async1 start -> async2 -> promise1 -> promise2 -> script end -> async1 end -> promise3 -> setTimeout 事件循环第一遍任务: script start -> async1 start -> async2 -> promise1 -> promise2 -> script end 事件循环第一遍微任务: async1 end -> promise3 UI渲染阶段:无逻辑 (对应的Node环境直接无该阶段) 事件循环阶段第二遍任务: setTimeout

Nodejs中事件循环

浏览器是将Js集成到HTML事件循环之中,与此对应的是Node.js 将js集成到libuv的 I/O循环之中。 简言之,二者都是将js集成到各自的环境中。但HTML(浏览器端)与libuv(服务端)面对的场景有很大的差异。比如:

  1. 事件循环的过程没有 HTML 渲染。只剩下了任务队列和微任务队列这两个部分。
  2. 任务队列的事件源不同。Node.js 端没有了⿏标、键盘,摄像头等外设但是新增了⽂件等 IO,与操作系统交互(通过libuv中转)。
  3. 微任务队列的事件仅剩下 Promise 的 then 和 catch

Node事件循环模型

node环境的任务队列的事件循环的6个阶段顺序是固定的(timers ->pending -> idl -> poll -> check -> close callbacks) 浏览器与Node事件循环机制解析

其主要逻辑如下:

  1. 6个阶段中的每个阶段都是一个先进先出的任务队列
  2. 会依次循环每个阶段(timers ->pending -> idl -> poll -> check -> close callbacks)循环到该阶段时,会把该阶段中的任务队列所有任务执行完
  3. 取出所有微任务并执行完
  4. 再执行任务队列中的任务
  5. 清空微任务队列中的任务 ...

阶段概述

  1. timers: 此阶段执行由 setTimeout() 和 setInterval() 排序。
  2. pending callbacks: 执行 I/O 回调推迟到下一个循环 迭代。
  3. idle, prepare: 仅在内部使用。
  4. poll: 检索新的 I/O 事件; 执行与 I/O 相关的几乎任何回调(由“计时器”或 “setImmediate()”所设的紧邻回调除外); node 将在适当时机在此处暂停。
  5. check: setImmediate() 回调在此处被调用。
  6. close callbacks:一些关闭的回调函数,如:socket.on('close', ...)。

setImmediate

setTimeout(fn, 0) setTimeout的精度是毫秒级别的(0毫秒)。对计算机来说1毫秒可以执行很多任务(执行个几万次任务是很正常的),所以Node提供了一个setImmediate的API, 响应是微秒级别的。setImmediate 是有概率比setTimeout更早运行的。两个API在外部任务队列中的不同的阶段。如果是微秒级的(更快执行精度的触发器)可以考虑setImmediate。特别是在没有很大的I/O操作的情况下,很大概念在微秒精度下触发会比setTimeout执行更早。有一定的小概率是setTimeout先执行,其原因就是精度问题。

demo

setTimeout(()=>{
 console.log('setTimeout1');
 Promise.resolve().then(() => console.log('promise1'));
});
setTimeout(()=>{
 console.log('setTimeout2');
 Promise.resolve().then(() => console.log('promise2'));
});
setImmediate(() => {
 console.log('setImmediate1');
 Promise.resolve().then(() => console.log('promise3'));
});
setImmediate(() => {
 console.log('setImmediate2');
 Promise.resolve().then(() => console.log('promise4'));
});

浏览器与Node事件循环机制解析

process.nextTick()

您可能已经注意到process.nextTick() 在图示中没有显示,即使它是异步 API 的一部分。这是因为 process.nextTick()从技术上讲不是事件循环的一部分。相反,它都将在当前操作完成后处理nextTickQueue, 而不管事件循环的当前阶段如何。这里所谓的操作被定义为来自底层 C/C++ 处理器的转换,和需要处理的 JavaScript 代码的执行。

回顾我们的图示,任何时候在给定的阶段中调用 process.nextTick(),所有传递到 process.nextTick() 的回调将在事件循环继续之前解析。这可能会造成一些糟糕的情况,因为它允许您通过递归 process.nextTick()调用来“饿死”您的 I/O,阻止事件循环到达 轮询 阶段。 更多的内容请参考:参考资料

参考文章

HTML事件循环 Nodejs事件循环 MDN 事件循环