前言
先来一句鸡血开启本文吧:比你优秀的大佬们比你还要努力,你还有什么资格下班回到家后就躺在那刷抖音,刷剧。二十几岁的年纪却过着六十几岁的生活...
笔者认为不论是身处在哪个行业,善于学习跟总结的人总能拥有各种各样的机会,如果兴趣不够,那就只能靠自律了。
相信很多小伙伴不管是在面试,还是日常的工作当中一定会遇到这样的一些问题:
- 面试官:说一下你对js的运行机制是如何理解的?
- 改需求bug:我的setTimeout明明是写在console.log()之前,为啥console.log()却先执行呢?
面对上述问题,相信很多小伙伴通常都是一知半解,或者大概清楚什么是js事件循环,什么是异步任务跟同步任务,但是如果一旦深挖其背后的原理,可能就一脸懵逼了?正如下图的小哥哥一样...
本文大概从以下几个方面来深入的解析js在浏览器中的运行机制:
- 浏览器多进程与js引擎线程之间的关系
- 事件循环(Event Loop)中同步跟异步任务是如何分工及执行的顺序的不同
- 结合常见的面试题来解析js的运行机制
- 浏览器端跟Node端js运行的机制区别在哪
浏览器的多进程有哪些
在了解浏览器进程之前,我们先来科普一下什么是进程,具有什么特点?
- 程序的最小运行环境进程:一个程序运行时,需要执行环境,包括执行上下文、代码、数据,和一个执行任务的主线程。在程序启动时,操作系统为程序会分配一块内存空间来初始化这样的运行环境,这样的一个运行环境称之为进程
- 线程负责执行任务:程序的执行最终是在进程中的线程内执行的
- 线程由进程管理:进程中的线程是不能独立存在的,由进程来管理
- 线程阻塞:进程中的线程是阻塞的,任意线程执行出错都会导致进程崩溃
- 数据共享:线程之间共享进程中的数据
- 进程隔离:进程之间是相互隔离相互独立的内存空间,可以通过IPC通信
- 内存回收:进程运行中,可以手动或自动控制内存的回收,或者在进程关闭之后,操作系统会回收内存,已达到内存循环利用的效果
我们打开chrome浏览器的任务管理器可以发现,当我们每次打开一个tab的时候都会新开启一个全新的进程。
浏览器是多进程的
通常浏览器每次新打开一个tab页都会自动开启一个新的进程,这些进程包括有哪些呢?
- Browser进程 浏览器的主进程(负责协调、主控),只有一个
- GPU进程 用于3D绘制
- 第三方插件进程 当我们从chrome商店里下载浏览器插件并且使用的时候,就会开启一个相对应的进程
- 浏览器渲染进程(浏览器的内核) 默认一个tab开启一个,互相之间不受影响,主要负责控制页面的渲染,同步、异步的事件处理
如图所示:
浏览器的内核是多线程的
浏览器渲染进程(浏览器内核)中到底会包含哪些线程呢?
大致可以分为以下几类:
- GUI线程
- 事件触发线程
- JS引擎线程
- 定时器线程
- 网络请求线程
那么每一个线程又负责做什么工作呢?
GUI线程
- 负责渲染浏览器界面,解析HTML,CSS,构建DOM树和Render树,布局跟绘制。
- 当我们修改元素的尺寸,页面就会回流(Reflow)
- 当我们修改了一些元素的颜色或者背景色,页面就会重绘(Repaint)
注意:
GUI渲染线程与JS引擎线程是互斥的
- 当JS引擎执行时GUI线程会被挂起(相当于被冻结了)
- GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行
事件触发线程
- 归属于浏览器内核进程,不受JS引擎线程控制。主要用于控制事件(例如鼠标,键盘等事件),当该事件被触发时候,事件触发线程就会把该事件的处理函数推进事件队列,等待JS引擎线程执行
JS引擎线程
- JS引擎线程就是JS内核,负责处理Javascript脚本程序(例如V8引擎)
- JS引擎线程负责解析Javascript脚本,运行代码
- JS引擎一直等待着任务队列中任务的到来,然后加以处理
- 浏览器同时只能有一个JS引擎线程在运行JS程序,所以js是单线程运行的
- GUI渲染线程与JS引擎线程是互斥的,js引擎线程会阻塞GUI渲染线程
- 我们常遇到的JS执行时间过长,造成页面的渲染不连贯,导致页面渲染加载阻塞(就是加载慢)
- 例如浏览器渲染的时候遇到
定时器线程
- 主要控制计时器setInterval和延时器setTimeout,用于定时器的计时,计时完毕,满足定时器的触发条件,则将定时器的处理函数推进事件队列中,等待JS引擎线程执行。
网络请求线程
- 在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
- 将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中再由JavaScript引擎执行
- 简单说就是当执行到一个http异步请求时,就把异步请求事件添加到异步请求线程,等收到响应(准确来说应该是http状态变化),再把回调函数添加到事件队列,等待js引擎线程来执行
js的本质是单线程的
为什么js是单线程的呢?
作为浏览器脚本语言,JavaScript的主要用途是与用户互动,以及操作DOM。这决定了它只能是单线程,否则会带来很复杂的同步问题。比如,假定JavaScript同时有两个线程,一个线程在某个DOM节点上添加内容,另一个线程删除了这个节点,这时浏览器应该以哪个线程为准?所以,为了避免复杂性,从一诞生,JavaScript就是单线程,这已经成了这门语言的核心特征,将来也不会改变。
事件循环 Event Loop
同步任务&异步任务
刚才我们已经解释了js为什么是单线程的,那么单线程就意味着js在处理任务的时候是需要排队等候的,前一个任务结束,才会执行后一个任务。如果前一个任务耗时很长,后一个任务就不得不一直等着。所以,js的执行任务可以分为两种:同步任务和异步任务。
- 同步任务:在主线程上排队执行的任务,只有前一个任务执行完毕,才能执行后一个任务;
- 异步任务:不进入主线程、而进入"任务队列"(task queue)的任务,只有"任务队列"通知主线程,某个异步任务可以执行了,该任务才会进入主线程执行。
两种任务的执行机制大概有如下几步:
- 所有同步任务都在主线程上执行,形成一个执行栈(execution context stack)。
- 主线程之外,还存在一个"任务队列"(task queue)。只要异步任务有了运行结果,就在"任务队列"之中放置一个事件。
- 一旦"执行栈"中的所有同步任务执行完毕,系统就会读取"任务队列",看看里面有哪些事件。那些对应的异步任务,于是结束等待状态,进入执行栈,开始执行。
- 主线程不断重复上面的第三步。
当主线程执行完成之后,就会去读取"任务队列",然后重复的进行,这就是事件循环。
下面我们用一种图来理解一下事件循环:
- 整体的script(作为第一个宏任务)开始执行的时候,会把所有代码分为两部分:“同步任务”、“异步任务”;
- 同步任务会直接进入主线程依次执行;
- 异步任务会再分为宏任务和微任务;
- 宏任务进入到Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
- 微任务也会进入到另一个Event Table中,并在里面注册回调函数,每当指定的事件完成时,Event Table会将这个函数移到Event Queue中;
- 当主线程内的任务执行完毕,主线程为空时,会检查微任务的Event Queue,如果有任务,就全部执行,如果没有就执行下一个宏任务;
- 上述过程会不断重复,这就是Event Loop事件循环;
宏任务 & 微任务
宏任务
可以理解是每次执行栈执行的代码就是一个宏任务(包括每次从事件队列中获取一个事件回调并放到执行栈中执行)
浏览器为了能够使得JS内部(macro)task与DOM任务能够有序的执行,会在一个(macro)task执行结束后,在下一个(macro)task 执行开始前,对页面进行重新渲染,流程如下:
(macro)task->渲染->(macro)task->...
常见的宏任务包括有哪些?
script(整体代码)
setTimeout
setInterval
I/O
UI交互事件
postMessage
setImmediate(Node.js 环境)
微任务
microtask,可以理解是在当前 task 执行结束后立即执行的任务。也就是说,在当前task任务后,下一个task之前,在渲染之前。
所以它的响应速度相比setTimeout(setTimeout是task)会更快,因为无需等渲染。也就是说,在某一个macrotask执行完后,就会将在它执行期间产生的所有microtask都执行完毕(在渲染前)。
常见的微任务有哪些?
Promise.then
Object.observe
process.nextTick(Node.js 环境)
那么宏任务跟微任务之间的执行顺序又是什么样的呢?
当执行完宏任务以后,会首先判断还有没有微任务,如果有微任务则继续执行微任务,如果没有微任务就直接进行浏览器渲染。这样第一次的事件循环就执行完毕了,会继续下一次的事件循环,执行下一次宏任务。重复进行
灵魂拷问的面试题
梳理完上边的js事件循环机制以后,我们再撸几道面试题,加深一下印象。
第一题
console.log(1);
setTimeout(function(){
console.log(2)
}, 0);
console.log(3)
答案:1,3,2
解析:console.log()是同步任务,setTimeout是异步任务,异步任务会等同步任务执行完毕之后再执行。setTimeout虽然设置为了0,浏览器规定不低于4ms的延迟,console.log(2)在4ms后会被放入到任务队列中。当同步任务执行完成之后,打印1和3,主线程再从任务队列中取出任务,打印为2。
第二题
console.log('A')
while(true){}
console.log('B')
答案:A 解析:console.log是同步任务,会先执行打印出A,当执行到while时进入死循环,所以不再会往后执行。
console.log('A');
setTimeout(function(){
console.log('B')
}, 0);
while(1){}
答案:A 解析:console.log是同步任务,会先执行打印出A,当执setTimeout时,为异步宏任务,会被放置入任务队列中,继续执行while,进入死循环,所以不会打印出B
第三题
for(var i=0; i<4; i++){
setTimeout(function(){
console.log(i)
}, 0)
}
执行的顺序相当于
for(var i=0; i<4; i++){}
setTimeout(function(){
console.log(i)
}, 0)
setTimeout(function(){
console.log(i)
}, 0)
setTimeout(function(){
console.log(i)
}, 0)
setTimeout(function(){
console.log(i)
}, 0)
答案:4,4,4,4
解析:for循环是同步任务,会先执行,此时i的值为4。4ms后console.log(i) 被依次放入任务队列,此时如果执行栈中没有同步任务了,就从任务队列中依次取出任务,所以打印出 4 个 4。
那么如何才能按照期望打印出 0, 1,2,3 呢?有三个方法:
//方法1:把 var 换成 let
for(let i=0; i<4; i++){
setTimeout(function(){
console.log(i)
}, 0)
}
//方法2:使用立即执行函数
for(let i=0; i<4; i++){
(function(i){
setTimeout(function(){
console.log(i)
}, 0)
})(i)
}
//方法3:加闭包
for(let i=0; i<4; i++){
var a = function(){
var j = i;
setTimeout(function(){
console.log(j)
}, 0)
}
a();
}
第四题
setTimeout(function(){
console.log(1)
});
new Promise(function(resolve){
console.log(2);
for(var i = 0; i < 10000; i++){
i == 9999 && resolve();
}
}).then(function(){
console.log(3)
});
console.log(4);
答案:2,4,3,1
解析:
- setTimeout是异步,且是宏函数,放到宏函数队列中;
- new Promise是同步任务,直接执行,打印2,并执行for循环;
- promise.then是微任务,放到微任务队列中;
- console.log(4)同步任务,直接执行,打印4;
- 此时主线程任务执行完毕,检查微任务队列中,有promise.then,执行微任务,打印3;
- 微任务执行完毕,第一次循环结束;从宏任务队列中取出第一个宏任务到主线程执行,打印1;
第五题
console.log(1);
setTimeout(function() {
console.log(2);
}, 0);
Promise.resolve().then(function() {
console.log(3);
}).then(function() {
console.log('4.我是新增的微任务');
});
console.log(5);
答案:1,5,3,'4.我是新增的微任务',2
分析:
- console.log(1)是同步任务,直接执行,打印1;
- setTimeout是异步,且是宏函数,放到宏函数队列中;
- Promise.resolve().then是微任务,放到微任务队列中;
- console.log(5)是同步任务,直接执行,打印5;
- 此时主线程任务执行完毕,检查微任务队列中,有Promise.resolve().then,执行微任务,打印3;
- 此时发现第二个.then任务,属于微任务,添加到微任务队列,并执行,打印4.我是新增的微任务;
- 微任务执行过程中,发现新的微任务,会把这个新的微任务添加到队列中,微任务队列依次执行完毕后,才会执行下一个循环;
- 微任务执行完毕,第一次循环结束;取出宏任务队列中的第一个宏任务setTimeout到主线程执行,打印2;
第六题
function add(x, y) {
console.log(1)
setTimeout(function() { // timer1
console.log(2)
}, 1000)
}
add();
setTimeout(function() { // timer2
console.log(3)
})
new Promise(function(resolve) {
console.log(4)
setTimeout(function() { // timer3
console.log(5)
}, 100)
for(var i = 0; i < 100; i++) {
i == 99 && resolve()
}
}).then(function() {
setTimeout(function() { // timer4
console.log(6)
}, 0)
console.log(7)
})
console.log(8)
答案:1,4,8,7,3,6,5,2
分析:
- add()是同步任务,直接执行,打印1;
- add()里面的setTimeout是异步任务且宏函数,记做timer1放到宏函数队列;
- add()下面的setTimeout是异步任务且宏函数,记做timer2放到宏函数队列;
- new Promise是同步任务,直接执行,打印4;
- Promise里面的setTimeout是异步任务且宏函数,记做timer3放到宏函数队列;
- Promise里面的for循环,同步任务,执行代码;
- Promise.then是微任务,放到微任务队列;
- console.log(8)是同步任务,直接执行,打印8;
- 此时主线程任务执行完毕,检查微任务队列中,有Promise.then,执行微任务,发现有setTimeout是异步任务且宏函数,记做timer4放到宏函数队列;
- 微任务队列中的console.log(7)是同步任务,直接执行,打印7;
- 微任务执行完毕,第一次循环结束;
- 检查宏任务Event Table,里面有timer1、timer2、timer3、timer4,四个定时器宏任务,按照定时器延迟时间得到可以执行的顺序,即Event Queue:timer2、timer4、timer3、timer1,取出排在第一个的timer2;
- 取出timer2执行,console.log(3)同步任务,直接执行,打印3;
- 没有微任务,第二次Event Loop结束;
- 取出timer4执行,console.log(6)同步任务,直接执行,打印6;
- 没有微任务,第三次Event Loop结束;
- 取出timer3执行,console.log(5)同步任务,直接执行,打印5;
- 没有微任务,第四次Event Loop结束;
- 取出timer1执行,console.log(2)同步任务,直接执行,打印2;
- 没有微任务,也没有宏任务,第五次Event Loop结束;
第七题
setTimeout(function() { // timer1
console.log(1);
setTimeout(function() { // timer3
console.log(2);
})
}, 0);
setTimeout(function() { // timer2
console.log(3);
}, 0);
答案:1,3,2
分析:
- 第一个setTimeout是异步任务且宏函数,记做timer1放到宏函数队列;
- 第三个setTimeout是异步任务且宏函数,记做timer2放到宏函数队列;
- 没有微任务,第一次Event Loop结束;
- 取出timer1,console.log(1)同步任务,直接执行,打印1;
- timer1里面的setTimeout是异步任务且宏函数,记做timer3放到宏函数队列;
- 没有微任务,第二次Event Loop结束;
- 取出timer2,console.log(3)同步任务,直接执行,打印3;
- 没有微任务,第三次Event Loop结束;
- 取出timer3,console.log(2)同步任务,直接执行,打印2;
- 没有微任务,也没有宏任务,第四次Event Loop结束;
Node 中的事件循环(Event Loop)
Node.js是一个事件驱动I/O服务端JavaScript环境,基于Google的V8引擎,V8引擎执行Javascript的速度非常快,性能非常好。将libuv作为跨平台抽象层,libuv是用c/c++写成的高性能事件驱动的程序库。nodejs的原理类似c/c++系统编程中的epoll
Node.js的运行机制
- V8引擎解析JavaScript脚本
- 解析后的代码,调用Node API
- libuv库负责Node API的执行。它将不同的任务分配给不同的线程,形成一个Event Loop(事件循环),以异步的方式将任务的执行结果返回给V8引擎
- V8引擎再将结果返回给用户
Node中的Event Loop也跟浏览器端的Event Loop之间有很大的差异化。
node.js的主进程和Event Loop是分开的,主进程执行完同步代码就不执行了,剩下的就交给事件循环去做了。
Node中的事件循环
node事件循环执行的顺序为:
执行整体代码(同步宏任务)--> 执行微任务(process.nextTick()、promise.then)--> 轮询阶段(poll)-->检查阶段(check)-->关闭事件回调阶段(close callback)-->定时器检测阶段(timer)-->I/O事件回调阶段(I/O callbacks)-->闲置阶段(idle, prepare)-->轮询阶段(按照该顺序反复运行)
特殊的process.nextTick
process.nextTick是Node.js中的一个非常特殊的微任务,它通常会单独开启一个任务队列,并且它的执行优先级要大于其它的微任务,如果process.nextTick跟Promise.then同时存在,会优先去执行前者。
Promise.resolve().then(function() {
console.log('promise1')
})
process.nextTick(() => {
console.log('nextTick')
})
// nextTick promise1
需要注意的setTimeout 和 setImmediate
setTimeout跟setImmediate在没有I/O处理的情况下执行的结果是不确定的,setTimeout(fn, 0) 具有几毫秒的不确定性. 无法保证进入timers阶段, 定时器能够立即执行处理程序。
var fs = require('fs')
fs.readFile(__filename, () => {
setTimeout(() => {
console.log('timeout')
}, 0)
setImmediate(() => {
console.log('immediate')
})
})
// immediate timeout
在上述代码中,setImmediate 永远先执行。因为两个代码写在 IO 回调中,IO 回调是在 poll 阶段执行,当回调执行完毕后队列为空,发现存在 setImmediate 回调,所以就直接跳转到 check 阶段去执行回调了。
浏览器端的Event Loop跟Node环境中Event Loop之间的差异化在哪
我们可以通过一道Node端运行的练习题来解析一下差异化:
console.log('start')
setTimeout(() => {
console.log('timer1')
Promise.resolve().then(function() {
console.log('promise1')
})
}, 0)
setTimeout(() => {
console.log('timer2')
Promise.resolve().then(function() {
console.log('promise2')
})
}, 0)
Promise.resolve().then(function() {
console.log('promise3')
})
console.log('end')
// start end promise3 timer1 timer2 promise1 promise2
- 从上到下,会首先执行执行栈中的同步任务打印出start跟end,并且将setTimeou这个宏任务依次放入到任务队列中,然后再执行微任务打印出promise3,这一点跟浏览器是一样的。
- 当执行到timers1阶段的时候,会先打印出timers1,然后将promise.then放入到微任务的队列中,同样执行到timers1阶段的时候,打印出timers2,这点跟浏览器端相差比较大,timers阶段有几个setTimeout/setInterval都会依次执行,并不像浏览器端,每执行一个宏任务后就去执行一个微任务。
结语
文章也参考了其他大佬的一些见解,如果对本文有什么疑问,欢迎评论区留言,一起交流探讨。
如果大家对微前端感兴趣,欢迎大家阅读我之前写的这篇从0到1去搭建一个适合自己公司的微前端架构。