发布网友 发布时间:2024-09-06 00:43
共1个回答
热心网友 时间:2024-09-28 19:30
我们都知道JavaScript是一门单线程、非阻塞、异步、解释性的脚本语言。单线程就意味着所有代码按顺序从前往后一步一步执行,但实际JavaScript编程中并非如此。此外,非阻塞和异步也意味着一个任务不需要等待前一个任务执行完毕再执行,这看起来与单线程似乎是矛盾的。那JavaScript为什么会如此"怪异"呢,其实这都与事件循环(EventLoop)机制相关,本文就从事件循环的角度来探寻一下JavaScript是如何执行的。
在阅读本文之前,建议您先观看视频Whattheheckistheeventloop和InTheLoop,会让您对事件循环有个直观的认识,本文也是基于这些视频来展开的。
一、前言在正式介绍事件循环之前,我们先来了解一些相关的基础知识。
1.1为什么JavaScript是单线程的单线程是JavaScript的一大特点,这也意味着同一时间只能做一件事。但是,为什么不像Java等语言一样,拥有多个线程提高执行效率呢?JavaScript之所以是单线程的,这与其用途有关。JavaScript的主要用户是与用户互动,以及操作DOM。如果设计成多线程则会带来很多问题。例如,假设有两个线程,一个线程在DOM节点上添加内容,而另一个线程删除该DOM节点,这样导致操作冲突,浏览器无法确定以哪个操作为准。所以,为了避免复杂性,JavaScript就是一门单线程语言。
1.2浏览器架构关于浏览器是由哪些进程组成的,每个进程中又包含哪些线程,以及它们的作用,大致如下图所示。我的另一篇文章“浏览器的页面渲染流程”中对此有较为详细的介绍。
1.3JavaScript的浏览器运行环境(Runtime)1.3.1JavaScript引擎JavaScript引擎是一个执行JavaScript代码的程序或解释器,主要由两个组件组成的:
内存堆(memoryheap):内存分配发生的地方,所有的对象变量都以随机方式分配在这里。
调用堆栈(callstack):代码执行时堆栈帧所在的位置(函数调用的地方)。
(1)调用堆栈(callstack)调用堆栈是一种后进先出(LIFO)的数据结构,它记录了当前控制流在程序中的位置。调用堆栈中的每个条目称为堆栈帧。
JavaScript是一种单线程编程语言,这就意味着只有一个调用堆栈,因此一次只能做一件事。当控制流进入一个函数,那么就把该函数放在栈顶。若一个函数返回结束,那么就从栈顶弹出该函数。
如下代码:
当JavaScript引擎开始执行代码时,调用堆栈为空。
之后调用printSquare,第一个帧压入堆栈,帧中包含了printSquare的参数和局部变量。
紧接着调用multiply,第二个帧压入堆栈,multiply执行完毕返回时,第二个帧出栈,
紧接着调用console.log,第三个帧压入堆栈,console.log执行完毕时,第三个帧出栈
最后printSquare执行完毕时,第一个帧出栈,调用堆栈就被清空了。
functionmultiply(x,y){returnx*y;}functionprintSquare(x){vars=multiply(x,x);console.log(s);}printSquare(5);具体可见步骤如下图:
异常堆栈追踪?抛出异常时构建堆栈追踪的方式就是基于调用堆栈的:
Blowingthestack当达到最大调用堆栈大小时会发生这种情况。例如无限递归:
functionfoo(){foo();}foo();(2)内存堆(memoryheap)对象被分配在堆中,堆是一个用来表示一大块(通常是非结构化的)内存区域的计算机术语。
1.3.2WebAPI由浏览器提供的API(例如DOM、AJAX、setTimeout等等),让我们能够同时处理多项任务。当完成WebAPI的内部函数后,便将任务传送至任务队列。
在Chrome中,这些API的实现并不存在于V8引擎源码中,所以在JavaScript中只能调用这些API,而且它们的执行也不是在JS引擎上的。例如:调用setTimeout()的计时操作是在定时触发器线程上执行的。(其实这也说明了JavaScript为什么可以进行异步编程)
1.3.3回调队列(CallbackQueue)回调队列,这是一个先进先出(FIFO)的工作队列,会接收来自WebAPI的任务,并通过事件循环(EventLoop)监控,当调用堆栈中没有执行项目时,便把队列中的任务加入到调用堆栈中执行。
综上所述,JavaScript的Runtime大致如下图:
二、事件循环(eventloop)2.1事件循环的定义严格来说,事件循环并不是JavaScript本身的机制,而是JavaScript运行环境(runtime)里面的机制。(即:浏览器或Node.js)所以,在EMAScript中并没有对事件循环的定义,而在HTMLStandard中对事件循环是这样定义的:
Tocoordinateevents,userinteraction,scripts,rendering,networking,andsoforth,useragentsmustuse?eventloops?asdescribedinthissection.Each?agenthasanassociated?eventloop,whichisuniquetothatagent.
即事件循环是为了协调事件、用户交互、脚本、渲染、网络等
2.2事件循环是什么?我们知道JavaScript的并不是独立运行的,它的运行依赖于一个宿主环境,例如常见的Web浏览器、Node.js。但实际上,技术发展到现在,JavaScript也可以嵌入到机器人等设备中运行,这些设备就是JavaScript的宿主环境。这些宿主环境的共同点在于,都有一个称为事件循环的内置机制,会调用JS引擎执行处理程序多个块的执行。这就意味着,JS引擎知识JS代码的执行环境,然而事件调度是周围环境进行的。那么,事件循环到底是什么呢?我们来看一篇博客中的解释:
TheEventLoophasonesimplejob—tomonitortheCallStackandtheCallbackQueue.IftheCallStackisempty,itwilltakethefirsteventfromthequeueandwillpushittotheCallStack,whicheffectivelyrunsit.——HowJavaScriptworks:EventloopandtheriseofAsyncprogramming
翻译过来就是:事件循环负责监控调用堆栈和回调队列。如果调用堆栈为空,事件循环将从队列中取出第一个事件并将其推送到调用堆栈,然后调用堆栈会执行它。
或许这样说还是有点模糊,我们来看HowJavaScriptworks:EventloopandtheriseofAsyncprogramming中的一段代码:
console.log('Hi');setTimeout(functioncb1(){console.log('cb1');},5000);console.log('Bye');//代码输出如下://Hi//Bye//cb1当我们执行这段代码时,浏览器内部是如何工作的呢:
1、初始的时候,调用栈、回调队列均为空,控制台也没有任何输出
2、console.log('Hi');添加到调用栈,然后执行,执行结束后出栈
3、setTimeout(functioncb1(){...})添加到调用堆栈
4、setTimeout(functioncb1(){...})被执行。为了不阻塞代码执行,浏览器会创建一个计时器作为WebAPI的一部分。它将用于处理倒计时。
5、setTimeout(functioncb1(){...})自身完成并从调用堆栈中移除。
6、console.log('Bye')添加到调用堆栈,然后执行,执行结束后出栈。
7、至少5000毫秒后,计时器完成并将cb1回调推送到回调队列。
8、事件循环cb1从回调队列中取出并将其推送到调用堆栈
9、cb1执行并添加console.log('cb1')到调用堆栈。
10、console.log('cb1')执行,执行结束后出栈。
11、cb执行结束,出栈总的来说,当JS引擎执行一段代码时,会创建一个堆(Heap)和一个栈(Stack),会在栈中按顺序执行JS代码,当栈中的代码调用了WebAPI时,会通知浏览器开启另外的线程进行相应的操作,操作有了结果后,会向任务队列(TaskQueue)中添加相应事件。当栈中的代码执行完毕,即栈空时,事件循环将从队列中取出第一个事件并将其推送到栈,然后执行事件对应的回调函数。那么哪些情况下会向事件队列中添加一个事件呢,例如:
定时器倒计时结束
异步HTTP请求有了结果,无论请求成功还是失败
用户对DOM的一些操作,例如点击、滚动等此外,个人觉得事件队列、任务队列、消息队列、回调队列这四个术语应该指的都是同一个东西,向事件队列添加一个事件,其实就是把事件对应的回调函数添加到队列中,在栈中执行异步任务,其实执行的也就是这个异步任务对应的回调函数。
setTimeout(…)如何工作setTimeout()并不会自动将对应的回调函数添加到事件队列中。它设置了一个定时器,该定时器的倒计时是在渲染进程的定时触发线程中执行的,当定时器到期时,JS引擎所在的运行环境会将回调函数添加到事件队列中,当事件循环监控到调用堆栈未空时,就会把该回调函数推送到调用堆栈中执行。
setTimeout(myCallback,1000);如上代码,我们需要注意的是,myCallback并不会在1000ms后立即执行,而是在1000ms后加入到事件队列中。可能在它之前,队列中还有其他事件,所以必须等待这些事件对应的回调函数执行完毕,myCallback才能执行。所以,myCallback有可能在1000ms后不会执行,这也是为什么setTimeout()会存在偏差的原因。
零延时setTimeout(callback,0):可能大家经常会遇见将setTimeout()第二个参数设置为0的情况,其实这就是将回调函数延迟到调用堆栈清空之后再执行。
console.log('Hi');setTimeout(functioncb1(){console.log('cb1');},0);console.log('Bye');例如上述代码,输出顺序为:
HiByecb1小结有关浏览器的事件循环,上述介绍的可能不是很清晰,不过我看到最好的解释是:
事件循环负责监控调用堆栈和回调队列。如果调用堆栈为空,事件循环将从队列中取出第一个事件并将其推送到调用堆栈,然后调用堆栈会执行事件对应的回调函数。
三、作业队列(jobqueue)3.1定义由于ES6中的引入的新特性Promise,Promise的处理程序(handlers).then、.catch?和?.finally?都是异步的。为了对异步任务做适当的管理,对应也引入了作业(job)和作业队列(jobqueue)的概念。ES6中的定义如下:
作业(job)
A?Job?isan?AbstractClosure?withnoparametersthatinitiatesanECMAScriptcomputationwhennootherECMAScriptcomputationiscurrentlyinprogress.
即:作业是一个抽象闭包,在没有其他ECMAScriptcomputation时启动ECMAScriptcomputation。
作业队列(jobqueue)
AJobQueueisaFIFOqueueofPendingJobrecords.EachJobQueuehasanameandthefullsetofavailableJobQueuesaredefinedbyanECMAScriptimplementation.
即:作业队列是一个由PendingJob记录组成的FIFO队列
3.2作业时如何执行的我们再来看ES6规范中的一段描述:
ExecutionofaJobcanbeinitiatedonlywhenthereisnorunning?executioncontext?and?theexecutioncontextstack?isempty.APendingJobisarequestforthefutureexecutionofaJob.APendingJobisaninternalRecord.OnceexecutionofaJobisinitiated,theJobalwaysexecutestocompletion.NootherJobmaybeinitiatentilthecurrentlyrunningJobcompletes.However,thecurrentlyrunningJoborexternaleventsmaycausetheenqueuingofadditionalPendingJobsthatmaybeinitiatedsometimeaftercompletionofthecurrentlyrunningJob.
大体意思就是:
只有当没有正在运行的执行上下文且执行上下文栈为空时,才能启动作业的执行
PendingJob是对一个作业的未来执行的请求。PendingJob是一个内部记录。
一旦开始执行一项作业,该作业总是执行到完成。在当前运行的作业完成之前,不可能启动其他作业。
然而,当前正在运行的作业或外部事件可能会导致排队等候额外的PendingJob,这些作业可能会在当前运行的作业完成后的某个时候被启动。
每个ECMAScript实现都至少有以下事件队列(JobQueues):Name????|Purpose?????????????????????????????????????????????????????????????||-----------|---------------------------------------------------------------------------------------------------------------------------------||ScriptJobs?|JobsthatvalidateandevaluateECMAScript?Script?and?Mole?sourcetext.????????????????||PromiseJobs|JobsthatareresponsestothesettlementofaPromise.
对于Promise来说,其对应的处理程序就是一个作业,会被放到作业队列中。即.then、.catch?和?.finally对应的回调函数。
我们来看下面一段代码:
console.log('scriptstart');constpromise1=newPromise((resolve,reject)=>{console.log('promise1');resolve('success');})promise1.then(()=>{console.log('promise1.then');}).then(()=>{console.log('promise1.then.then');})constpromise2=newPromise((resolve,reject)=>{console.log('promise2');reject('fail');})promise2.catch(()=>{console.log('promise2.catch')}).finally(()=>{console.log('promise2.finally')})console.log('scriptend');//scriptstart//promise1//promise2//scriptend//promise1.then//promise2.catch//promise1.then.then//promise2.finally为什么输出结果会这样呢,我们来理以下步骤:
开始执行脚本
console.log('scriptstart');进入调用堆栈,并执行,输出“scriptstart”,执行完毕后出栈
newPromise()进入调用堆栈,并执行,输出promise1,执行完毕出栈
promise1.then进入作业队列
newPromise()进入调用堆栈,并执行,输出promise2,执行完毕出栈
promise2.then进入作业队列
console.log('scriptend');进入调用堆栈,并执行,输出“scriptend”,执行完毕后出栈
脚本执行完毕,开始执行作业队列中的作业
执行promise1.then,输出“promise1.then”,执行过程中又产生了另一个微任务promise1.then.then,加入到作业队列中
紧接着执行promise2.catch,输出“promise2.catch”,执行过程中又产生了另一个微任务promise2.finally,加入到作业队列中
执行promise1.then.then,输出“promise1.then.then”,
执行promise2.finally,输出“promise2.finally”
3.3宏任务(Task)和微任务(Microtask)(1)宏任务(Task)宏任务(Task)就是我们平时所说的任务,就是指计划由标准机制来执行的任何JavaScript,如程序的初始化、事件触发的回调等。?除了使用事件,你还可以使用?setTimeout()?或者?setInterval()来添加任务。
(2)微任务(Microtask)微任务(Microtask)其实就是上述ECMAScript规范中定义的作业(job),微任务仅来自于我们的代码。它们通常是由promise创建的:对?.then/catch/finally?处理程序的执行会成为微任务。微任务也被用于?await?的“幕后”,因为它是promise处理的另一种形式。
(3)宏任务队列和微任务队列的区别任务队列和微任务队列的区别很简单,但却很重要:
当执行来自任务队列中的任务时,在每一次新的事件循环开始迭代的时候运行时都会执行队列中的每个任务。在每次迭代开始之后加入到队列中的任务需要在下一次迭代开始之后才会被执行.
每次当一个任务退出且执行上下文为空的时候,微任务队列中的每一个微任务会依次被执行。不同的是它会等到微任务队列为空才会停止执行——即使中途有微任务加入。换句话说,微任务可以添加新的微任务到队列中,并在下一个任务开始执行之前且当前事件循环结束之前执行完所有的微任务。
注意:执行JavaScript脚本程序也算是一个任务,微任务要等JavaScript脚本程序执行完毕后才能开始执行。
console.log('scriptstart');Promise.resolve().then(()=>{console.log(1);}).then(()=>{console.log(2);})setTimeout(()=>{Promise.resolve().then(()=>{console.log(3);}).then(()=>{console.log(4);})},1000)setTimeout(()=>{console.log(5);},0)console.log('scriptend');//scriptstart//scriptend//1//2//5//3haol//4最后来看InTheLoop中给出的一个有趣的例子,JS代码如下:
letbtn=document.querySelector('.cycle');btn.addEventListener('click',()=>{Promise.resolve().then(()=>{console.log('Microtask1')});console.log('Listener1');})btn.addEventListener('click',()=>{Promise.resolve().then(()=>{console.log('Microtask2')});console.log('Listener2');})btn.click();当我们运行这段代码时,根据微任务的运行机制,我们可以很快知道输出的是:Listener1->Listener2->Microtask1->Microtask2但是,如果我们通过鼠标去点击该按钮时,输出的顺序又不同了:Listener1->Microtask1->Listener2->Microtask2那么为什么会这样呢,这是因为当我们在外部点击按钮时,console.log('Listener1');执行结束后调用堆栈就清空了,此时微任务就可以开始执行了,但是如果是调用btn.click();,console.log('Listener1');执行结束后,btn.click();还没有执行结束,调用堆栈还没有清空,还轮不到微任务执行。
四、window.requestAnimationFramewindow.requestAnimationFrame()?告诉浏览器——你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行
这里简单记录一下,三种队列执行的区别。
任务队列中一次只执行一项
AnimationCallback队列中,会把依次所有的项都执行完
微任务队列也会依次把所有的项执行完,但如果某个微任务执行过程中产生新的微任务,那么把新的微任务添加到队尾,继续执行,直到所有微任务执行完毕。我们再来看看下面四段代码:
while(true)
while(true);setTiemout循环
functionfoo(){foo();}foo();0P