笔者以前面试的时候经常遇到写一堆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是运行在这个平台上的语言
Chrome浏览器打开多个标签页, 在 任务管理器 中看到有多个进程(一个Tab页面一个独立进程外加一个主程)
同步和异步
同步异步简单理解就是,同步的代码都是按照书写顺序执行的,异步的代码可能跟书写顺序不一样,写在后面的可能先执行。下面来看个例子:
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页面一个进程,互不影响。主要作用有页面渲染,脚本执行,事件处理等
浏览器多进程的优势
- 相对于单进程, 多进程的优点, 避免单个页面崩溃影响整个浏览器
- 避免第三方插件崩溃影响整个浏览器
- 多进程充分利用多核优势
- 方便使用沙盒模型隔离插件等进程,提高浏览器稳定性
渲染进程
渲染进程(多线程)包含的线程(也叫浏览器内核: 渲染内核 + JS内核 + 等等),是浏览器内核
GUI线程
- 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等
- 当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
⚠️ 注意: GUI渲染线程与JS引擎线程是互斥的,当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行
JS引擎线程
- 这个线程就是负责执行JS的主线程,大名鼎鼎的Chrome V8引擎就是在这个线程运行的
- JS引擎一直等待着任务队列(事件队列)中任务的到来,然后加以处理,一个Tab页(renderer进程)中无论什么时候都只有一个JS线程在运行JS程序
⚠️ 注意: 这个线程跟GUI线程是互斥的。互斥的原因是JS也可以操作DOM,如果JS线程和GUI线程同时操作DOM,结果就混乱了,不知道到底渲染哪个结果。这带来的后果就是如果JS长时间运行,GUI线程就不能执行,整个页面就感觉卡死了。所以我们最开始例子的while(true)这样长时间的同步代码在真正开发时是绝对不允许的
定时触发器线程
- 传说中的 setTimeout 与 setInterval 所在线程, 所以“单线程的JS”能够实现异步。
- 浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确)
- 因此通过单独线程来计时并触发定时(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
- 注意,W3C在HTML标准中规定,规定要求setTimeout中低于4ms的时间间隔算为4ms。所以, 即便setTimeout设置为0, 事实上也是4ms
- 定时器线程其实只是一个计时的作用,他并不会真正执行时间到了的回调,真正执行这个回调的还是JS主线程。所以当时间到了定时器线程会将这个回调事件给到事件触发线程,然后事件触发线程将它加到事件队列里面去。最终JS主线程从事件队列取出这个回调执行。事件触发线程不仅会将定时器事件放入任务队列,其他满足条件的事件也是他负责放进任务队列
事件触发线程
- 用来控制事件循环(可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助)
- 当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中
- 当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
- 注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行
异步HTTP请求线程
- 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
- 这个线程负责处理异步的ajax请求,当请求完成后,他也会通知事件触发线程,然后事件触发线程将这个事件放入事件队列给主线程执行
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中。再由JavaScript引擎执行
所以JS异步的实现靠的就是浏览器的多线程,当他遇到异步API时,就将这个任务交给对应的线程,当这个异步API满足回调条件时,对应的线程又通过事件触发线程将这个事件放入任务队列,然后主线程从任务队列取出事件继续执行。这个流程我们多次提到了任务队列,这其实就是Event Loop,下面我们详细来讲解下
梳理浏览器内核中线程之间的关系
load事件与DOMContentLoaded事件
DOMContentLoaded: 仅当DOM加载完成,不包括样式表
load: 页面上所有的DOM,样式表,脚本,图片都已经加载完成了(渲染完毕了)
JS阻塞页面加载
JS如果执行时间过长就会阻塞页面。 譬如,假设JS引擎正在进行巨量的计算,此时就算GUI有更新,也会被保存到队列中,等待JS引擎空闲后执行。 然后,由于巨量计算,所以JS引擎很可能很久很久后才能空闲,自然页面渲染加载阻塞
css加载是否会阻塞dom树渲染
这里说的是头部引入css的情况, 由于css是由单独的下载线程异步下载的。 然后
- css加载不会阻塞DOM树解析(异步加载时DOM照常构建)
- 但会阻塞render树渲染(渲染时需等css加载完毕,因为render树需要css信息)
因为你加载css的时候,可能会修改下面DOM节点的样式,
如果css加载不阻塞render树渲染的话,那么当css加载完之后,render树可能又得重新重绘或者回流了,这就造成了一些没有必要的损耗。
所以干脆就先把DOM树的结构先解析完,把可以做的工作做完,然后等你css加载完之后,在根据最终的样式来渲染render树,这种做法性能方面确实会比较好一点
普通图层和复合图层
- 普通文档流内可以理解为一个复合图层, absolute(fixed)也都默认是跟普通文档流在同一复合图层中
- 如果a是一个复合图层,而且b在a上面,那么b也会被隐式转为一个复合图层,这点需要特别注意在GPU中,各个复合图层是单独绘制的,所以互不影响
- 某些动画, 为了防止DOM更新然后全部页面回流重绘, 所以会通过translate3d等方式, 另起一个复合图层, 节省性能(硬件加速)
Event Loop
所谓Event Loop,就是事件循环,其实就是JS管理事件执行的一个流程,具体的管理办法由他具体的运行环境确定。目前JS的主要运行环境有两个
- 浏览器
- Node.js
这两个环境的Event Loop还有点区别,我们会分开来讲
浏览器的Event Loop
事件循环就是一个循环,是各个异步线程用来通讯和协同执行的机制。各个线程为了交换消息,还