乐趣区

js异步从入门到放弃(一)- Event Loop模型

前言
异步一直是前端开发里最让人头疼的一个难点,接下来的几篇文章,将围绕这个话题展开。
1. 单线程的语言 -JavaScript
众所周知,JS 最初的目的是用于处理浏览器的用户交互和操作 DOM,因此,如果 JS 设计成允许同时存在 2 个以上的线程,就会出现以下这种问题:
2 个线程同时操作了同一个 DOM 节点(a 线程要编辑该节点,而 b 线程删除该节点),那么此时浏览器将无法处理,因为无法判断以哪个线程为基准。因此,JS 只能是单线程。(Web Worker API 虽然提供了多线程,但只是纯粹基于使用多核 cpu 的计算能力,其创建的子线程严格受控,不影响 JS 单线程的设计实质),单线程的设计就意味着,任务以排队的方式依此执行。
基于单线程设计,不可避免的遇到一个情形:某些任务需要的时间很长,但不是因为任务本身太过复杂,难以处理,而是输入输出太慢(例如 Ajax 获取数据)。而在等待输入输出的过程中,CPU 是闲置的,为了充分利用资源,这一类任务被设计成允许暂时挂起,等到有了结果再执行的任务。
现在有两种任务了:同步任务和异步任务
接下来介绍 JS 的处理机制。
2. Event Loop
理论基础
首先看来自 MDN 的一张图:

栈 (stack),函数调用堆栈。
看这个例子:
function a(){
console.log(‘a’)
}

function b(){
console.log(‘from’)
a() // 这里调用了函数 a
}
b()
在 Chrome 中运行,并且单步调试,可以看到以下步骤:

执行 b() 时,函数 b 进栈(如图 1)
在 b 中调用函数 a 时,a 继续进栈(如图 2)
函数 a 执行完毕,出栈(如图 1)

(这部分内容实际上对应着之前介绍闭包时,函数作用域链的生成部分,传送门)

堆 (heap),内存区,用于存储对象。(这个目前不是很重要先不用管)

队列 (queue),待处理消息队列,每一个消息都关联着一个用以处理这个消息的函数。

常见示例:

让页面中的某个按钮,点击时触发 handleClick 函数,那么,当用户触发点击按钮的动作时,会有一个待处理消息进入 queue,关联的函数为 handleClick。
发起一个 ajax 请求,当请求有结果之后,会有一个待处理消息进入 queue,关联的函数为所指定的回调函数

整体运行过程
整体的执行过程如下(如图):

主线程执行同步代码,执行过程会产生对应的函数调用栈 stack,如果碰到有异步事件,如发起 ajax 请求,则提交给对应的异步模块处理,当异步任务有结果时,异步模块负责在消息队列中添加待处理的消息;
当同步任务处理完成,函数调用栈清空时,主线程检查消息队列 queue:如果消息队列不为空,那么从消息队列头部取出一个待处理的消息,进入主线程;
主线程重复以上过程

上述过程循环执行,所以称为事件循环(Event Loop)
// 简单的例子
var req = new XMLHttpRequest();
req.open(‘GET’, url);
req.onload = function (){}; // 指定回调函数,这是一个异步任务,会被先提交到异步处理的 api,等有了结果才会添加到消息队列
req.send();
* 任务队列类型
补充说明以下,任务队列分成 2 类:

microtask queue:ES6 的 promise 产生的任务队列
macrotask queue:除 microtask queue 以外的任务产生的任务队列,如(事件触发 setTimeout Ajax 请求)

他们的区别下次讲解 Promise 时再说明(挖个坑)
3. 定时器
上述 Event Loop 模型中,消息队列的新消息来源,除了有 dom 事件操作,ajax 请求等,也可能是定时任务,也就是由 setTimeout 创建的任务。这个函数大家肯定不陌生,但是也可能未必真的足够熟悉~。
setTimeout 接受两个参数:

回调函数
延迟执行的毫秒数。(严格来说,应该是实际加入到主线程的最小延迟时间,为什么呢,往下看)

现在看下以下 2 个例子:
// 示例 1
console.log(1);
setTimeout(function(){console.log(2);},1000);
console.log(3);
// 输出结果 1 3 2 , 因为 setTimeout 指定了里面的函数要推迟 1000 毫秒才会执行
这个例子说明了 setTimeout 的基本作用,比较简单不多说。
// 示例 2
const s = new Date().getSeconds(); // 获取当前的秒数
setTimeout(function() {
// 输出 “2”,表示回调函数并没有在 500 毫秒之后立即执行
console.log(“Ran after ” + (new Date().getSeconds() – s) + ” seconds”);
}, 500);

while(true) {// 这个循环含义就是,至少要过 2s,当前主线程任务才执行完毕
if(new Date().getSeconds() – s >= 2) {
console.log(“Good, looped for 2 seconds”);
break;
}
}

// 实际输出
Good, looped for 2 seconds
eventloop.html:15 Ran after 2 seconds
这个例子,首先使用 setTimeout 指定了一个 500 毫秒后执行的回调函数,然后使用 while 循环故意让当前运行超过 2 秒钟,根据上文的流程图可知:
其实在第 500 毫秒时,这个消息已经被添加到消息队列,但是由于当前的主线程并没有执行完,调用栈尚未清空,所以在 500 毫秒不会执行 setTimeout 指定的回调函数。实际上,即使把上述代码中的 500 改成 0,结果也是一样的。
简而言之,setTimeout(fn,x 毫秒) 的 x 只是指定了 fn 被执行的最小等待时间,息具体能在多少时间之后执行,取决于现有调用栈函数的执行进度,以及消息队列中前面的任务执行进度。
小结
本文介绍了 Event Loop 模型过程以及常见的任务队列的几种任务队列消息来源,这是 JS 异步话题的基础篇。
参考文献:MDN-EventLoopJavaScript 运行机制详解:再谈 Event Loop

惯例:如果内容有错误的地方欢迎指出(觉得看着不理解不舒服想吐槽也完全没问题);如果有帮助,欢迎点赞和收藏,转载请征得同意后著明出处,如果有问题也欢迎私信交流,主页有邮箱地址

退出移动版