JS 基础篇(十):JS的执行机制Event Loop --- 浏览器篇

337 阅读8分钟

思考一下下面这段代码的输出是怎样的?

console.log(1);

setTimeout(function () {
	console.log(2);

	new Promise(function (resolve, reject) {
		console.log(3);
		resolve();
		console.log(4);
	}).then(function () {
		console.log(5);
	});
});

function fn() {
	console.log(6);
	setTimeout(function () {
		console.log(7);
	}, 50);
}

new Promise(function (resolve, reject) {
	console.log(8);
	resolve();
	console.log(9);
}).then(function () {
	console.log(10);
});

fn();

console.log(11);

一、JavaScript说明

我们都知道,javascript从诞生之日起就是一门单线程的非阻塞的脚本语言。这是由其最初的用途来决定的:与浏览器交互。

1.1 JavaScript是单线程

我们都知道 JavaScript 是单线程的,那么既然有单线程就有多线程,首先看看单线程与多线程的区别:

  • 单线程: 从头执行到尾,一行一行执行,如果其中一行代码报错,那么剩下代码将不再执行。同时容易代码阻塞。
  • 多线程: 代码运行的环境不同,各线程独立,互不影响,避免阻塞。

那为什么JavaScript是单线程的呢?

JavaScript 的单线程,与它的用途有关。作为浏览器脚本语言,JavaScript 的主要用途是与用户互动,以及操作 DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个 DOM 节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准呢? 所以,为了避免复杂性,从一诞生,JavaScript 就是单线程,这已经成了这门语言的核心特征,将来也不会改变。

1.2 JavaScript是非阻塞

非阻塞指的是当代码需要进行一项异步任务(无法立刻返回结果,需要花一定时间才能返回的任务,如I/O事件)的时候,主线程会挂起(pending)这个任务,然后在异步任务返回结果的时候再根据一定规则去执行相应的回调。

那么javascript引擎到底是如何实现非阻塞的呢?答案就是今天这篇文章的主角——event loop(事件循环)。

注:虽然nodejs中的也存在与传统浏览器环境下的相似的事件循环。然而两者间却有着诸多不同,故把两者分开,单独解释。

二、JavaScript事件循环

2.1 执行栈与任务队列

JS单线程就意味着,所有任务需要排队,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。

如果排队是因为计算量大,CPU忙不过来,倒也算了,但是很多时候CPU是闲着的,因为IO设备(输入输出设备)很慢(比如Ajax操作从网络读取数据),不得不等着结果出来,再往下执行。

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

于是,广义上将 JavaScript 所有任务可以分成两种,一种是同步任务(synchronous),另一种是异步任务(asynchronous)。

主线程运行的时候,产生堆(heap)和栈(stack),栈中的代码调用各种外部API,它们在"任务队列"中加入各种事件(DOM Event,ajax,setTimeout...)。只要栈中的代码执行完毕,主线程就会去读取任务队列,依次执行那些事件所对应的回调函数。

  • 堆(heap): 对象被分配在一个堆中,即用以表示一个大部分非结构化的内存区域。

  • 执行栈(stack): 运行同步代码。执行栈中的代码(同步任务),总是在读取"任务队列"(异步任务)之前执行。

  • 任务队列(Event queue):
    "任务队列"是一个事件的队列(也可以理解成消息的队列),IO设备完成一项任务,就在"任务队列"中添加一个事件,表示相关的异步任务的回调函数可以进入"执行栈"了。主线程读取"任务队列",就是读取里面有哪些事件。

    "任务队列"中的事件,除了IO设备的事件以外,还包括一些用户产生的事件(比如鼠标点击、页面滚动等等)。只要指定过回调函数,这些事件完成后,就会在“任务队列”中添加回调事件,等待主线程读取。

    "任务队列"是一个先进先出的数据结构,排在前面的事件,优先被主线程读取。主线程的读取过程基本上是自动的,只要执行栈一清空,"任务队列"上第一位的事件就自动进入主线程。

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

2.2、宏任务与微任务

以上的事件循环过程是一个宏观的表述,除了广义的同步任务和异步任务,其实对任务还有更细致的划分

  • macro-task(宏任务):包括整体代码script,setTimeout,setInterval,ajax,dom操作
  • micro-task(微任务):Promise,process.nextTick

具体来说,宏任务与微任务执行的运行机制如下:

  • (1)首先,将"执行栈"最开始的所有同步代码(宏任务)执行完成;
  • (2)检查是否有微任务,如有则执行所有的微任务;
  • (3)取出"任务队列"中事件所对应的回调函数(宏任务)进入"执行栈"并执行完成;
  • (4)再检查是否有微任务,如有则执行所有的微任务;
  • (5)主线程不断重复上面的(3)(4)步。
setTimeout(function() {
    console.log('setTimeout');
})

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

console.log('console');

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

三、代码解析

回到开头给出的代码,我们来一步一步解析:
1、运行"执行栈"中的代码:

console.log(1);

// setTimeout(function () { // 作为宏任务,暂不执行,放到宏任务队列中(1)
// 	console.log(2);
//
// 	new Promise(function (resolve, reject) {
// 		console.log(3);
// 		resolve();
// 		console.log(4);
// 	}).then(function () {
// 		console.log(5);
// 	});
// });

function fn() {
	console.log(6);
	//setTimeout(function () { // 作为宏任务,暂不执行,放到宏任务队列中(2)
	//	console.log(7);
	//}, 50);
}

new Promise(function (resolve, reject) {
	console.log(8);
	resolve();
	console.log(9);
})
// .then(function () { // 作为微任务,暂不执行,放到微任务队列中
// 	console.log(10);
// });

fn();

console.log(11);

此时输出为:1 8 9 6 11

2、检查微任务队列,运行微任务

new Promise(function (resolve, reject) {
	// console.log(8); // 已执行
	// resolve(); // 已执行
	// console.log(9); // 已执行
})
.then(function () {
	console.log(10);
});

此时输出为:10

3、读取"宏任务队列(1)"到"执行栈":

setTimeout(function () {
	console.log(2);

	new Promise(function (resolve, reject) {
		console.log(3);
		resolve();
		console.log(4);
	})
	//.then(function () { // 作为微任务,暂不执行
	//	console.log(5);
	//});
});

此时输出为:2 3 4

4、再次检查微任务队列,运行微任务:

setTimeout(function () {
	// console.log(2); // 已执行

	new Promise(function (resolve, reject) {
		// console.log(3); // 已执行
		// resolve(); // 已执行
		// console.log(4); // 已执行
	})
	.then(function () {
		console.log(5);
	});
});

此时输出为:5

5、读取"宏任务队列(2)"到"执行栈":

// function fn() { // 已执行
	// console.log(6); // 已执行
	setTimeout(function () {
		console.log(7);
	}, 50);
// }

此时输出为:7

6、检查微任务队列,发现无任务

7、读取宏任务队列,发现无任务,执行全部结束

综上,最终的输出顺序是:1 8 9 6 11 10 2 3 4 5 7

四、思考&感悟

javascrit的事件循环是这门语言中非常重要且基础的概念。清楚的了解了事件循环的执行顺序和每一个阶段的特点,可以使我们对一段异步代码的执行顺序有一个清晰的认识,从而减少代码运行的不确定性。合理的使用各种延迟事件的方法,有助于代码更好的按照其优先级去执行。

五、参考

这一次,彻底弄懂 JavaScript 执行机制
全方位理解JavaScript的Event Loop
详解JavaScript中的Event Loop(事件循环)机制
js-代码运行机制,宏任务、微任务