[译]深入理解JavaScript函数执行—调用栈,事件循环和任务等

3,746 阅读8分钟

Web 开发者,或者前端工程师(我们更喜欢别人这么称呼)现如今几乎能做所有的工作,从扮演一个浏览器内部交互性的角色,到制作电脑游戏、桌面控件、跨平台手机应用,甚至还可以把它写在服务器端(最流行的是node.js)和数据库连接——作为一个脚本语言,实现却近似无所不在。因此弄明白JavaScript的内部机制非常重要,这有助于我们更好地和更有效率的使用它,而这些就是本篇文章要讲的内容。

现在JavaScript生态变得比以往都要复杂,而且未来还会更加复杂。构建一个现代web应用可使用的工具有WebPack、Babel、ESLint、 Mocha、 Karma、 Grunt等等——这些工具我该用哪个呢,每个工具又是用来干嘛的呢?。我发现这个web漫画,生动的诠释了今天web开发者的内心的纠结。

JavaScript疲劳——学习JavaScript的感觉

在每个JavaScript开发者在一头扎进框架或者库的使用之前,首先需要做的就是知晓 如何在最根本的层面上实现所有这些的基础。几乎所有的JS开发者都听说过术语 “V8” 、Chrome的运行时,但一些人可能并不真的懂得他们的意义以及用处。最初我在从事开发工作的第一个年头,对这些花哨的术语也不太了解,因为更多的是先完成工作。而这并不能满足我对JavaScript是如何做到这些事情的好奇心。我决定深挖,查遍谷歌,然后发现好的博客文章很少。而这不多的有用信息中就包括一位Philip Roberts的大牛,及其视频到底什么是Event Loop呢? | 欧洲 JSConf 2014。因此我决定总结我在视频中所学到的并把它分享出来。因为有很多事情需要先做解释,我就把文章分为2个部分。本部分将介绍用到的术语,第二部分再把他们给串起来。

JavaScript是一门单线程单并发语言,意味着它一次只能处理一个任务,或者一次只运行一条代码。它有一个单独的调用栈(call stack),与堆、队列等其他部分一起构成Javascript并发模型(在V8中实现)。我们首先简要介绍下每个术语:

JS模型的可视化表示

  1. 调用栈(Call Stack):调用栈是一个记录函数调用的数据结构。如果我们调用一个函数去执行,我们就会在这个栈中push东西。当我们从一个函数返回时,栈顶就pop出该函数。
    JS栈可视化模型

当运行程序时,我们首先查找main函数——我们所有其他的函数都是在main函数中执行。如上面GIF图所示,运行首先开始于 console.log(bar(6)),因此其被push到栈中。下一帧是函数bar和他的参数,而bar调用函数foo,因此foo也被push到栈中。

foo 立即执行完成后返回,因此从栈顶弹出。类似的bar也从栈中弹出,最后是console弹出并打印输出。所有这些都发生在毫秒级的时间里。

我想你们一定见过浏览器控制台中有时会出现的红色错误堆栈跟踪,它基本上指示了调用栈的当前状态,而函数报错的从顶到底的方式和栈一样。(见下图)

错误栈追踪

有时候,在我们调用递归函数的时候会进入一个无线循环的情况,而Chrome浏览器限制栈的大小是16000帧,如果超出就会终止掉你的进程并弹出Max Stack Error Reached(见下图)

2. 堆(Heap):对象在堆中分配,即堆中的大部分是非结构化的内存区域。变量和对象的内存分配都发生在这里。

  1. 队列(Queue ):一个js 运行时包含一个消息队列,它是一个要处理的消息和相关要调用的函数的的列表。当栈有足够的容量时,从队列中取出消息并进行处理,该消息包括调用关联函数(从而创建初始堆栈帧)。当消息处理结束时,栈又变成了空的。简言之,这些消息是根据外部的异步事件(例如鼠标被单击或接收对HTTP请求的响应)排队的,因为已经提供了回调函数。如果,比如有人点击一个按钮,而按钮没有提供回调函数,就不会有消息去排队。

事件循环

总的说来,当我们评估js代码性能时,是栈中的函数来决定是快还是慢,console.log()运行很快,而执行for或者while进行大量迭代的函数则会慢得多,并且在执行时会保持堆栈被占用或阻塞。这就是你们在Webpage Speed Insights上听到或看到的术语:阻塞脚本。

网络请求可能会很慢,图片请求可能会很慢,但谢天谢地,服务器请求可以通过异步的AJAX完成。试想,假如这些网络请求是通过同步功能实现的,将会发生什么?。网络请求被发送到一些服务器上,它一般是另一台计算机/机器。现在,计算机可以很慢地回送响应。同时,如果单击某个按钮,或者需要执行其他渲染,当栈被阻塞时,就什么也做不了。在多线程语言像ruby,别的请求可以被处理。但在单线程语言像js,在栈中函数return一个值之前,别的请求想要被处理就显得不太现实。在浏览器不能做任何事时,网页就糟糕透顶。如果我们想要用户的体验流畅的UI,这是非常不理想的。那么,我们该怎么解决呢?

“Concurrency in JS— One Thing at a Time, except not Really, Async Callbacks”

最简单的方式就是使用异步回调,异步回调意味着我们运行代码的一部分,然后给它一个、在后面执行的回调函数。我们一定都遇到过异步回调像$.get()这样的ajax请求、setTimeout()、setInterval()、Promise等等。Node中全部都是关于异步函数执行的。所有的这些异步回调都是不立即运行,而是在某个时间之后才运行,因此他们不会像console.log(), 算数运算等这些同步函数一样被立即入栈。那么,他们到底去哪里了,又该怎么处理?
如果我们在JavaScript中看到一个类似于上面代码的网络请求:

  1. 执行请求函数,在onreadystatechange事件中传递匿名函数作为回调,以便在将来某个时候响应可用时执行。
  2. console会立即输出“Script call done!”。
  3. 将来的某个时间,响应到来并且我们的回调执行,输出他的响应body到console 调用者与响应的解耦允许JavaScript runtime 在等待异步操作完成及其回调触发时执行其他操作。

2这是浏览器自身的API发挥作用的地方,调用这些API处理诸如DOM事件、HTTP请求、StimeTimeUT等异步事件。(知道了这一点之后,在Angular 2 中,使用Zones来对这些API进行重新封装,以引起运行时更改检测,我现在可以了解一下它们是如何实现的)

现在,这些 WebAPI 本身不能将执行代码放到堆栈中,如果放的话,那么这些执行代码将随机出现在你们代码中。上面讨论的消息调用队列阐释了这一过程。3WebAPI中的任何一个在执行完后将回调推送到队列中。事件循环现在负责在队列中执行这些回调,并当堆栈为空时,将其推送到堆栈中。4事件循环的基本工作就是盯着栈和任务队列,当看到栈为空时,将队列中的第一项推到堆栈。在处理任何其他消息之前,会完全处理每个消息或回调。

在Web浏览器中,任何事件发生时都会添加消息,并且附加了事件侦听器。如果没有侦听器,则事件丢失。因此,单击一个带有单击事件处理程序的元素,将添加一个消息,而任何其他事件也是如此。这个回调函数的调用充当调用堆栈中的初始帧,并且由于JavaScript是单线程的,所以在堆栈上返回所有调用之前,将暂停进一步的消息轮询和处理。后续(同步)函数调用将新的调用帧添加到堆栈中。

在下一部分,我将展示上述过程的代码执行的可视化动画,进一步解释什么是不同类型的异步函数,如任务、微任务以及队列中谁的优先级高等。此外,类似零延迟的黑客用来执行某些功能。

希望,各位读者喜欢。您的宝贵意见,就是对我最大的支持。

注释

文章原文地址: Understanding Javascript Function Executions — Call Stack, Event Loop , Tasks & more