笔者以前面试的时候常常遇到写一堆 setTimeout
,setImmediate
来问哪个先执行。本文次要就是来讲这个问题的,然而不是简略的讲讲哪个先,哪个后。抽象的晓得 setImmediate
比setTimeout(fn, 0)
先执行是不够的,因为有些状况下 setTimeout(fn, 0)
是会比 setImmediate
先执行的。要彻底搞明确这个问题,咱们须要零碎的学习 JS 的异步机制和底层原理。本文就会从异步基本概念登程,始终讲到 Event Loop 的底层原理,让你彻底搞懂 setTimeout
,setImmediate
,Promise
, 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,而后调用 syncFunc
,syncFunc
外面 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
事件循环就是一个循环,是各个异步线程用来通信和协同执行的机制。各个线程为了替换音讯,还有一个专用的数据区,这就是事件队列。各个异步线程执行完后,通过事件触发线程将回调事件放到事件队列,主线程每次干完手上的活儿就来看看这个队列有没有新活儿,有的话就取出来执行。画成一个流程图就是这样:
流程解说如下:
- 主线程每次执行时,先看看要执行的是同步工作,还是异步的 API
- 同步工作就继续执行,始终执行完
- 遇到异步 API 就将它交给对应的异步线程,本人继续执行同步工作
- 异步线程执行异步 API,执行完后,将异步回调事件放入事件队列上
- 主线程手上的同步工作干完后就来事件队列看看有没有工作
- 主线程发现事件队列有工作,就取出外面的工作执行
- 主线程一直循环上述流程
定时器不准
Event Loop 的这个流程外面其实还是暗藏了一些坑的,最典型的问题就是总是先执行同步工作,而后再执行事件队列外面的回调。这个个性就间接影响了定时器的执行,咱们想想咱们开始那个 2 秒定时器的执行流程:
- 主线程执行同步代码
- 遇到
setTimeout
,将它交给定时器线程- 定时器线程开始计时,2 秒到了告诉事件触发线程
- 事件触发线程将定时器回调放入事件队列,异步流程到此结束
- 主线程如果有空,将定时器回调拿进去执行,如果没空这个回调就始终放在队列里。
上述流程咱们能够看出,如果主线程长时间被阻塞,定时器回调就没机会执行,即便执行了,那工夫也不准了,咱们将结尾那两个例子联合起来就能够看出这个成果:
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 秒后,同步代码执行结束才有机会执行这个定时器回调。所以再次强调,写代码时肯定不要长时间占用主线程。
引入微工作
后面的流程图我为了便于了解,简化了事件队列,其实事件队列外面的事件还能够分两类:宏工作和微工作。微工作领有更高的优先级,当事件循环遍历队列时,先查看微工作队列,如果外面有工作,就全副拿来执行,执行完之后再执行一个宏工作。执行每个宏工作之前都要查看下微工作队列是否有工作,如果有,优先执行微工作队列。所以残缺的流程图如下:
上图须要留神以下几点:
- 一个 Event Loop 能够有一个或多个事件队列,然而只有一个微工作队列。
- 微工作队列全副执行完会从新渲染一次
- 每个宏工作执行完都会从新渲染一次
- requestAnimationFrame 处于渲染阶段,不在微工作队列,也不在宏工作队列
所以想要晓得一个异步 API 在哪个阶段执行,咱们得晓得他是宏工作还是微工作。
常见宏工作有:
script
(能够了解为外层同步代码)setTimeout/setInterval
setImmediate
(Node.js)- I/O
- UI 事件
postMessage
常见微工作有:
Promise
process.nextTick
(Node.js)Object.observe
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,这个没什么说的,同步代码最先执行
console.log('2');
在setTimeout
外面,setTimeout
是宏工作,“2”进入宏工作队列console.log('5');
在Promise.then
外面,进入微工作队列console.log('3');
在 Promise 构造函数的参数外面,这其实是同步代码,间接输入console.log('4');
在 then 外面,他会进入微工作队列,查看事件队列时先执行微工作- 同步代码运行后果是“1,3”
- 而后查看微工作队列,输入“5,4”
- 最初执行宏工作队列,输入“2”
Node.js 的 Event Loop
Node.js 是运行在服务端的 js,尽管他也用到了 V8 引擎,然而他的服务目标和环境不同,导致了他 API 与原生 JS 有些区别,他的 Event Loop 还要解决一些 I /O,比方新的网络连接等,所以与浏览器 Event Loop 也是不一样的。Node 的 Event Loop 是分阶段的,如下图所示:
- timers: 执行
setTimeout
和setInterval
的回调- pending callbacks: 执行提早到下一个循环迭代的 I/O 回调
- idle, prepare: 仅零碎外部应用
- poll: 检索新的 I/O 事件; 执行与 I/O 相干的回调。事实上除了其余几个阶段解决的事件,其余简直所有的异步都在这个阶段解决。
- check:
setImmediate
在这里执行- close callbacks: 一些敞开的回调函数,如:
socket.on('close', ...)
每个阶段都有一个本人的先进先出的队列,只有当这个队列的事件执行完或者达到该阶段的下限时,才会进入下一个阶段。在每次事件循环之间,Node.js 都会查看它是否在期待任何一个 I / O 或者定时器,如果没有的话,程序就敞开退出了。咱们的直观感触就是,如果一个 Node 程序只有同步代码,你在控制台运行完后,他就本人退出了。
还有个须要留神的是 poll
阶段,他前面并不一定每次都是 check
阶段,poll
队列执行完后,如果没有 setImmediate
然而有定时器到期,他会绕回去执行定时器阶段:
setImmediate
和setTimeout
下面的这个流程说简略点就是 在一个异步流程 里,setImmediate
会比定时器先执行,咱们写点代码来试试:
console.log('outer');
setTimeout(() => {setTimeout(() => {console.log('setTimeout');
}, 0);
setImmediate(() => {console.log('setImmediate');
});
}, 0);
上述代码运行如下:
和咱们后面讲的一样,setImmediate
先执行了。咱们来理一下这个流程:
- 外层是一个
setTimeout
,所以执行他的回调的时候曾经在timers
阶段了- 解决外面的
setTimeout
,因为本次循环的timers
正在执行,所以他的回调其实加到了下个timers
阶段- 解决外面的
setImmediate
,将它的回调退出check
阶段的队列- 外层
timers
阶段执行完,进入pending callbacks
,idle, prepare
,poll
,这几个队列都是空的,所以持续往下- 到了
check
阶段,发现了setImmediate
的回调,拿进去执行- 而后是
close callbacks
,队列是空的,跳过- 又是
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)。原理咱们都有了,咱们来理一下流程:
- 外层同步代码一次性全副执行完,遇到异步 API 就塞到对应的阶段
- 遇到
setTimeout
,尽管设置的是 0 毫秒触发,然而被 node.js 强制改为 1 毫秒,塞入times
阶段- 遇到
setImmediate
塞入check
阶段- 同步代码执行结束,进入 Event Loop
- 先进入
times
阶段,查看以后工夫过来了 1 毫秒没有,如果过了 1 毫秒,满足setTimeout
条件,执行回调,如果没过 1 毫秒,跳过- 跳过空的阶段,进入 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');
});
});
这里 setTimeout
和setImmediate
在 readFile
的回调外面,因为 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');
});
});
这段代码的打印如下:
咱们还是来理一下流程:
- 咱们代码根本都在
readFile
回调外面,他本人执行时,曾经在poll
阶段- 遇到
setTimeout(fn, 0)
,其实是setTimeout(fn, 1)
,塞入前面的timers
阶段- 遇到
setImmediate
,塞入前面的check
阶段- 遇到
nextTick
,立马执行,输入 ’nextTick 1′- 到了
check
阶段,输入 ’setImmediate’, 又遇到个nextTick
, 立马输入 ’nextTick 2′- 到了下个
timers
阶段,输入 ’setTimeout’
这种机制其实相似于咱们后面讲的微工作,然而并不齐全一样, 比方同时有 nextTick
和Promise
的时候,必定是 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,当初咱们再来总结一下:
- JS 所谓的“单线程”只是指主线程只有一个,并不是整个运行环境都是单线程
- JS 的异步靠底层的多线程实现
- 不同的异步 API 对应不同的实现线程
- 异步线程与主线程通信靠的是 Event Loop
- 异步线程实现工作后将其放入工作队列
- 主线程一直轮询工作队列,拿出工作执行
- 工作队列有宏工作队列和微工作队列的区别
- 微工作队列的优先级更高,所有微工作解决完后才会解决宏工作
-
Promise
是微工作 - Node.js 的 Event Loop 跟浏览器的 Event Loop 不一样,他是分阶段的
-
setImmediate
和setTimeout(fn, 0)
哪个回调先执行,须要看他们自身在哪个阶段注册的,如果在定时器回调或者 I / O 回调外面,setImmediate
必定先执行。如果在最外层或者setImmediate
回调外面,哪个先执行取决于过后机器情况。 -
process.nextTick
不在 Event Loop 的任何阶段,他是一个非凡 API,他会立刻执行,而后才会继续执行 Event Loop
文章的最初,感激你破费贵重的工夫浏览本文,如果本文给了你一点点帮忙或者启发,请不要悭吝你的赞和 GitHub 小星星,你的反对是作者继续创作的能源。
作者博文 GitHub 我的项目地址:https://github.com/dennis-jiang/Front-End-Knowledges