为什么代码没有按编写顺序执行
前端工程师算是最幸运的软件工程师,因为从一开始就可以接触到“异步”这种高级特性,比如 DOM 事件、AJAX 请求及定时器;同时也是最不幸的软件工程师,因为入门 JavaScript 的时候就要习惯异步这种高难度的开发方式,异步经常会导致输出的结果与我们的预期不一致。
异步和同步
这两个概念大家应该都比较熟悉啦,简单解释一下,要比较同步和异步,可以将调用函数的过程分成两部分:执行操作和返回结果。程序在同步调用函数的时候,会立即执行操作并等待得到返回结果后再继续运行,也就是说同步执行是阻塞的。而异步会将操作和结果在时间上分隔开来,在当下执行操作,在未来某个时刻返回结果,在这个等待返回结果的过程中,程序将继续执行后面的代码。也就是说异步执行是非阻塞的。这里就不举🌰啦。
异步与回调
我们经常调用 JavaScript 的异步函数可能会认为:异步操作都采用回调函数的形式。毕竟从浏览器端的 DOM 事件、AJAX 请求、定时器到 Node.js 端的文件读写、多进程,都是采用的回调形式。那么还会有其他case嘛,上🌰。
下面是一段简单的代码,定义了一个 JSON 对象 a,然后把它打印到控制台,最后再将对象 a 的 couter.index 属性值自增 1。
1 | var a = { |
我们在控制台里看一下,结果可能和我们的预期不一致,输出了一个JSON 对象:{conter:{index: 2}}。原因在于浏览器在运行代码的时候,把控制台打印这种涉及 I/O 的操作进行了延迟执行。可能有人会推测是不是控制台打印的只是将对象 a 进行了类似“浅拷贝”的操作,否定这种猜想很简单,此时再执行一次自增操作,就会发现被打印的对象值并没有发生变化。
既然并非所有异步都回调,那么反过来,是否所有回调函数都是异步执行的呢?答案也是否定的。比如数组原型函数 forEach,它有两个参数,第一个是回调函数,第二个是 this 指向的对象,这里的回调就是同步的。
异步原理
回顾了异步的基础概念,下面就来深入讲解异步的原理。
事件循环
对于大多数语言而言,实现异步会通过启动额外的进程、线程或协程来实现,而我们在前面已经提到过,JavaScript 是单线程的。为什么单线程还能实现异步呢?其实也没有什么特殊的黑魔法,只是把一些操作交给了其他线程处理,然后采用了一种称之为“事件循环”(也称“事件轮询”)的机制来处理返回结果。
下面我们用一段简化的代码,来描述事件循环机制。
数组 eventLoop 表示事件队列(也有称作“任务队列”),用来存放需要执行的任务事件(可以理解为回调函数),对象 event 变量表示当前需要执行的任务事件。用一个永不停止的 while 循环来表示事件循环,每一次循环称为一个 tick。对每个 tick 而言,如果在队列中有等待事件,那么就会从队列中获取一个事件并执行,这些事件通常是回调函数的形式。
1 | var eventLoop = []; // 事件队列,先进先出 |
那么这个事件队列里的事件是怎么来的呢?以 AJAX 请求为例,当我们发出一个 AJAX 请求时,浏览器会将请求任务分派给网络线程来进行处理,当对应的网络线程拿到返回的数据之后,就会把回调函数插入到事件队列中。setTimeout 和 setInterval 也是同样的道理,当我们执行 setTimeout 的时候并不是直接把回调函数放入事件队列中。它所做的是交给定时器线程来处理,当定时器到时后,再把回调函数放在事件队列中,这样,在未来的某轮 tick 中获取并执行这个回调函数。这么做有一个隐性的问题,如果事件队列中已经有其他事件,那么这个回调就会排队等待。所以说 setTimeout/setInterval 定时器的精度并不高。准确地说,它只能确保回调函数不会在指定的时间间隔之前运行,但可能会在那个时刻运行,也可能在那之后运行,这就要根据事件队列的状态而定。
事件队列
在讲述 setTimeout/setInterval 原理的时候也暴露了事件队列的一个缺陷:事件队列按照先进先出的顺序执行,那么如果队列较长时,排在后面的事件即使较为“紧急”,也得需要等待前面的任务先执行完成。JavaScript 解决这个问题的思路就是:设置多个队列,按照优先级来执行。
下面这段代码可以验证 JavaScript 内部拥有优先级不同的 2 个队列,我们暂时称为红色队列和绿色队列,其中红色队列优先级高于绿色队列。这段代码定义了 4 个异步函数 f1、f2、f3、f4,其中:函数 f1 通过定时器 setTimeout 向绿色队列中插入一个控制台打印任务,输出数字 1;函数 f2 通过 Promise 向红色队列中插入一个控制台打印任务,输出数字 2;函数 f3 通过定时器 setTimeout 向绿色队列中插入一个回调函数,该回调函数会调用控制台打印数字 3,并且调用函数 f2;函数 f4 通过 Promise 向红色队列中插入一个回调函数,该回调函数会调用控制台打印数字 4,并且调用函数 f1。
1 | function f1() { |
当调用函数 f3 和函数 f4 之后,绿色队列和红色队列都会被插入一个匿名回调函数。第 1 次 tick,由于红色队列优先级高,所以先执行红色匿名函数,控制台打印数字 4,然后调用函数 f1,向绿色队列中插入一个打印函数;第 2 次 tick,按照先进先出原则,此时调用匿名函数打印数字 3,并调用函数 f2,向红色队列中插入一个打印函数;第 3 次 tick,调用红色队列中的打印函数,控制台打印数字 2;第 4 次 tick,调用绿色队列中的打印函数,控制台打印数字 1。
关于红色队列和绿色队列,一般称为“宏任务队列(Macro Task Queue)”和“微任务队列(Micro Task Queue)”,不同队列优先级不同,每次事件循环时会从优先级高的队列中获取事件,只有当优先级高的队列为空时才会从优先级低的队列中获取事件,同级队列之间的事件不存在优先级,只遵循先进先出的原则。
常见的异步函数优先级如下,从上到下优先级逐层降低:
1 | process.nextTick(Node.js) > |