其实浏览器事件循环标准是由HTML标准规定的,具体是由whatwg规定的。详情请参考浏览器中的事件循环。NodeJS中的事件循环实际上略有不同。具体可以参考nodejs中的event-loops。我们在讲解事件模型的时候多次提到事件循环。事件是指它处理的对象是事件本身。每个浏览器至少有一个事件循环,一个事件循环至少有一个任务队列。循环意味着它永远处于“无限循环”中。不断将注册的回调函数压入执行栈。那么事件循环到底是做什么用的呢?浏览器的事件循环和NodeJS的事件循环有什么区别?让我们从零开始,一步步探究背后的原因。为什么会有事件循环JS引擎?为了回答这个问题,让我们看一个简单的例子:functionc(){}functionb(){c();}functiona(){b();}a();上面这段简单的JS代码是如何被浏览器执行的?首先,如果浏览器要执行一个JS脚本,它需要一个“东西”把JS脚本(本质上是纯文本)变成机器可以理解和执行的计算机指令。这个“东西”就是JS引擎。它实际上编译和执行JS脚本。整个过程非常复杂。这里就不过多介绍了。感兴趣的可以期待我的V8章节。如无特别说明,以下均以V8为例。有两个非常核心的组件,执行栈和堆。正在执行的代码存放在执行栈中,变量的值存放在堆中,通常是无规律的。当V8执行代码行a()时,a将被压入栈顶。在a中,我们点击了b(),此时b被压入栈顶。在b内部,我们又遇到了c(),此时c被压入栈顶。c执行完后,会从栈顶移除。函数返回b,b也被执行,b从栈顶移除。也将被删除。整个过程用动画表示是这样的:(在线观看)此时,我们还没有接触到堆内存和执行上下文栈,一切都比较简单。这些内容我们以后再说。DOM和WEBAPI现在我们有了一个可以执行JS的引擎,但是我们的目标是构建一个用户界面,而传统的前端用户界面是基于DOM构建的,所以我们需要引入DOM。DOM是文档对象模型,提供了一系列JS可以直接调用的接口。理论上,它可以提供其他语言的接口,而不仅仅是JS。而且除了DOM接口可以调用到JS之外,浏览器还提供了一些WEBAPI。无论是DOM还是WEBAPI,本质上都与JS无关,完全不同。JS对应的ECMA规范,V8是用来实现ECMA规范的,其他的不关心。这也是JS引擎和JS执行环境的区别。V8是JS引擎,用于执行JS代码。浏览器和Node是JS的执行环境,提供一些JS可以调用的API,即JS绑定。由于浏览器的存在,现在JS可以操作DOM和WEBAPI,似乎可以构建用户界面。有一点需要提前明确,V8只有栈和堆,对事件循环、DOM、WEBAPI等其他事件一无所知。原因前面已经讲过了,因为V8只负责JS代码的编译和执行。如果给V8一段JS代码,它会从头执行到尾,中间不会停。另外,这里继续提一下,JS执行栈和渲染线程是相互阻塞的。为什么?本质上是因为JS太灵活了,它可以获取DOM中的坐标等信息。如果两者同时执行,可能会发生冲突。比如我先获取了某个DOM节点的x坐标,下一刻坐标发生了变化。JS使用这个“旧”坐标进行计算,然后赋值给DOM,就发生了冲突。解决冲突的方式有两种:限制JS的能力,在特定的时间只能使用特定的API。这种做法极其复杂,也带来很多不便。JS和渲染线程不是同时执行的。一种方法是现在广泛使用的相互阻塞。事实上,这就是当今浏览器广泛使用的方式。单线程or多线程or异步前面说了,你给V8一段JS代码,它会从头执行到尾,中间不会停。为什么不停止,能不能设计成停止,就像C语言一样?假设我们需要获取用户信息,获取用户文章,获取用户好友。单线程不异步由于是单线程不异步,所以我们的三个接口需要采用同步的方式。fetchUserInfoSync().then(doSomethingA);//1sfetchMyArcticlesSync().then(doSomethingB);//3sfetchMyFriendsSync().then(doSomethingC);//2s由于以上三个请求是同步执行的,所以上面的代码会先执行fetchUserInfoSync,一秒后执行fetchMyArcticlesSync,三秒后执行fetchMyFriendsSync。最可怕的是我们刚才说的JS执行栈和渲染线程是互相阻塞的。所以用户在这期间根本无法操作,界面也无法响应,这显然是不能接受的。多线程不异步因为是多线程不异步,虽然我们的三个接口还是需要采用同步的方式,但是我们可以分别在多个线程中执行代码,比如我们在三个线程中执行这段代码。线程1:fetchUserInfoSync().then(doSomethingA);//1s线程2:fetchMyArcticlesSync().then(doSomethingB);//3s线程3:fetchMyFriendsSync().then(doSomethingC);//2s由于三件代码同时执行,所以总时间理想情况下取决于最慢的时间,也就是3s,这和使用异步方式是一样的(当然前提是请求之间没有依赖关系)。为什么说它是最好的呢?由于三个线程都可以访问DOM和堆内存,因此很可能会发生冲突。冲突的原因和我上面说的JS线程和渲染线程冲突的原因没有本质区别。所以理想情况是没有冲突的话是3s,但是如果有冲突,我们就需要用锁来解决,所以时间可能会高于3s。相应的,编程模型也会更加复杂,和锁打过交道的程序员应该都有同感。单线程+异步如果还是用单线程,是不是改成异步比较好?问题的关键是如何实现异步?这就是我们要说的——事件循环。事件循环是如何实现异步的?我们知道浏览器中只有一个JS线程,如果没有事件循环,就会出现问题。即如果JS发起异步IO请求,在等待返回结果的这段时间内,后面的代码会被阻塞。我们知道JS主线程和渲染进程是相互阻塞的,所以这会导致浏览器卡顿。如何解决这个问题呢?一种有效的方法是我们将在本节中讨论的事件循环。其实事件循环就是用来做调度的。浏览器和NodeJS中的事件循环就像操作系统的调度器一样。操作系统的调度程序决定何时将什么资源分配给谁。对于线程模型的计算机,操作系统执行代码的最小单位是线程,分配资源的最小单位是进程。代码执行的过程是由操作系统调度的,整个调度过程非常复杂。我们知道现在很多电脑都有多核。为了让多个核心同时发挥作用,没有一个核心特别闲,没有一个核心特别累。操作系统的调度器会执行某种神秘的算法,保证每个核都能被分配到任务。这也是为什么我们在使用NodeJS作为集群时,worker节点的数量通常设置为core的数量。调度程序将尝试将每个工作人员平均分配给每个核心。当然,这个过程不是确定性的,也就是不一定按预定的方式分配设备,但是很多次都会这样。了解了操作系统调度器的原理之后,我们不妨继续回顾一下事件循环。事件循环本质上是为了调度,只是调度的对象变成了JS的执行。事件循环决定V8什么时候执行什么代码。V8只负责解析和执行JS代码,其他的一概不知。事件在浏览器或NodeJS中被触发后,直到事件监听函数被V8执行完这段时间的所有工作就是事件循环。总结一下:对于V8来说,它有:调用栈(callstack)这里的单线程是指只有一个调用栈。只有一个调用堆栈意味着一次只能执行一段代码。Heap(堆)对于浏览器运行环境:WEBAPIDOMAPI任务队列事件触发事件循环流动以下面的代码为例:functionc(){}functionb(){c();}functiona(){setTimeout(b,2000)}a();执行过程如下:(在线观看)因此,事件循环之所以可以异步,是因为浏览器在遇到异步执行的代码“如fetch、setTimeout”时,会将用户注册保存回调函数,然后继续执行以下代码。当“异步任务”在未来某个时刻完成时,会触发一个事件,浏览器会将“任务详情”作为参数传递给之前用户绑定的回调函数。具体来说就是将用户绑定的回调函数压入浏览器的执行栈。但这并不意味着它是随便推进去的。只有在浏览器执行完必须“一口气”执行的JS脚本时,才需要“喘口气”的时候检查是否有需要处理的“消息”。如果是,则将消息绑定对应的回调函数压入栈中。当然,如果没有绑定事件,事件消息实际上会被丢弃,不做处理。比如用户触发了点击事件,但是用户没有绑定点击事件的监听函数,那么这个事件实际上会被丢弃。让我们看看添加用户交互后的样子。以点击事件为例:$.on('button','click',functiononClick(){setTimeout(functiontimer(){console.log('Youclickedthebutton!');},2000);});console.日志(“嗨!”);setTimeout(functiontimeout(){console.log(“点击按钮!”);},5000);console.log(“Welcometoloupe。”);上面的代码会在每次点击按钮时发送一个事件,因为我们绑定了一个监听函数。因此,每一次点击都会产生一次点击事件消息,浏览器会相应地将用户绑定的事件处理函数压入栈中,在“空闲时间”执行。伪代码:while(true){if(queue.length>0){queue.processNextMessage()}}动画演示:(在线观看)添加宏任务&微任务我们再看一个复制的例子感受一下。console.log(1)setTimeout(()=>{console.log(2)},0)Promise.resolve().then(()=>{returnconsole.log(3)}).then(()=>{console.log(4)})console.log(5)上面的代码会输出:1,5,3,4,2。如果想要很严谨的解释可以参考whatwg对它的描述-event-循环处理模型。下面我将对其进行简单的解释。浏览器先执行宏任务,即我们的脚本(只执行一次)完成后检查是否有微任务,然后不断执行,直到队列清空才执行宏任务其中:宏任务主要是包括:setTimeout、setInterval、setImmediate、I/O。UI交互事件微任务主要包括:Promise、process.nextTick、MutaionObserver等。有了这些知识,我们不难得到上面代码的输出结果。由此可见,macrotaskµtask只是为了实现异步过程,我们对信号的处理顺序不同。如果不区分,都放在一个队列中,就没有宏任务&微任务了。这种人为的优先级排序过程有时非常有用。添加执行上下文栈说到执行上下文,不得不提的是,浏览器对JS函数的执行实际上分为两个过程。一个是创建阶段,另一个是执行阶段。和执行栈一样,浏览器每遇到一个函数,也会把当前函数的执行上下文栈推到栈顶。例如:functiona(num){functionb(num){functionc(num){constn=3console.log(num+n)}c(num);}b(num);}a(1);遇到上面的代码。首先,a会被压入执行栈,然后我们开始创建阶段,将a的执行上下文压入栈。然后初始化a的执行上下文,即VO、ScopeChain(VO链)和This。从这里我们也可以看出,这其实是动态决定的。VO是指变量、函数和参数。而执行上下文栈也会随着执行栈的销毁同步销毁。伪代码表示:constEC={'scopeChain':{},'variableObject':{},'this':{}}下面重点介绍ScopeChain(VO链)。上图的执行上下文大概是这样的,伪代码:global.VO={a:pointertoa(),scopeChain:[global.VO]}a.VO={b:pointertob(),arguments:{0:1},scopeChain:[a.VO,global.VO]}b.VO={c:pointertoc(),arguments:{0:1},scopeChain:[b.VO,a.VO,global.VO]}C。VO={arguments:{0:1},n:3scopeChain:[c.VO,b.VO,a.VO,global.VO]}引擎搜索变量时,会先从VOC开始搜索。继续去VOB……直到GlobalVO。如果找不到GlobalVO,它将返回ReferenceError。整个过程类似于原型链的查找。值得一提的是,JS是词法作用域,即静态作用域。换句话说,范围取决于代码定义的位置,而不是执行的位置,这是关闭的本质原因。如果将上面的代码转化为:functionc(){}functionb(){}functiona(){}a()b()c()或者这样:functionc(){}functionb(){c();}functiona(){b();}a();虽然执行上下文栈是一样的,但是对应的scopeChain是完全不同的,因为函数定义的位置变了。以上面的代码片段为例,c.VO会变成这样:c.VO={scopeChain:[c.VO,global.VO]}也就是说,它已经取不到a和b中的VO了.总结通过本文,希望大家对单线程、多线程、异步、事件循环、事件驱动等知识点有更深的理解和感悟。除了这些大的方面,我们还从执行栈和执行上下文栈的角度解释了我们的代码是如何被浏览器运行的。我们还解释了作用域和闭包产生的本质原因。最后总结了一张浏览器运行代码的整体示意图,希望对大家有所帮助:下一节浏览器的事件循环和NodeJS的事件循环有什么区别,敬请期待~
