笔者以前面试的时候常常遇到写一堆setTimeout,setImmediate来问哪个先执行。本文次要就是来讲这个问题的,然而不是简略的讲讲哪个先,哪个后。抽象的晓得setImmediatesetTimeout(fn, 0)先执行是不够的,因为有些状况下setTimeout(fn, 0)是会比setImmediate先执行的。要彻底搞明确这个问题,咱们须要零碎的学习JS的异步机制和底层原理。本文就会从异步基本概念登程,始终讲到Event Loop的底层原理,让你彻底搞懂setTimeout,setImmediatePromise, process.nextTick谁先谁后这一类问题。

同步和异步

同步异步简略了解就是,同步的代码都是依照书写程序执行的,异步的代码可能跟书写程序不一样,写在前面的可能先执行。上面来看个例子:

const syncFunc = () => {  const time = new Date().getTime();  while(true) {    if(new Date().getTime() - time > 2000) {      break;    }  }  console.log(2);}console.log(1);syncFunc();console.log(3);

上述代码会先打印出1,而后调用syncFuncsyncFunc外面while循环会运行2秒,而后打印出2,最初打印出3。所以这里代码的执行程序跟咱们的书写程序是统一,他是同步代码:

再来看个异步例子:

const asyncFunc = () => {  setTimeout(() => {    console.log(2);  }, 2000);}console.log(1);asyncFunc();console.log(3);

上述代码的输入是:

能够看到咱们两头调用的asyncFunc外面的2却是最初输入的,这是因为setTimeout是一个异步办法。他的作用是设置一个定时器,等定时器工夫到了再执行回调外面的代码。所以异步就相当于做一件事,然而并不是马上做,而是你先给他人打了个招呼,说xxx条件满足的时候就干什么什么。就像你早晨睡觉前在手机上设置了一个第二天早上7天的闹钟,就相当于给了手机一个异步事件,触发条件是工夫达到早上7点。应用异步的益处是你只须要设置好异步的触发条件就能够去干别的事件了,所以异步不会阻塞骨干上事件的执行。特地是对于JS这种只有一个线程的语言,如果都像咱们第一个例子那样去while(true),那浏览器就只有始终卡死了,只有等这个循环运行完才会有响应

JS异步是怎么实现的

咱们都晓得JS是单线程的,那单线程是怎么实现异步的呢?事实上所谓的"JS是单线程的"只是指JS的主运行线程只有一个,而不是整个运行环境都是单线程。JS的运行环境次要是浏览器,以大家都很相熟的Chrome的内核为例,他不仅是多线程的,而且是多过程的:

上图只是一个概括分类,意思是Chrome有这几类的过程和线程,并不是每种只有一个,比方渲染过程就有多个,每个选项卡都有本人的渲染过程。有时候咱们应用Chrome会遇到某个选项卡解体或者没有响应的状况,这个选项卡对应的渲染过程可能就解体了,然而其余选项卡并没有用这个渲染过程,他们有本人的渲染过程,所以其余选项卡并不会受影响。这也是Chrome单个页面解体并不会导致浏览器解体的起因,而不是像老IE那样,一个页面卡了导致整个浏览器都卡。

对于前端工程师来说,次要关怀的还是渲染过程,上面来别离看下外面每个线程是做什么的。

GUI线程

GUI线程就是渲染页面的,他解析HTML和CSS,而后将他们构建成DOM树和渲染树就是这个线程负责的。

JS引擎线程

这个线程就是负责执行JS的主线程,后面说的"JS是单线程的"就是指的这个线程。赫赫有名的Chrome V8引擎就是在这个线程运行的。须要留神的是,这个线程跟GUI线程是互斥的。互斥的起因是JS也能够操作DOM,如果JS线程和GUI线程同时操作DOM,后果就凌乱了,不晓得到底渲染哪个后果。这带来的结果就是如果JS长时间运行,GUI线程就不能执行,整个页面就感觉卡死了。所以咱们最开始例子的while(true)这样长时间的同步代码在真正开发时是相对不容许的

定时器线程

后面异步例子的setTimeout其实就运行在这里,他跟JS主线程基本不在同一个中央,所以“单线程的JS”可能实现异步。JS的定时器办法还有setInterval,也是在这个线程。

事件触发线程

定时器线程其实只是一个计时的作用,他并不会真正执行工夫到了的回调,真正执行这个回调的还是JS主线程。所以当工夫到了定时器线程会将这个回调事件给到事件触发线程,而后事件触发线程将它加到事件队列外面去。最终JS主线程从事件队列取出这个回调执行。事件触发线程不仅会将定时器事件放入工作队列,其余满足条件的事件也是他负责放进工作队列。

异步HTTP申请线程

这个线程负责解决异步的ajax申请,当申请实现后,他也会告诉事件触发线程,而后事件触发线程将这个事件放入事件队列给主线程执行。

所以JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个工作交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入工作队列,而后主线程从工作队列取出事件继续执行。这个流程咱们屡次提到了工作队列,这其实就是Event Loop,上面咱们具体来解说下。

Event Loop

所谓Event Loop,就是事件循环,其实就是JS治理事件执行的一个流程,具体的治理方法由他具体的运行环境确定。目前JS的次要运行环境有两个,浏览器和Node.js。这两个环境的Event Loop还有点区别,咱们会离开来讲。

浏览器的Event Loop

事件循环就是一个循环,是各个异步线程用来通信和协同执行的机制。各个线程为了替换音讯,还有一个专用的数据区,这就是事件队列。各个异步线程执行完后,通过事件触发线程将回调事件放到事件队列,主线程每次干完手上的活儿就来看看这个队列有没有新活儿,有的话就取出来执行。画成一个流程图就是这样:

流程解说如下:

  1. 主线程每次执行时,先看看要执行的是同步工作,还是异步的API
  2. 同步工作就继续执行,始终执行完
  3. 遇到异步API就将它交给对应的异步线程,本人继续执行同步工作
  4. 异步线程执行异步API,执行完后,将异步回调事件放入事件队列上
  5. 主线程手上的同步工作干完后就来事件队列看看有没有工作
  6. 主线程发现事件队列有工作,就取出外面的工作执行
  7. 主线程一直循环上述流程

定时器不准

Event Loop的这个流程外面其实还是暗藏了一些坑的,最典型的问题就是总是先执行同步工作,而后再执行事件队列外面的回调。这个个性就间接影响了定时器的执行,咱们想想咱们开始那个2秒定时器的执行流程:

  1. 主线程执行同步代码
  2. 遇到setTimeout,将它交给定时器线程
  3. 定时器线程开始计时,2秒到了告诉事件触发线程
  4. 事件触发线程将定时器回调放入事件队列,异步流程到此结束
  5. 主线程如果有空,将定时器回调拿进去执行,如果没空这个回调就始终放在队列里。

上述流程咱们能够看出,如果主线程长时间被阻塞,定时器回调就没机会执行,即便执行了,那工夫也不准了,咱们将结尾那两个例子联合起来就能够看出这个成果:

const syncFunc = (startTime) => {  const time = new Date().getTime();  while(true) {    if(new Date().getTime() - time > 5000) {      break;    }  }  const offset = new Date().getTime() - startTime;  console.log(`syncFunc run, time offset: ${offset}`);}const asyncFunc = (startTime) => {  setTimeout(() => {    const offset = new Date().getTime() - startTime;    console.log(`asyncFunc run, time offset: ${offset}`);  }, 2000);}const startTime = new Date().getTime();asyncFunc(startTime);syncFunc(startTime);

执行后果如下:

通过后果能够看出,尽管咱们先调用的asyncFunc,尽管asyncFunc写的是2秒后执行,然而syncFunc的执行工夫太长,达到了5秒,asyncFunc尽管在2秒的时候就曾经进入了事件队列,然而主线程始终在执行同步代码,始终没空,所以也要等到5秒后,同步代码执行结束才有机会执行这个定时器回调。所以再次强调,写代码时肯定不要长时间占用主线程

引入微工作

后面的流程图我为了便于了解,简化了事件队列,其实事件队列外面的事件还能够分两类:宏工作和微工作。微工作领有更高的优先级,当事件循环遍历队列时,先查看微工作队列,如果外面有工作,就全副拿来执行,执行完之后再执行一个宏工作。执行每个宏工作之前都要查看下微工作队列是否有工作,如果有,优先执行微工作队列。所以残缺的流程图如下:

上图须要留神以下几点:

  1. 一个Event Loop能够有一个或多个事件队列,然而只有一个微工作队列。
  2. 微工作队列全副执行完会从新渲染一次
  3. 每个宏工作执行完都会从新渲染一次
  4. requestAnimationFrame处于渲染阶段,不在微工作队列,也不在宏工作队列

所以想要晓得一个异步API在哪个阶段执行,咱们得晓得他是宏工作还是微工作。

常见宏工作有:

  1. script (能够了解为外层同步代码)
  2. setTimeout/setInterval
  3. setImmediate(Node.js)
  4. I/O
  5. UI事件
  6. postMessage

常见微工作有:

  1. Promise
  2. process.nextTick(Node.js)
  3. Object.observe
  4. MutaionObserver

下面这些事件类型中要留神Promise,他是微工作,也就是说他会在定时器后面运行,咱们来看个例子:

console.log('1');setTimeout(() => {  console.log('2');},0);Promise.resolve().then(() => {  console.log('5');})new Promise((resolve) => {  console.log('3');  resolve();}).then(() => {  console.log('4');})

上述代码的输入是1,3,5,4,2。因为:

  1. 先输入1,这个没什么说的,同步代码最先执行
  2. console.log('2');setTimeout外面,setTimeout是宏工作,“2”进入宏工作队列
  3. console.log('5');Promise.then外面,进入微工作队列
  4. console.log('3');在Promise构造函数的参数外面,这其实是同步代码,间接输入
  5. console.log('4');在then外面,他会进入微工作队列,查看事件队列时先执行微工作
  6. 同步代码运行后果是“1,3”
  7. 而后查看微工作队列,输入“5,4”
  8. 最初执行宏工作队列,输入“2”

Node.js的Event Loop

Node.js是运行在服务端的js,尽管他也用到了V8引擎,然而他的服务目标和环境不同,导致了他API与原生JS有些区别,他的Event Loop还要解决一些I/O,比方新的网络连接等,所以与浏览器Event Loop也是不一样的。Node的Event Loop是分阶段的,如下图所示:

  1. timers: 执行setTimeoutsetInterval的回调
  2. pending callbacks: 执行提早到下一个循环迭代的 I/O 回调
  3. idle, prepare: 仅零碎外部应用
  4. poll: 检索新的 I/O 事件;执行与 I/O 相干的回调。事实上除了其余几个阶段解决的事件,其余简直所有的异步都在这个阶段解决。
  5. check: setImmediate在这里执行
  6. close callbacks: 一些敞开的回调函数,如:socket.on('close', ...)

每个阶段都有一个本人的先进先出的队列,只有当这个队列的事件执行完或者达到该阶段的下限时,才会进入下一个阶段。在每次事件循环之间,Node.js都会查看它是否在期待任何一个I/O或者定时器,如果没有的话,程序就敞开退出了。咱们的直观感触就是,如果一个Node程序只有同步代码,你在控制台运行完后,他就本人退出了。

还有个须要留神的是poll阶段,他前面并不一定每次都是check阶段,poll队列执行完后,如果没有setImmediate然而有定时器到期,他会绕回去执行定时器阶段:

setImmediatesetTimeout

下面的这个流程说简略点就是在一个异步流程里,setImmediate会比定时器先执行,咱们写点代码来试试:

console.log('outer');setTimeout(() => {  setTimeout(() => {    console.log('setTimeout');  }, 0);  setImmediate(() => {    console.log('setImmediate');  });}, 0);

上述代码运行如下:

和咱们后面讲的一样,setImmediate先执行了。咱们来理一下这个流程:

  1. 外层是一个setTimeout,所以执行他的回调的时候曾经在timers阶段了
  2. 解决外面的setTimeout,因为本次循环的timers正在执行,所以他的回调其实加到了下个timers阶段
  3. 解决外面的setImmediate,将它的回调退出check阶段的队列
  4. 外层timers阶段执行完,进入pending callbacksidle, preparepoll,这几个队列都是空的,所以持续往下
  5. 到了check阶段,发现了setImmediate的回调,拿进去执行
  6. 而后是close callbacks,队列是空的,跳过
  7. 又是timers阶段,执行咱们的console

然而请留神咱们下面console.log('setTimeout')console.log('setImmediate')都包在了一个setTimeout外面,如果间接写在最外层会怎么样呢?代码改写如下:

console.log('outer');setTimeout(() => {  console.log('setTimeout');}, 0);setImmediate(() => {  console.log('setImmediate');});

咱们来运行下看看成果:

如同是setTimeout先输入来,咱们多运行几次看看:

怎么setImmediate又先进去了,这代码是见鬼了还是啥?这个世界上是没有鬼怪的,所以事件都有起因的,咱们顺着之前的Event Loop再来理一下。无理之前,须要通知大家一件事件,node.js外面setTimeout(fn, 0)会被强制改为setTimeout(fn, 1),这在官网文档中有阐明。(说到这里顺便提下,HTML 5外面setTimeout最小的工夫限度是4ms)。原理咱们都有了,咱们来理一下流程:

  1. 外层同步代码一次性全副执行完,遇到异步API就塞到对应的阶段
  2. 遇到setTimeout,尽管设置的是0毫秒触发,然而被node.js强制改为1毫秒,塞入times阶段
  3. 遇到setImmediate塞入check阶段
  4. 同步代码执行结束,进入Event Loop
  5. 先进入times阶段,查看以后工夫过来了1毫秒没有,如果过了1毫秒,满足setTimeout条件,执行回调,如果没过1毫秒,跳过
  6. 跳过空的阶段,进入check阶段,执行setImmediate回调

通过上述流程的梳理,咱们发现要害就在这个1毫秒,如果同步代码执行工夫较长,进入Event Loop的时候1毫秒曾经过了,setTimeout执行,如果1毫秒还没到,就先执行了setImmediate。每次咱们运行脚本时,机器状态可能不一样,导致运行时有1毫秒的差距,一会儿setTimeout先执行,一会儿setImmediate先执行。然而这种状况只会产生在还没进入timers阶段的时候。像咱们第一个例子那样,因为曾经在timers阶段,所以外面的setTimeout只能等下个循环了,所以setImmediate必定先执行。同理的还有其余poll阶段的API也是这样的,比方:

var fs = require('fs')fs.readFile(__filename, () => {    setTimeout(() => {        console.log('setTimeout');    }, 0);    setImmediate(() => {        console.log('setImmediate');    });});

这里setTimeoutsetImmediatereadFile的回调外面,因为readFile回调是I/O操作,他自身就在poll阶段,所以他外面的定时器只能进入下个timers阶段,然而setImmediate却能够在接下来的check阶段运行,所以setImmediate必定先运行,他运行完后,去查看timers,才会运行setTimeout

相似的,咱们再来看一段代码,如果他们两个不是在最外层,而是在setImmediate的回调外面,其实状况跟外层一样,后果也是随缘的,看上面代码:

console.log('outer');setImmediate(() => {  setTimeout(() => {    console.log('setTimeout');  }, 0);  setImmediate(() => {    console.log('setImmediate');  });});

起因跟写在最外层差不多,因为setImmediate曾经在check阶段了,外面的循环会从timers阶段开始,会先看setTimeout的回调,如果这时候曾经过了1毫秒,就执行他,如果没过就执行setImmediate

process.nextTick()

process.nextTick()是一个非凡的异步API,他不属于任何的Event Loop阶段。事实上Node在遇到这个API时,Event Loop基本就不会持续进行,会马上停下来执行process.nextTick(),这个执行完后才会持续Event Loop。咱们写个例子来看下:

var fs = require('fs')fs.readFile(__filename, () => {    setTimeout(() => {        console.log('setTimeout');    }, 0);    setImmediate(() => {        console.log('setImmediate');                process.nextTick(() => {          console.log('nextTick 2');        });    });    process.nextTick(() => {      console.log('nextTick 1');    });});

这段代码的打印如下:

咱们还是来理一下流程:

  1. 咱们代码根本都在readFile回调外面,他本人执行时,曾经在poll阶段
  2. 遇到setTimeout(fn, 0),其实是setTimeout(fn, 1),塞入前面的timers阶段
  3. 遇到setImmediate,塞入前面的check阶段
  4. 遇到nextTick,立马执行,输入'nextTick 1'
  5. 到了check阶段,输入'setImmediate',又遇到个nextTick,立马输入'nextTick 2'
  6. 到了下个timers阶段,输入'setTimeout'

这种机制其实相似于咱们后面讲的微工作,然而并不齐全一样,比方同时有nextTickPromise的时候,必定是nextTick先执行,起因是nextTick的队列比Promise队列优先级更高。来看个例子:

const promise = Promise.resolve()setImmediate(() => {  console.log('setImmediate');});promise.then(()=>{    console.log('promise')})process.nextTick(()=>{    console.log('nextTick')})

代码运行后果如下:

总结

本文从异步基本概念登程始终讲到了浏览器和Node.js的Event Loop,当初咱们再来总结一下:

  1. JS所谓的“单线程”只是指主线程只有一个,并不是整个运行环境都是单线程
  2. JS的异步靠底层的多线程实现
  3. 不同的异步API对应不同的实现线程
  4. 异步线程与主线程通信靠的是Event Loop
  5. 异步线程实现工作后将其放入工作队列
  6. 主线程一直轮询工作队列,拿出工作执行
  7. 工作队列有宏工作队列和微工作队列的区别
  8. 微工作队列的优先级更高,所有微工作解决完后才会解决宏工作
  9. Promise是微工作
  10. Node.js的Event Loop跟浏览器的Event Loop不一样,他是分阶段的
  11. setImmediatesetTimeout(fn, 0)哪个回调先执行,须要看他们自身在哪个阶段注册的,如果在定时器回调或者I/O回调外面,setImmediate必定先执行。如果在最外层或者setImmediate回调外面,哪个先执行取决于过后机器情况。
  12. process.nextTick不在Event Loop的任何阶段,他是一个非凡API,他会立刻执行,而后才会继续执行Event Loop

文章的最初,感激你破费贵重的工夫浏览本文,如果本文给了你一点点帮忙或者启发,请不要悭吝你的赞和GitHub小星星,你的反对是作者继续创作的能源。

作者博文GitHub我的项目地址: https://github.com/dennis-jiang/Front-End-Knowledges