一、前言
我们知道,JavaScript的运⾏顺序就是完全单线程的异步模型,也就是说,在同一个时间只能执行一个任务。这个任务通常是由事件循环机制控制的。当代码遇到异步操作时(如定时器、Ajax 请求等),它会将这个操作放入任务队列中,并继续执行下面的代码。当当前任务执行完毕后,事件循环会从任务队列中取出下一个任务并执行。也就是同步在前,异步在后。所有的异步任务都要等待当前的同步任务执⾏完毕之后才能执⾏。
那么为什么是这样的执行顺序呢?这个顺序地执⾏原理是什么样的呢?这⾥需要介绍⼀下浏览器中⼀个Tab⻚的实际线程组成。以Chrome浏览器为例,Chrome浏览器打开多个标签页,可以在 Chrome的任务管理器中看到有多个进程,分别是每一个Tab页面有一个独立的进程,以及一个主进程。如果再多打开一个Tab页,进程正常会多1个以上。注意,在这里浏览器应该也有自己的优化机制,有时候打开多个tab页后,可以在Chrome任务管理器中看到,有些进程被合并了,所以每一个Tab标签对应一个进程并不一定是绝对的。
二、JS的线程组成
线程是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。
虽然浏览器是单线程执⾏JavaScript代码的,但是浏览器实际是以多个线程协助操作来实现单线程异步模型的。JS的线程主要有:GUI渲染线程、JavaScript引擎线程、事件触发线程、定时器触发线程、HTTP请求线程。
1、GUI渲染线程
负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制等。当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。页面在第一次渲染的时候也会触发这个线程。
2、JavaScript引擎线程
也称为JS内核,负责处理Javascript脚本程序,例如V8引擎。JS引擎线程负责解析Javascript脚本,运行代码。JS引擎一直等待着任务队列中任务的到来,然后加以处理,浏览器无论什么时候都只有一个JS线程在运行JS程序,一个tab页中只有一个js线程在运行js程序。
注意:GUI渲染线程与JS引擎线程是互斥的,他们不能同时执行。当JS引擎执行时GUI线程会被挂起(相当于被冻结了),GUI更新会被保存在一个队列中等到JS引擎空闲时立即被执行。如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
3、事件触发线程
归属于浏览器而不是JS引擎,用来控制事件循环,可以理解,JS引擎自己都忙不过来,需要浏览器另开线程协助。
当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击、AJAX异步请求等),会将对应任务添加到事件线程中,当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理。
注意,由于JS的单线程关系,所以这些待处理队列中的事件都得排队等待JS引擎处理(当JS引擎空闲时才会去执行)。
4、定时器线程
定时器与计时器的线程,在满足条件之后会被添加到事件队列里边,等待js引擎空闲的时候去执行。浏览器定时计数器并不是由JavaScript引擎计数的,因为JavaScript引擎是单线程的, 如果处于阻塞线程状态就会影响记计时的准确,因此通过单独线程来计时并触发定时,计时完毕后,添加到事件队列中,等待JS引擎空闲后执行。
5、HTTP请求线程
在发送http请求的时候,这个线程会被执行。在有回调函数的时候,这个线程会把回调的事件放入到事件队列中去,等待js引擎执行。
按照真实的浏览器线程组成分析,会发现实际上运⾏JavaScript的线程其实并不是⼀个,但是为什么说JavaScript是单线程的呢?因为这些线程中实际参与代码执⾏的线程并不是所有线程,⽐如GUI渲染线程为什么单独存在,这个是防⽌在html⽹⻚渲染⼀半的时候突然执⾏了⼀段阻塞式的JS代码⽽导致⽹⻚卡在⼀半停住这种效果。在JavaScript代码运⾏的过程中实际执⾏程序时同时只存在⼀个活动线程,这⾥实现同步异步就是靠多线程切换的形式来进⾏实现的。
所以我们通常分析时,将上⾯的细分线程归纳为下列两条线程:
主线程:这个线程⽤来执⾏⻚⾯的渲染,JavaScript代码的运⾏,事件的触发等。
⼯作线程:这个线程是在幕后⼯作的,⽤来处理异步任务的执⾏来实现⾮阻塞的运⾏模式。
三、JS的运行模型
JavaScript 的运行机制是基于事件驱动模型的。当用户与页面交互时(如点击按钮、输入文本等),浏览器会生成相应的事件,并将其加入到事件队列中。JavaScript 通过监听这些事件来实现页面交互效果。
上图是JavaScript运⾏时的⼀个⼯作流程和内存划分的简要描述,我们根据图中可以得知主线程就是JavaScript执⾏代码的线程,主线程代码在运⾏时,会按照同步和异步代码将其分成两个去处,如果是同步代码执⾏,就会直接将该任务放在⼀个叫做"函数执⾏栈"的空间进⾏执⾏,执⾏栈是典型的栈结构,即先进后出,程序在运⾏的时候会将同步代码按顺序⼊栈,将异步代码放到⼯作线程中暂时挂起,⼯作线程中保存的是定时任务函数、JS的交互事件、JS的⽹络请求等耗时操作。当主线程将代码块筛选完毕后,进⼊执⾏栈的函数会按照从外到内的顺序依次运⾏,运⾏中涉及到的对象数据是在堆内存中进⾏保存和管理的。当执⾏栈内的任务全部执⾏完毕后,执⾏栈就会清空。执⾏栈清空后,"事件循环"就会⼯作,"事件循环"会检测任务队列中是否有要执⾏的任务,那么这个任务队列的任务来源就是⼯作线程,程序运⾏期间,⼯作线程会把到期的定时任务、返回数据的http任务等异步任务按照先后顺序插⼊到任务队列中,等执⾏栈清空后,事件循环会访问任务队列,将任务队列中存在的任务,按先进先出的顺序放在执⾏栈中继续执⾏,直到任务队列清空。
看上面的一段文字解释比较抽象,不容易理解。接下来将根据一段代码并结合图来解读js的实际运⾏思路。
js
function func1() {
console.log('函数1')
}
function func2() {
console.log('函数2')
}
function func3() {
console.log('函数3')
}
function func4() {
console.log('函数4')
}
func1()
setTimeout(func2, 1000)
setTimeout(func3, 500)
func4()
上面代码创建了四个函数代表4个任务,函数本身都是同步代码。在执⾏的时候会按照1,2,3,4进⾏解析,解析过程中我们发现func2和func3被setTimeout进⾏了定时托管,这样就只能先运⾏func1和func4了。当func1和func4运⾏完毕之后500ms后运⾏func3,1000ms后运⾏func2。
如上图,在上述代码刚开始运⾏的时候我们的主线程即将⼯作,按照顺序从上到下进⾏解释执⾏,此时执⾏栈、⼯作线程、任务队列都是空的,事件循环也没有⼯作。
如上图,可以看出程序在主线程执⾏之后就将func1、func4和func2、func3分别放进了两个⽅向,func1和func4都是⽴即执⾏任务所以会按照1->4的顺序进栈出栈(这⾥由于func1和2是平⾏任务所以会先执⾏func1的进出栈再执⾏func4的进出栈),⽽func2和func3由于是异步任务就会进⼊⼯作线程挂起并开始计时,并不影响主线程运⾏,此时的任务队列还是空置的。
如上图,可以看出同步任务的执⾏消耗时间非常短,当执⾏栈空置时,func2和func3还没有到时间,这样事件循环就会开始⼯作等待任务队列中的任务进⼊,接下来就是执⾏异步任务的时候了。
如上图,发现任务队列并不是⼀下⼦就会将func2和func3⼀起放进去,⽽是哪个计时器到时间了哪个放进去,这样我们的事件循环就会发现队列中的任务,并且将任务拿到执⾏栈中进⾏消费,此时会输出func3的内容。
如上图,到这就是最后⼀次执⾏,当执⾏完毕后⼯作线程中没有计时任务,任务队列的任务清空程序到此执⾏完毕。
通过图解之后就会更清晰的知道异步任务的执⾏⽅式了,这⾥采⽤最简单的任务模型描绘复杂的任务在内存中的分配和⾛向,实际中是⾮常复杂的。
四、执行栈
执⾏栈是⼀个栈的数据结构,是一种后进先出(LIFO)的数据结构,当我们运⾏单层函数时,执⾏栈执⾏的函数进栈后,会出栈销毁然后下⼀个进栈下⼀个出栈,当有函数嵌套调⽤的时候栈中就会堆积栈帧。
当Javascript引擎开始执行第一行Javascript脚本代码的时候,它就会创建一个全局执行上下文然后将它压到执行栈中。每当引擎碰到一个函数的时候,它就会创建一个函数执行上下文,然后将这个执行上下文压到执行栈中。这种结构类似弹夹,执行栈就是弹夹,执行上下文是子弹,子弹被一个个压入弹夹,当子弹发射的时候,最后一个进弹夹的子弹会被最先射出。
引擎会执行位于执行栈栈顶的执行上下文(一般是函数执行上下文),当该函数执行结束后,对应的执行上下文就会被弹出,然后控制流程到达执行栈的下一个执行上下文。
以下面一段简单代码为例:
js
function func1() {
console.log('函数1')
func2()
console.log('函数2执行完毕')
}
function func2() {
console.log('函数2执行')
func3()
console.log('函数2执行完毕')
}
function func3() {
console.log('函数3执行')
}
func1()
console.log('函数1执行完毕')
上面代码执行过程如下:
第⼀次执⾏的时候调⽤func1函数执⾏到console.log的时候先进⾏输出,接下来会遇到func2函数的调⽤会出现下⾯的情况:
执⾏到此时检测到func2中还有调⽤func3的函数,那么就会继续进⼊func3中执⾏,如下图:
在执⾏完func3中的输出之后func3内部没有其他代码,那么func3函数执⾏完毕后就会发⽣出栈⼯作。
此时会发现func3出栈之后程序运⾏⼜会回到func2的函数中继续它的执⾏。
接下来之后就剩下func1⾃⼰了,他在func2销毁之后输出"func2执⾏完毕"后它也会出栈并销毁。
当func1执⾏完毕之后它随着销毁最后⼀⾏输出,就会进⼊执⾏栈执⾏并销毁,销毁之后执⾏栈和主线程清空。这个过程就会出现上面代码输出的这个顺序,⽽且我们在打印输出时,也能通过打印的顺序来理解⼊栈和出栈的顺序和流程。
()五、递归
递归函数是项⽬开发时经常涉及到的场景,我们经常会在未知深度的树形结构,或其他合适的场景中使⽤递归。那么递归在⾯试中也会经常被问到⻛险问题,如果了解了执⾏栈的执⾏逻辑后,递归函数就可以看成是在⼀个函数中嵌套n层执⾏,那么在执⾏过程中会触发⼤量的栈帧堆积,如果处理的数据过⼤,会导致执⾏栈的⾼度不够放置新的栈帧,⽽造成栈溢出的错误。
执⾏栈的深度根据不同的浏览器和JS引擎有着不同的区别,我们这⾥就Chrome浏览器为例⼦来尝试⼀下递归的溢出。
js
let i = 0;
function func() {
i++
console.log(`第${i}次递归`)
func()
}
func()
发现在递归了10436次(每个人的电脑不同浏览器不同数值都不同)之后会提示"Uncaught RangeError: Maximum call stack size exceeded"超过栈深度的错误,也就是⽆法在Chrome或者其他浏览器做太深层的递归操作。
那么可以通过怎么样的技术手段跨越递归的限制呢?可以将代码做如下更改,这样就不会出现递归问题了。
js
let i = 0;
function func() {
i++
console.log(`第${i}次递归`)
setTimeout(() => {
func()
}, 0)
}
func()
可以看到控制台的输出发现超过了10436的界限也没有报错。我们只是做了⼀个小小的改造,这样就不会出现栈溢出的错误了。这是为什么呢?这个是因为这⾥使⽤了异步任务去调⽤递归中的函数,那么这个函数在执⾏的时候就不只使⽤栈进⾏执⾏了。
看下图没有异步任务调用时是这样的:
再看有异步任务调用时是这样的:
有了异步任务之后递归就不会叠加栈帧了,因为放⼊⼯作线程之后该函数就结束了,可以出栈销毁,那么在执⾏栈中就永远都是只有⼀个任务在运⾏,这样就防⽌了栈帧的⽆限叠加,从⽽解决了⽆限递归的问题,不过异步递归的过程是⽆法保证运⾏速度的,在实际的⼯作场景中,如果考虑性能问题,还需要使⽤while循环等解决⽅案,来保证运⾏效率的问题,在实际⼯作场景中,尽量避免递归循环,因为递归循环就算控制在有限栈帧的叠加,其性能也远远不及指针循环。
六、后记
在知道了事件循环模型以及JavaScript的执⾏流程后,我们认识了⼀个叫做任务队列的容器,它的数据结构是队列的结构。所有除同步任务外的代码都会在⼯作线程中,按照它到达的时间节点有序的进⼊任务队列,⽽且任务队列中的异步任务⼜分为宏任务和微任务------Javascript事件循环模型之宏任务和微任务。