网上有很多文章讲解 JS 的运行机制,宏任务、微任务、事件循环等,但是很少有讲得很清晰明白的,我个人总结了一下,主要原因应该有以下几种:

  • 专业词汇过多,且没有解释
  • 由于这个内容属于 JS 相对比较高阶的底层知识,所以默认认为读者很多东西都清楚,所以背景交代不全,讲解顺序混乱
  • 从读者方面来说,JS 的异步操作已存在固有印象,会认为是 JS 的一部分,而不能理解它与单线程、多线程的关系
  • 大部分文章都会配图,而绘制的图上文字描述或者走线的顺序交代不够清楚就很容易看不懂

所以提升了这个部分,也就是 JS 运行机制的相关知识的难度,实际上它并不是很难,于是我便尝试着写这篇文章,希望以最全面的方式梳理清楚整个 JS 的运行机制,欢迎大家留言探讨、交流。

JavaScript 是一门单线程的语言,它多线程的实现是通过 Event Loop (事件循环)机制来实现的。

什么是单线程?

首先我们要知道 JavaScript 的一个语言特性就是单线程,也就是说,同一个时间只能做一件事,当有多个任务时,只能按照顺序上一个任务完成了再执行下一个,上一个任务未完成则会一直等待。

1
2
3
console.log(1)
console.log(2)
console.log(3)

这段代码,毫无疑问会依次输出 1、2、3,我们可以把每一次打印当做成一个任务,我们的代码会从上往下开始运行,这个 从上往下依次运行,我们可以理解为 主线程,也就是第一条运行起来的 线程,当代码执行到第一个任务的时候会立刻输出 1,任务完成了之后才会执行第2个任务输出 2,同理当第2个任务执行完之后才会执行第3个任务输出 3,这是单线程的运行机制,所以这段代码的输出永远固定的都是 1,2,3,而不会随其他因素(例如网络波动)的影响,而改变执行结果(顺序)。

为什么 JavaScript 是单线程?

作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM(文档对象模型)和 BOM(浏览器对象模型),而多线程需要共享资源,多线程编程经常面临状态同步等问题,这决定了 JavaScript 只能是单线程。

举个例子: JavaScript 同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该个线程为准?

所以为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

单线程带来的问题以及解决方案

单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。如果排队是因为计算量大,CPU 忙不过来倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

1
2
3
4
5
6
7
8
9
10
11
let data = [];

$.ajax({
url:www.javascript.com,
data:data,
success:() => {
console.log('发送成功!');
}
})

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

在分析这段代码之前,请先暂时忘记同步异步的概念,按照上面的理论,从上往下执行,顺序应该是:

  • 执行任务1: 创建变量 data,赋值为 [],结束任务1
  • 执行任务2:
    • 调用 ajax 方法
    • data 参数赋值
    • 像服务端发送请求
    • 等待服务端返回结果
    • 拿到服务端返回的结果
    • 打印 发送成功
    • 结束任务2
  • 执行任务3: 打印 代码执行结束

注意: 执行任务2中 等待服务端返回结果 这一步时,假设数据量巨大,那么这个等待时间可能会很久,等待过程中代码将暂停运行,并且cpu也处于空闲状态,这便是单线程带来的问题。

JavaScript 语言的设计者意识到,这时主线程完全可以不管IO设备,挂起处于等待中的任务,先运行排在后面的任务。等到IO设备返回了结果,再回过头,把挂起的任务继续执行下去。

于是便有了解决方案,就是将所有任务分成两种,一种是同步任务 synchronous,另一种是异步任务 asynchronous

同步和异步

同步任务 指的是前一个任务结束后再执行后一个任务,程序的执行顺序与任务的排列顺序是一致的。

如果我们将做家务这个事情当做一个任务,同步做法就是: 将衣服放进洗衣机开始洗衣服,衣服洗好需要45分钟,站在洗衣机旁边等待45分钟,然后去晾晒衣服,然后再拖地,洗碗。

回到代码中来理解就是: 代码从上往下依次运行。

异步任务 指的是执行某个任务会花费一定的时间,而在做这件事的同时,你还可以去处理其他事情。

还是上面做家务的例子,那么异步的做法就是: 将衣服放进洗衣机开始洗衣服,衣服洗好需要45分钟,在这个过程中去拖地、洗碗,45分钟之后(并且已经完成了拖地、洗碗的任务)再去晾晒衣服。

过程中洗衣机45分钟洗衣服的部分,就是 异步任务

回到代码中来理解就是: 代码还是从上往下依次运行,如果遇到了 异步任务,不进入主线程、而进入 任务队列 去执行,只有等主线程任务执行完毕,任务队列开始通知主线程,请求执行任务,该任务才会进入主线程执行。

代码执行期间遇到的每个任务都会判断一下是否为异步任务,如果是异步任务则丢给任务队列,继续往下执行,由此可得结论: 同步任务永远优先于异步任务。

在搞清楚上面讲的执行顺利之后,我们就该开始认识一下,在 JavaScript 中,到底哪些算是异步任务。

主要的异步任务有:

  • Events: javascript 各种事件的执行都是异步任务, 如 onclick;
  • 定时器 setTimeoutsetInterval;
  • XMLHttpRequest 也就是 Ajax;
  • requestAnimationFrame 类似于定时器,但是是以每一帧为单位;
  • fetch Fetch API 提供了一个 JavaScript 接口, 用于访问和操纵 HTTP 管道的一些具体部分;
  • Promise
  • async function
  • process.nextTick

通过个简单的例子,回顾执行顺序:

1
2
3
4
5
6
7
console.log(1)

document.onclick = function () {
console.log(2)
}

console.log(3)

所以这段代码执行顺序应该是:1、3,等待用户点击之后输出 2,没有点击则不输出,由于 onclick 是异步任务,它必须等待 1、3 输出完毕才能输出 2,所以无论你点击的速度(执行事件)有多快,输出的 2 都会在 1、3 后面。

微任务和宏任务

  • macro-task(宏任务):包括整体代码 scriptsetTimeoutsetInterval
  • micro-task(微任务):Promiseprocess.nextTick

宏任务定义

宏任务,其实就是标准 JavaScript 机制下的常规任务,或者简单的说,就是指任务队列中的等待被主线程执行的事件。在宏任务执行过程中,v8引擎都会建立新栈存储任务,宏任务中执行不同的函数调用,栈随执行变化,当该宏任务执行结束时,会清空当前的栈,接着主线程继续执行下一个宏任务。

微任务定义

微任务必须是一个异步的执行的任务,这个执行的时间需要在主函数执行之后,也就是微任务建立的函数执行后,而又需要在当前宏任务结束之前。

触发宏任务的方式

  • script 中的代码块
  • setTimeout()
  • setInterval()
  • setImmediate() (非标准,IE 和 Node.js 中支持)
  • 注册事件

触发微任务的方式

  • Promise
  • MutationObserver
  • queueMicrotask()

为什么要有微任务

微任务的出现其实就是语言设计中的一种实时性和效率的权衡体现。当宏任务执行时间太久,就会影响到后续任务的执行,而此时因为某些需求,编程人员需要让某些任务在宿主环境(比如浏览器)提供的事件循环下一轮执行前执行完毕,提高实时性,这就是微任务存在的意义。

事件循环中的同步、宏任务、微任务图解复盘

自测题

最后我们来分析一段较复杂的代码,看看你是否真的掌握了js的执行机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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')
})
})