笔者以前面试的时候经常遇到写一堆setTimeout,setImmediate来问哪个先执行。本文主要就是来讲这个问题的,但是不是简单的讲讲哪个先,哪个后。笼统的知道setImmediate比setTimeout(fn, 0)先执行是不够的,因为有些情况下setTimeout(fn, 0)是会比setImmediate先执行的。要彻底搞明白这个问题,我们需要系统的学习JS的异步机制和底层原理。本文就会从异步基本概念出发,一直讲到Event Loop的底层原理,让你彻底搞懂setTimeout,setImmediate,Promise, process.nextTick谁先谁后这一类问题浏览器是多进程的 浏览器的简化理解
- 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
- 浏览器是多进程的,市场上的大多数浏览器是用C#, C ,C++写的
- 浏览器之所以能够运行,是因为系统给它的进程分配了资源(cpu、内存)
- 简单点理解,每打开一个Tab页,就相当于创建了一个独立的浏览器进程
- 而js能够在浏览器上运行,是因为大名鼎鼎的 v8引擎
??注意: 可以这么理解,浏览器是平台载体,而js是运行在这个平台上的语言
文章图片
同步和异步 同步异步简单理解就是,同步的代码都是按照书写顺序执行的,异步的代码可能跟书写顺序不一样,写在后面的可能先执行。下面来看个例子:
const syncFunc = () => {
const starTime = new Date().getTime();
while (true) {
if (new Date().getTime() - starTime > 2000) {
break;
}
}
console.log(2);
}console.log(1);
syncFunc();
console.log(3);
里面while循环会运行2秒,然后打印出2,最后打印出3。所以这里代码的执行顺序跟我们的书写顺序是一致,他是同步代码:syncFunc,syncFunc上述代码会先打印出1,然后调用环会运行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的运行环境主要是浏览器,以大家都很熟悉的Chrome的内核为例,他不仅是多线程的,而且是多进程的:
文章图片
上图只是一个概括分类,意思是Chrome有这几类的进程和线程,并不是每种只有一个,比如渲染进程就有多个,每个选项卡都有自己的渲染进程。有时候我们使用Chrome会遇到某个选项卡崩溃或者没有响应的情况,这个选项卡对应的渲染进程可能就崩溃了,但是其他选项卡并没有用这个渲染进程,他们有自己的渲染进程,所以其他选项卡并不会受影响。这也是Chrome单个页面崩溃并不会导致浏览器崩溃的原因,而不是像老IE那样,一个页面卡了导致整个浏览器都卡。
对于前端工程师来说,主要关心的还是渲染进程,下面来分别看下里面每个线程是做什么的
Browser进程
浏览器的主进程(负责协调、主控),只有一个, 作用有- 负责浏览器界面显示,与用户交互。如前进,后退等
- 负责各个页面的管理,创建和销毁其他进程
- 将Renderer进程得到的内存中的Bitmap,绘制到用户界面上
- 网络资源的管理,下载等
第三方插件进程
每种类型的插件对应一个进程,仅当使用该插件时才创建GPU进程
最多一个,用于3D绘制等浏览器渲染进程
浏览器内核(Renderer进程,内部是多线程的):默认每个Tab页面一个进程,互不影响。主要作用有页面渲染,脚本执行,事件处理等浏览器多进程的优势
- 相对于单进程, 多进程的优点, 避免单个页面崩溃影响整个浏览器
- 避免第三方插件崩溃影响整个浏览器
- 多进程充分利用多核优势
- 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性
GUI线程
- 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等
- 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
JS引擎线程
- 这个线程就是负责执行JS的主线程,大名鼎鼎的Chrome V8引擎就是在这个线程运行的
- JS引擎一直等待着任务队列(事件队列)中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
定时触发器线程
- 传说中的 setTimeout 与 setInterval 所在线程, 所以“单线程的JS”能够实现异步。
- 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
- 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
- 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。所以, 即便setTimeout设置为0, 事实上也是4ms
- 定时器线程其实只是一个计时的作用,他并不会真正执行时间到了的回调,真正执行这个回调的还是JS主线程。所以当时间到了定时器线程会将这个回调事件给到事件触发线程,然后事件触发线程将它加到事件队列里面去。最终JS主线程从事件队列取出这个回调执行。事件触发线程不仅会将定时器事件放入任务队列,其他满足条件的事件也是他负责放进任务队列
- 用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
- 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
- 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行
- 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
- 这个线程负责处理异步的ajax请求,当请求完成后,他也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行
梳理浏览器内核中线程之间的关系
load事件与DOMContentLoaded事件 DOMContentLoaded: 仅当DOM加载完成,不包括样式表
load: 页面上所有的DOM,样式表,脚本,图片都已经加载完成了(渲染完毕了)
JS阻塞页面加载 JS如果执行时间过长就会阻塞页面。 譬如,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。 然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然页面渲染加载阻塞
css加载是否会阻塞dom树渲染 这里说的是头部引入css的情况, 由于css是由单独的下载线程异步下载的。 然后
- css加载不会阻塞DOM树解析(异步加载时DOM照常构建)
- 但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息)
如果css加载不阻塞render树渲染的话,那么当css加载完之后,render树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。
所以干脆就先把DOM树的结构先解析完,把可以做的工作做完,然后等你css加载完之后,在根据最终的样式来渲染render树,这种做法性能方面确实会比较好一点
普通图层和复合图层
- 普通文档流内可以理解为一个复合图层, absolute(fixed)也都默认是跟普通文档流在同一复合图层中
- 如果a是一个复合图层,而且b在a上面,那么b也会被隐式转为一个复合图层,这点需要特别注意在GPU中,各个复合图层是单独绘制的,所以互不影响
- 某些动画, 为了防止DOM更新然后全部页面回流重绘, 所以会通过translate3d等方式, 另起一个复合图层, 节省性能(硬件加速)
- 浏览器
- Node.js
这两个环境的Event Loop还有点区别,我们会分开来讲
事件循环就是一个循环,是各个异步线程用来通讯和协同执行的机制。各个线程为了交换消息,还有一个公用的数据区,这就是事件队列。各个异步线程执行完后,通过事件触发线程将回调事件放到事件队列,主线程每次干完手上的活儿就来看看这个队列有没有新活儿,有的话就取出来执行。画成一个流程图就是这样:
文章图片
文章图片
文章图片
流程讲解如下:
- 主线程每次执行时,先看看要执行的是同步任务,还是异步的API
- 同步任务就继续执行,一直执行完
- 遇到异步API就将它交给对应的异步线程,自己继续执行同步任务
- 异步线程执行异步API,执行完后,将异步回调事件放入事件队列上
- 主线程手上的同步任务干完后就来事件队列看看有没有任务
- 主线程发现事件队列有任务,就取出里面的任务执行
- 主线程不断循环上述流程
- 主线程执行同步代码
- 遇到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处于渲染阶段,不在微任务队列,也不在宏任务队列
常见宏任务有
- script (可以理解为外层同步代码)
- setTimeout/setInterval
- setImmediate(Node.js)
- I/O
- UI事件
- postMessage
- Promise
- process.nextTick(Node.js)
- Object.observe
- MutaionObserver
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');
})for (let i = 0;
i < 10;
i++) {
console.log("这是for里面的输出事件")
}/*上述代码的输出是
1
3
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
这是for里面的输出事件
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”
async function async1() {
console.log("async1 start");
//2
await async2()
console.log("async1 end");
//4
}async function async2() {
console.log("async2");
//3
}console.log("script start");
//1setTimeout(() => {
console.log("定时器");
//8
}, 0);
async1()
new Promise(function (resolove) {
console.log("Promise");
//5
resolove()
}).then(()=>{
console.log("Promise then");
//7
});
console.log("script end");
//6
Node.js的Event Loop
Node.js是运行在服务端的js,虽然他也用到了V8引擎,但是他的服务目的和环境不同,导致了他API与原生JS有些区别,他的Event Loop还要处理一些I/O,比如新的网络连接等,所以与浏览器Event Loop也是不一样的。Node的Event Loop是分阶段的
在Node v10及以前,微任务和宏任务在Node的执行顺序:
- 执行完一个阶段的所有任务
- 执行完nextTick队列里面的内容
- 然后执行完微任务队列的内容
文章图片
??注意: 首先需要知道的是Node版本不同,执行顺序有所差异。因为Node v11之后, 事件循环的原理发生了变化,和浏览器执行顺序趋于一致,都是每执行一个宏任务就执行完微任务队列
文章图片
- 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(() => {
console.log('setTimeout')
}, 0)setImmediate(() => {
console.log('setImmediate')
})
上述代码运行如下:
文章图片
和我们前面讲的一样,setImmediate先执行了。我们来理一下这个流程:
- 外层是一个setTimeout,所以执行他的回调的时候已经在timers阶段了
- 处理里面的setTimeout,因为本次循环的timers正在执行,所以他的回调其实加到了下个timers阶段
- 处理里面的setImmediate,将它的回调加入check阶段的队列
- 外层timers阶段执行完,进入pending callbacks,idle, prepare,poll,这几个队列都是空的,所以继续往下
- 到了check阶段,发现了setImmediate的回调,拿出来执行
- 然后是close callbacks,队列时空的,跳过
- 又是timers阶段,执行我们的console
console.log('outer')setTimeout(() => {
setTimeout(() => {
console.log('setTimeout')
}, 0)setImmediate(() => {
console.log('setImmediate')
})
}, 0)
我们来运行下看看效果:
文章图片
好像是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回调
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');
});
});
console.log('---------');
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’
const promise = Promise.resolve()setImmediate(() => {
console.log('setImmediate');
});
promise.then(() => {
console.log('promise')
})process.nextTick(() => {
console.log('nextTick')
})
代码运行结果如下:
文章图片
宏任务和微任务相关面试题
面试题1
async function async1() {
console.log('async1 start')
await async2()// !重点 new Promise(()=> {}).then(()=> console.log('async1 end'))
console.log('async1 end') // 该任务是在.then里
}
async function async2() {
console.log('async2')
}
console.log('script start')
setTimeout(function () {
console.log('setTimeout')
}, 0)
async1()
new Promise(function (resolve) {
console.log('promise1')
resolve()
}).then(function () {
console.log('promsie2')
})
console.log('script end')/*
script start
async1 start
async2
promise1
script end
async1 end
promsie2
setTimeout
*/
面试题2
console.log('start')setTimeout(() => {
console.log('children2')
Promise.resolve().then(() => { // 直接把这个promsie resove出去了,然后把.then微任务的回调放入任务队列里
console.log('children3')
})
}, 0)new Promise(function (resolve) {
// 宏任务代码
console.log('children4')
setTimeout(() => {
console.log('children5') // 在这个promise里,一定要resolve出去,有结果,.then里的微任务回调才会被添加到任务队列里;所以在第一轮中,.then并没有添加到微任务队列里
resolve('children6')// 这一句执行了才是把.then微任务放入队列里
}, 0);
}).then(res => {// 微任务代码
console.log('children7')
setTimeout(() => {
console.log(res)
}, 0)
})/* 输出结果
start
children4
children2
children3
children5
children7
children6第一轮:整体代码宏任务 没有微任务
start => chilren4执行第二轮宏任务
children2清空微任务;
chilren3第三轮宏任务
children5
微任务
children7第四轮宏任务
children 6
陷阱1: 在promise里,一定要resolve出去,有结果,.then里的微任务回调才会被添加到任务队列里;所以在第一轮中,.then并没有添加到微任务队列里
*/
面试题3
const p = function () {
return new Promise((resolve, reject) => {
// 因为p(),所以这里面作为第一轮宏任务开始执行
const p1 = new Promise((resolve, rejct) => {
setTimeout(() => { // 陷阱:promise只能resolve一次,状态一旦被改变就不能撤回, 所以这一段不执行
resolve(1)
}, 0)
resolve(2)
})
p1.then(res => {
console.log(res)
})
console.log(3)
resolve(4)
})
}p().then(res => {
console.log(res)
})
console.log('end')/*
3
end
2
4第一轮宏任务 3 => end
清空微任务 2 4
陷阱:promise只能resolve一次,状态一旦被改变就不能撤回, 所以setTimeout这一段不执行
*/
文末之声
【前端面试|【1.1w字】面试常问Javascript 事件循环、同步异步、宏微任务,彻底明白原来这么简单】?? 文章首发于 【一缕清风】,欢迎关注
文章部分图片从网络中获取,如若侵权,请联系删除
与君远相知,不道云海深。祝你在前端的领域中所向披靡,一起走上人生巅峰
转载文献
推荐阅读
- react.js|props基本使用React
- javascript|JavaScript理论题(一)
- 排序算法|JS优化版(二叉搜索树第k大节点)
- javascript|睡前做几道JavaScript理论练习题吧
- vue.js|Vue练习题--不定时分享
- 吃透前端工程化,大厂级实战项目以战带练
- JS DY6 数组
- js dy5 (函数,事件,对象)
- js dy3 感觉需要注意的地方(包含循环)