事件循环

302 阅读4分钟

JavaScript 本身是单线程的,它执行的时候,是运行在执行上下文中的,就有了执行上下文栈(JS Stack)的概念。

同步任务直接进入主线程被执行,而我们的异步任务则存储在一个异步任务队列中,异步任务完成后,才会被放入异步任务回调队列,当主线程任务执行完成后,才会从异步任务回调队列读取任务,放入到主线程中。

异步任务队列分为 Task (任务/宏任务) 和 Microtask(微任务)。

与其说是JavaScript提供了事件循环,不如说是嵌入 JavaScript 的user agent需要通过事件循环来与多种事件源(比如用户交互如鼠标键盘等、脚本如JavaScript、渲染如HTML DOM和CSS、网络等)交互。

JavaScript 运行时

在执行 JavaScript 代码的时候,JavaScript 运行时实际上维护了一组用于执行 JavaScript 代码的代理。每个代理由一组执行上下文的集合、执行上下文栈、主线程、一组可能创建用于执行 worker 的额外的线程集合、一个任务队列以及一个微任务队列构成。除了主线程(某些浏览器在多个代理之间共享的主线程)之外,其它组成部分对该代理都是唯一的。

事件循环是什么

事件循环会驱动发生在浏览器中与用户交互有关的一切。

  • 外部队列 Task Queue(也叫 Macrotask Queue)
  • 内部队列 Microtask Queue

浏览器中:

  1. 外部队列一次执行一个,执行完成后,移除外部队列中当前执行的任务;
  2. 清空内部队列中所有的任务;
  3. 取出外部队列中的第一个任务,继续执行上面的步骤。

外部队列-宏任务

一个任务就是由执行诸如从头执行一段程序、执行一个事件回调或一个 interval/timeout 被触发之类的标准机制而被调度的任意 JavaScript 代码。这些都在任务队列(task queue)上被调度。

JavaScript 外部事件的队列,主要有:

  • script
  • I/O
  • UI Rendering
    • DOM操作(页面渲染)
    • 用户交互(鼠标、键盘)
  • 网络请求(Ajax)
  • History API 操作
  • 定时器(setTimeout/setInterval/setImmediate 等)

内部队列-微任务

JavaScript 语言内部的队列,主要有:

  • Promise的成功.then 与失败 .catch
  • MutationObserver
  • Object.observer(已废弃)
  • Process.nextTick(Node 独有)

案例分析:

console.log('1 script start')

setTimeout(function(){
    console.log('2 setTimeout')
}, 0)

Promise.resolve().then(function(){
    console.log('3 promise1')
}).then(function(){
    console.log('4 promise2')
})
console.log('5 script end')

此时的外部队列:

  • Script
  • setTimeout

内部队列:

  • Promise.then:3 promise1
  • Promise.then:4 promise2

Event Loop 处理过程:

  1. 执行Script
  2. 清空内部队列
  3. 执行 setTimeout

整个执行过程是:

  1. 执行 console.log (输出: 1 script start)
  2. 遇到 setTimeout 加入外部队列
  3. 遇到两个 Promise 的 then 加入内部队列
  4. 遇到 console.log (输出: 5 script end)
  5. 内部队列中的任务挨个执行完(输出:3 promise1 和 4 promise2)
  6. 外部队列中的任务执行(输出: 2 setTimeout)

浏览器与 node.js 事件循环差异

  1. node.js 事件循环的过程没有 HTML 渲染,只有外部队列和内部队列;
  2. 外部队列的事件源不同,node.js 没有鼠标输入等外设但是新增了文件等I/O;
  3. 内部队列的事件只剩下了 Promise 的 then 和 catch。

node.js 最初设计的时候是允许执行多次外部的事件再切换到内部队列的,而浏览器端一次事件循环只允许执行一次外部事件。

setTimeout(function(){
    console.log('1 timer1')
    Promise.resolve().then(function(){
        console.log('2 promise1')
    })
})
setTimeout(function(){
    console.log('3 timer2')
    Promise.resolve().then(function(){
        console.log('4 promise2')
    })
})

浏览器:外部队列不固定

1 timer1
2 promise1
3 timer2
4 promise2

node:外部队列是固定的

1 timer1
3 timer2
2 promise1
4 promise2

这是因为在浏览器端有外部队列一次事件循环只能执行一个的限制,而在 node.js 中放开了这个限制,允许外部队列中的所有任务都执行完成后再切换到内部队列。

node.js 自己的外部队列是有顺序的:

  • timers: setTimeout
  • poll: 文件读取的callback
  • check: setImmediate

node 11之前是先把外部队列清空,再去执行内部队列,11之后与浏览器保持一致。

v10 外部队列全部执行
v11
v12 跟浏览器端保持一致

案例解析

setTimeout(function(){ // 定时器
    console.log('1 setTimeout1')
    Promise.resolve().then(function(){
        console.log('2 promise1')
    })
})
setImmediate(function(){ // 立即执行 只能node端跑
    console.log('3 setImmediate1')
    Promise.resolve().then(function(){
        console.log('4 promise2')
    })
})
setTimeout(function(){ // 定时器
    console.log('5 setTimeout2')
    Promise.resolve().then(function(){
        console.log('6 promise3')
    })
})
setImmediate(function(){ // 立即执行 只能node端跑
    console.log('7 setImmediate2')
    Promise.resolve().then(function(){
        console.log('8 promise3')
    })
})

node.js v10:

1 setTimeout1
5 setTimeout2
2 promise1
6 promise2
3 setImmediate1
7 setImmediate2
4 promise3
8 promise4

node.js 自己的外部队列是有顺序的:

setTimeout 是以毫秒为单位来触发的,setImmediate 是微秒级的,有一定概率是比 setTimeout 执行更快。

为什么没有一次清空外部队列?是因为setTimeout和 setImmediate 之间是有时间间隔的,所以此时会切到内部队列。

node.js v12

1 setTimeout1
2 promise1
5 setTimeout2
6 promise2
3 setImmediate1
4 promise3
7 setImmediate2
8 promise4