# JavaScript 事件执行机制

首先需要了解 js 解析运行机制,浏览器由很多模块组成,有解析 html 和 css 的模块,有解析 js 、http 请求模块。 一个浏览器通常由以下常驻线程组成:

  • GUI 渲染线程
  • JavaScript 引擎线程
  • 定时触发器线程
  • 事件触发线程
  • 异步 http 请求线程

其中 JavaScript 引擎线程 v8 引擎就是用来解析 js 的,js 是单线程是因为 v8 引擎是单线程,当 v8 引擎解析到异步代码时,比如定时器,就会把异步代码交给相关的模块处理,处理完之后,再交给事件队列中排队,当执行栈有空时,消息队列就把事件交给调用栈执行。拿定时器来说,当 v8 引擎从上往下执行代码,读到定时器的时候。就会把定时器交给定时器模块处理,然后 v8 引擎继续执行代码,定时器模块进行计时,当时间到了,就会把任务交给事件队列,当执行栈有空闲时,事件队列就会把任务推给执行栈,执行栈执行完毕之后将其弹出;

而事件循环机制就是 JavaScript 中实现异步以及多线程的实现基础;

首先看一段代码:

setTimeout(function(){
  console.log('定时器开始啦')
});

new Promise(function(resolve){
  console.log('马上执行for循环啦');
  for(var i = 0; i < 10000; i++){
      i == 99 && resolve();
  }
}).then(function(){
  console.log('执行then函数啦')
});

console.log('代码执行结束');

执行结果:

// 马上执行for循环啦
// 代码执行结束
// 执行then函数啦
// undefined
// 定时器开始啦

首先我们要明确的是 JavaScript 是一门单线程的语言,虽然在 H5 中有了 Web-Worker,但单线程这一机制是没有变的。现在的 JavaScript 的多线程都是通过单线程模拟出来的(为了利用多核 CPU 的计算能力,HTML5 提出 Web Worker 标准,允许JavaScript 脚本创建多个线程,但是子线程完全受主线程控制,且不得操作 DOM。所以,这个新标准并没有改变 JavaScript 单线程的本质)。

# JavaScript 事件循环

JavaScript 的执行任务分为两类:

  • 同步任务
  • 异步任务

当我们打开网页的时候,网页的渲染过程就是很多同步任务,比如页面骨架和页面元素的渲染。而像一些大的图片之类占用资源大耗时久的任务,就是异步任务。

在这里插入图片描述

上面的图就是说:

  • 同步和异步任务分别进入不同的执行“场所”,同步的进入主线程,异步的进入 Event Table 并注册函数。
  • 当指定的事情完成时,Event Table 会将这个函数移入 Event Queue。
  • 主线程内的任务执行完毕为空,会去 Event Queue 读取对应的函数,进入主线程执行。
  • 上述过程会不断重复,也就是常说的 Event Loop(事件循环)。

js 引擎存在 monitoring process 进程,会持续不断的检查主线程执行栈是否为空,一旦为空,就会去 Event Queue 那里检查是否有等待被调用的函数。 例子:

let data = [];
$.ajax({
  url:www.javascript.com,
  data:data,
  success:() => {
    console.log('发送成功!')
  }
})
console.log('代码执行结束')

上面代码执行解析:

  • ajax 进入 Event Table,注册回调函数 success。
  • 执行 cconsole.log('代码执行结束')。
  • ajax 事件完成,回调函数 success 进入 Event Queue。
  • 主线程从 Event Queue 读取回调函数 success 并执行。

# setTimeout

看下面代码:

setTimeout(() => {
  task()
},3000)

sleep(10000000)

我们执行上面代码发现,执行 task 函数需要的时间是不止三秒的。上面代码执行解析:

  • task() 进入 Event Table 并注册,计时开始。
  • 执行 sleep 函数,很长事件,计时仍在继续。
  • 3 秒到了,计时事件 timeout 完成,task() 进去 Event Queue,但是 sleep 函数并没有执行完成,等待执行完成。
  • sleep 执行完成,task() 从 Event Queue 进入了主线程执行。

也就是说,setTimeout 这个函数,是经过指定时间后,把要执行的任务(本例中为 task() 函数)加入到 Event Queue 中,又因为是单线程任务要一个一个执行,如果前面的任务需要的事件太久,那么只能等着,导致真正的延迟事件远远大于 3 秒。

setTimeout(() => {
  console.log('执行啦')
},0);
console.log('先执行这里');
// 先执行这里
// 执行啦

所以说 setTimeout(fn,0) 他并不是立即执行,他的含义是指定某个任务在主线程最早可得的空闲时间执行,意思就是不用再等多少秒了,只要主线程执行栈内的同步任务全部执行完成,栈为空就马上执行。

这里需要注意的是 setTimeout 的最小延迟时间是 4ms。

# setInterval

setInterval 是循环执行,会每隔指定的事件将注册的函数置入 Event Queue,如果前面的任务耗时太久,那么同样需要等待。对于setInterval(fn,ms)来说,我们已经知道不是每过 ms 秒会执行一次 fn,而是每过 ms 秒,会有 fn 进入 Event Queue。一旦setInterval 的回调函数 fn 执行时间超过了延迟时间 ms,那么就完全看不出来有时间间隔了。

# Promise 与 process.nextTick(callback)

process.nextTick(callback) 类似 node.js 版的 "setTimeout",在事件循环的下一次循环前调用 callback 回调函数。process.nextTick() 执行总是早于 setImmediate() 和 setTimeout();

# setTimeout 与 setImmediate

setTimeout(() => {
  console.log('timeout')
}, 0)

setImmediate(() => {
  console.log('immediate')
})

// 'timeout'
// 'immediate'

setImmediate() 将在当前事件轮询的末尾处执行;setTimeout 会在最小延迟(4ms)后运行脚本;

但是如果遇到在 IO 操作中情况就会不一样:

const fs = require('fs')

fs.readFile(__filename, () => {
  setTimeout(() => {
    console.log('timeout')
  }, 0)
  setImmediate(() => {
    console.log('immediate')
  })
})

// immediate
// timeout

fs.readFile 的回调是在 poll 阶段执行的,当其回调执行完毕之后,poll 队列为空,而 setTimeout 入了 timers 的队列,此时有代码被 setImmediate(),于是事件循环先进入 check 阶段执行回调,之后在下一个事件循环再在 timers 阶段中执行有效回调。

https://segmentfault.com/a/1190000013102056 https://nodejs.org/en/docs/guides/event-loop-timers-and-nexttick/

# 宏任务、微任务

除了广义的同步任务和异步任务,我们对任务有更精细的定义:

  • macro-task(宏任务):包括整体代码 script,setTimeout,setInterval、I/O、setImmediate(IE)、requestAnimationFrame 各种 callback、 UI 渲染等;
  • micro-task(微任务):Promise.then catch finally,process.nextTick(callback)、MutaionObserver

不同类型的任务会进入对应的 Event Queue,比如 setTimeout 和 setInterval 会进入相同的 Event Queue。事件循环的顺序,决定 js 代码的执行顺序。进入整体代码(宏任务)后,开始第一次循环。接着执行所有的微任务,然后再次从宏任务开始,找到其中一个任务队列执行完毕,再执行所有的微任务。

https://developer.mozilla.org/zh-CN/docs/Web/API/HTML_DOM_API/Microtask_guide/In_depth#%E4%BA%8B%E4%BB%B6%E5%BE%AA%E7%8E%AF%EF%BC%88event_loops%EF%BC%89

如下代码:

setTimeout(function() {
    console.log('setTimeout');
})

new Promise(function(resolve) {
    console.log('promise');
    resolve();
}).then(function() {
    console.log('then');
})

console.log('console');

解释:

  • 这段代码作为宏任务,进入主线程。
  • 先遇到 setTimeout,那么将其回调函数注册后分发到宏任务 Event Queue。
  • 接下来遇到了 Promise,new Promise 立即执行,then 函数分发到微任务 Event Queue。
  • 遇到 console.log() 立即执行。
  • 至此,整体代码 script 作为第一个宏任务执行结束,看看有哪些微任务?我们发现了 then 在微任务 Event Queue 里面,执行。
  • ok,第一轮事件循环结束了,我们开始第二轮循环,当然要从宏任务 Event Queue 开始。我们发现宏任务 Event Queue 中 setTimeout 对应的回调函数,立即执行。
  • 结束

在这里插入图片描述

如下面一个复杂的代码:

console.log('1');

setTimeout(function() {
    console.log('2');
    process.nextTick(function() {
        console.log('3');
    })
    new Promise(function(resolve) {
        console.log('4');
        resolve();
    }).then(function() {
        console.log('5')
    })
})
process.nextTick(function() {
    console.log('6');
})
new Promise(function(resolve) {
    console.log('7');
    resolve();
}).then(function() {
    console.log('8')
})

setTimeout(function() {
    console.log('9');
    process.nextTick(function() {
        console.log('10');
    })
    new Promise(function(resolve) {
        console.log('11');
        resolve();
    }).then(function() {
        console.log('12')
    })
})
// 1, 7, 6,8, 2, 4, 3, 5, 9, 11, 10 , 12

分析: 第一轮事件循环流程分析:

  • 整体 script 作为第一个宏任务进入主线程,遇到 console.log 打印 1;
  • 遇到 setTimeout,将其回调函数被分发到宏任务 Event Queue 中,我们暂时且记为 setTimeout1。
  • 遇到 process.nextTick(),将其回调函数被分发到微任务 Event Queue 中,我们记为 process1。
  • 遇到 Promise,new Promise 直接执行,输出 7,then 被分发到微任务 Event QUeue 中,我们记为 then1。
  • 又遇到了 setTimeout,将其回调函数被分发到宏任务 Event Queue 中,我们记为 setTimeout2。
宏任务 Event Queue 微任务 Event Queue
setTimeout1 process1
setTimeout2 then1
  • 上面的表格是第一轮事件循环宏任务结束时各 Event Queue 的情况,此时已经输出了 1 和 7;
  • 然后执行微任务 process1 和 then1 执行,
  • 执行 process1,输出 6。
  • 执行 then1,输出 8。

至此,第一轮事件循环正式结束,这一轮的结果是输出 1, 7, 6, 8,那么第二轮事件循环从 setTimeout1 宏任务开始:

  • 首先输出 2,接下来遇到了 process.nextTick(),同样将其分发到微任务 Event Queue 中,记为 process2,new Promise 立即执行输出 4, then 也分发到微任务 Event Queue 中,记为 then2。
宏任务 Event Queue 微任务 Event Queue
setTimeout2 process2
then2
  • 第二轮事件循环宏任务结束,我们发现有 process2 和 then2 两个微任务可以执行。
  • 输出 3。
  • 输出 5。
  • 第二轮事件循环结束,第二轮输出 2, 4, 3, 5。
  • 第三轮事件循环开始,此时只剩 setTimeout2,执行。
  • 直接输出 9。
  • 将 process.nextTick() 分发到微任务 Event Queue 中,记为 process3。
  • 直接执行 new Promise,输出 11。
  • 将 then 分发到微任务 Event Queue 中,记为 then3。
宏任务 Event Queue 微任务 Event Queue
process3
then3
  • 第三轮事件循环宏任务执行结束,执行两个微任务 process3 和 then3。
  • 输出 10。
  • 输出 12。
  • 第三轮事件循环结束,第三轮输出 9, 11, 10, 12。

# 其他

# 共享事件循环

在特定情况下,同源窗口之间共享事件循环,例如:

  • 如果一个窗口打开了另一个窗口,它们可能会共享一个事件循环。
  • 如果窗口是包含在 <iframe> 中,则它可能会和包含它的窗口共享一个事件循环。
  • 在多进程浏览器中多个窗口碰巧共享了同一个进程。 这种特定情况依赖于浏览器的具体实现,各个浏览器可能并不一样。

# 触发微任务

触发微任务不仅仅通过内部使用来驱动诸如 promise 这些;还可以通过 queueMicrotask() 创建一个统一的微任务队列;如下代码:

MyElement.prototype.loadData = function (url) {
  if (this._cache[url]) {
    queueMicrotask(() => {
      this._setData(this._cache[url]);
      this.dispatchEvent(new Event("load"));
    });
  } else {
    fetch(url).then(res => res.arrayBuffer()).then(data => {
      this._cache[url] = data;
      this._setData(data);
      this.dispatchEvent(new Event("load"));
    });
  }
};

# 总结

  1. js 的异步

JavaScript 是一门单线程语言,不管是什么新的框架新语法糖实现的所谓异步,其实都是用同步的方法去模拟的,需要牢牢把握住单线程这点非常重要。

  1. 事件循环 Event Loop

事件循环是 js 实现异步的一种方法,也是 js 的执行机制。

  1. JavaScript 的执行和运行

执行和运行有很大的区别,JavaScript 在不同的环境下,比如 node,浏览器,Ringo(是一个构建在 Java 虚拟机上的 JS 平台) 等,执行方式是不同的。而运行大多指 JavaScript 解析引擎,是统一的。

  1. setImmediate

微任务和宏任务还有很多种类,比如 setImmediate 等等,执行都是有共同点的。

JavaScript 是一门单线程语言,Event Loop 是 JavaScript 的执行机制

  1. 微任务是 JS 级别的,宏任务是宿主级别的,是包含关系,不是先后关系;每一次事件轮询中,都是宏任务先执行,微任务后执行。
更新: 8/21/2021, 10:40:34 PM