Node.js指南,第6部分:事件循环,调用堆栈,计时器

今天,在Node.js手册翻译的第六部分中,我们将讨论事件循环,调用堆栈, process.nextTick()函数和计时器。 了解这些以及其他Node.js机制是该平台成功开发应用程序的基石之一。




事件循环


如果您想了解JavaScript代码的执行方式,则事件循环是您需要了解的最重要的概念之一。 在这里,我们将讨论JavaScript如何在单线程模式下工作以及如何处理异步函数。

我从事JavaScript的开发已经很多年了,但是我不能说我完全理解一切的工作原理,可以这么说。 程序员可能不知道他工作的环境的内部子系统的设备的复杂性。 但是,通常至少对此类事情有一个大致的了解是有用的。

您编写的JavaScript代码以单线程模式运行。 在某个时间点,仅执行一个动作。 实际上,此限制非常有用。 这极大地简化了程序的工作方式,从而使程序员无需解决特定于多线程环境的问题。

实际上,JS程序员只需要注意代码执行的确切动作,并尽量避免导致主线程阻塞的情况。 例如,以同步模式和无穷循环进行网络呼叫。

通常,浏览器在每个打开的选项卡中都有自己的事件循环。 这样,您就可以在隔离的环境中执行每个页面的代码,并避免某些页面(其中的代码存在无限循环或执行繁重的计算)能够“暂停”整个浏览器,从而避免出现这种情况。 浏览器支持许多同时存在的事件循环的工作,这些事件循环用于例如处理对各种API的调用。 此外,专有事件循环用于支持Web Worker

JavaScript程序员必须经常记住的最重要的事情是,他的代码使用了自己的事件循环,因此必须编写代码以防止该事件循环被阻塞。

事件循环锁定


任何花费太多时间执行的JavaScript代码(即,长时间不控制事件循环的代码)都会阻止其他任何页面代码的执行。 这甚至导致阻止用户界面事件的处理,这反映在以下事实中:用户无法与页面元素进行交互并且无法正常使用页面元素,例如滚动。

几乎所有基本的JavaScript I / O机制都是非阻塞的。 这适用于浏览器和Node.js。 例如,在这些机制中,我们可以提及在客户端和服务器环境中使用的用于执行网络请求的工具,以及用于处理Node.js文件的工具。 有执行此类操作的同步方法,但仅在特殊情况下使用。 这就是为什么传统的回调和更新的机制(promise和async / await构造)在JavaScript中非常重要的原因。

调用堆栈


JavaScript调用堆栈基于LIFO原理(后进先出-后进先出)。 事件循环不断检查调用堆栈,以查看其是否具有需要执行的功能。 如果在执行代码时调用了一个函数,则有关该函数的信息将添加到调用堆栈中,并执行该函数。

如果甚至在您对“调用堆栈”的概念不感兴趣之前,那么如果您遇到了包含堆栈跟踪的错误消息,您就已经可以想象它的外观了。 例如,此处在浏览器中看起来像这样。


浏览器错误信息

当发生错误时,浏览器会报告对函数的调用顺序,有关调用函数的信息存储在调用堆栈中,这使您可以找到错误的根源并了解哪些调用导致了这种情况。

现在,我们已经笼统地讨论了事件循环和调用堆栈,现在考虑一个示例,该示例说明代码片段的执行以及从事件循环和调用堆栈的角度看该过程的外观。

事件循环和调用堆栈


这是我们将尝试的代码:

 const bar = () => console.log('bar') const baz = () => console.log('baz') const foo = () => { console.log('foo') bar() baz() } foo() 

如果执行此代码,则以下内容将进入控制台:

 foo bar baz 

这样的结果是完全可以预期的。 即,运行此代码时,首先调用foo()函数。 在此函数内部,我们首先调用bar()函数,然后调用baz()函数。 同时,执行此代码期间的调用堆栈将进行下图所示的更改。


执行调查代码时更改调用堆栈的状态

在每次迭代时,事件循环都会检查调用栈中是否有任何东西,如果有,它将一直进行到调用栈为空为止。


事件循环迭代

排队功能


上面的示例看起来很普通,没有什么特别的:JavaScript查找需要执行的代码并按顺序执行。 我们将讨论如何在清除调用堆栈之前推迟执行函数。 为此,使用以下构造:

 setTimeout(() => {}), 0) 

它允许您在执行程序代码中调用的所有其他函数之后,执行传递给setTimeout()函数的函数。

考虑一个例子:

 const bar = () => console.log('bar') const baz = () => console.log('baz') const foo = () => { console.log('foo') setTimeout(bar, 0) baz() } foo() 

此代码输出的内容似乎是意外的:

 foo baz bar 

在运行此示例时,首先调用foo()函数。 在其中,我们调用setTimeout() ,并将此函数作为第一个参数bar传递。 通过将0作为第二个参数传递,我们通知系统应尽快执行此功能。 然后我们调用baz()函数。

这就是调用堆栈的外观。


执行代码时更改调用堆栈的状态

这是我们程序中的功能现在将执行的顺序。


事件循环迭代

为什么会这样发生?

事件队列


调用setTimeout()函数时,浏览器或Node.js平台将启动计时器。 在计时器工作之后(在我们的示例中,由于将其设置为0,因此立即发生),传递给setTimeout()的回调函数进入事件队列。

事件队列在涉及浏览器时,包括由用户启动的事件-由鼠标单击页面元素引起的事件,从键盘输入数据时触发的事件。 DOM onload处理程序(如onload会立即出现,该函数在接收异步请求以加载数据的答案时onload函数onload 。 他们在这里等待轮到他们处理。

事件循环优先考虑调用堆栈中的内容。 首先,它执行它设法在堆栈上找到的所有内容,然后在堆栈为空后继续处理事件队列中的内容。

我们不需要等到setTimeout()类的功能完成工作,因为浏览器提供了类似的功能,并且它们使用自己的流。 因此,例如,使用setTimeout()函数将计时器设置为2秒,由于计时器在您的代码之外运行,因此您不应在停止其他代码的执行之后等待这2秒。

ES6作业队列


ECMAScript 2015(ES6)引入了Job Queue的概念,Promise使用了Job Queue(它们也出现在ES6中)。 由于作业队列,可以尽可能快地使用执行异步功能的结果,而无需等待调用堆栈清除。

如果在当前功能结束之前解决了承诺,则将在当前功能完成后立即执行相应的代码。

我发现了一个有趣的类比。 这可以与游乐园中的过山车相提并论。 骑完山又想再做一次之后,您要买票,进入车尾。 这就是事件队列的工作方式。 但是作业队列看起来有所不同。 此概念类似于打折机票,它使您有权在完成上一张票后立即进行下一次旅行。

考虑以下示例:

 const bar = () => console.log('bar') const baz = () => console.log('baz') const foo = () => { console.log('foo') setTimeout(bar, 0) new Promise((resolve, reject) =>   resolve('should be right after baz, before bar') ).then(resolve => console.log(resolve)) baz() } foo() 

执行后将输出以下内容:

 foo baz should be right after baz, before bar bar 

您在此处看到的内容展示了promise(以及基于它们的async / await构造)与传统异步函数之间的严重区别,传统异步函数的执行是使用setTimeout()或所用平台的其他API进行组织的。

process.nextTick()


process.nextTick()方法以特殊方式与事件循环交互。 滴答是一个完整的事件周期。 将函数传递给process.nextTick()方法,我们通知系统需要在事件循环的当前迭代完成之后,下一个函数开始之前调用此函数。 使用此方法如下所示:

 process.nextTick(() => { // -  }) 

假设事件循环正忙于执行当前功能的代码。 该操作完成后,JavaScript引擎将执行上一个操作期间传递给process.nextTick()所有功能。 使用这种机制,我们努力确保异步执行某个功能(在当前功能之后),但要尽快将其放入队列中。

例如,如果使用setTimeout(() => {}, 0)构造,则该函数将在事件循环的下一次迭代中执行,即比在相同情况下使用process.nextTick()时要晚得多。 当必须确保在事件循环的下一次迭代的开始执行某些代码时,应使用此方法。

setImmediate()


Node.js为异步代码执行提供的另一个功能是setImmediate() 。 使用方法如下:

 setImmediate(() => { //   }) 

传递给setImmediate()的回调函数将在事件循环的下一次迭代中执行。

setImmediate()setTimeout(() => {}, 0) (即应尽快运行的计时器)和process.nextTick()有何区别?

传递给process.nextTick()的函数将在事件循环的当前迭代完成后执行。 也就是说,将始终在使用setTimeout()setImmediate()调度执行的功能之前执行该功能。

设置延迟为0 ms调用setTimeout()函数与调用setImmediate()非常相似。 传递给它们的函数的执行顺序取决于各种因素,但是在两种情况下,都将在事件循环的下一次迭代中调用回调。

计时器


我们已经讨论过setTimeout()函数,该函数允许您安排对传递给它的回调的调用。 让我们花一些时间来更详细地描述其功能,并考虑与之相似的另一个函数setInterval() 。 在Node.js中, 计时器模块中包含用于计时器的功能 ,但是由于它们是全局的,因此无需在代码中连接此模块就可以使用它们。

▍函数setTimeout()


回想一下,当您调用setTimeout()函数时,它将接收一个回调以及以毫秒为单位的时间,在该时间之后将调用该回调。 考虑一个例子:

 setTimeout(() => { //   2  }, 2000) setTimeout(() => { //   50  }, 50) 

在这里,我们将setTimeout()传递setTimeout()立即描述setTimeout()新函数,但是在这里,我们可以通过将setTimeout()的名称和一组参数传递给它来使用现有函数。 看起来像这样:

 const myFunction = (firstParam, secondParam) => { //   } //   2  setTimeout(myFunction, 2000, firstParam, secondParam) 

setTimeout()函数返回计时器标识符。 通常不使用它,但是您可以保存它,并且如果不再需要计划的回调,则可以删除计时器:

 const id = setTimeout(() => { //      2  }, 2000) //  ,       clearTimeout(id) 

▍零延迟


在前面的部分中,我们将setTimeout()传递给它,作为必须在其后调用回调0 。 这意味着将尽快调用回调,但要在当前函数完成之后:

 setTimeout(() => { console.log('after ') }, 0) console.log(' before ') 

这样的代码将输出以下内容:

 before after 

在执行繁重的计算任务时,我不想阻塞主线程,允许执行其他功能,将这些任务分为几个阶段,以setTimeout()调用执行,这种技术特别有用。

如果我们回想起上面的setImmediate()函数,那么它在Node.js中是标准的,不能在浏览器中说出来(它是在IE和Edge中实现的,但在其他浏览器中却没有实现)。

▍函数setInterval()


setInterval()函数类似于setTimeout() ,但是它们之间存在差异。 setInterval()不会定期执行传递给它的回调,而是以指定的时间间隔定期调用此回调。 理想情况下,此过程将一直持续到程序员明确停止该过程为止。 使用此功能的方法如下:

 setInterval(() => { //   2  }, 2000) 

传递给上述函数的回调将每2秒调用一次。 为了提供停止此过程的可能性,您需要获取setInterval()返回的计时器标识符,并使用clearInterval()命令:

 const id = setInterval(() => { //   2  }, 2000) clearInterval(id) 

一种常用的技术是在满足特定条件时在传递给setInterval()的回调中调用clearInterval() 。 例如,以下代码将定期运行,直到App.somethingIWait属性App.somethingIWaitarrived

 const interval = setInterval(function() { if (App.somethingIWait === 'arrived') {   clearInterval(interval)   //    -  ,   -    } }, 100) 

cur递归设置setTimeout()


setInterval()函数每隔n毫秒调用一次传递给它的回调,而不必担心该回调在上一次调用之后是否已完成。

如果每次对该回调的调用总是需要少于n时间,则这里不会出现问题。


定期调用回调,每个执行会话花费相同的时间,属于两次调用之间的间隔

可能需要花费不同的时间来完成回调,该回调仍小于n 。 例如,如果我们正在谈论执行某些网络操作,那么这种情况是可以预料的。


定期称为回调,每个执行会话花费不同的时间,介于两次调用之间

当使用setInterval() ,当回调占用的时间大于n ,可能会导致出现这种情况,导致下一个调用在上一个调用完成之前完成。


定期调用回调,每个会话花费不同的时间,有时不适合两次调用之间的间隔

为了避免这种情况,可以使用setTimeout()的递归计时器设置技术。 关键是要在上一次调用完成之后计划下一次回调:

 const myFunction = () => { //    setTimeout(myFunction, 1000) } setTimeout( myFunction() }, 1000) 

使用这种方法,可以实现以下情形:


对setTimeout()的递归调用以安排回调执行

总结


今天,我们讨论了Node.js的内部机制,例如事件循环,调用堆栈,并讨论了如何使用计时器来调度代码执行。 下次,我们将深入探讨异步编程的主题。

亲爱的读者们! 您是否曾经遇到过不得不使用process.nextTick()的情况?

Source: https://habr.com/ru/post/zh-CN424553/


All Articles