- 原文作者:Gaurav Pandvia
- 原文链接:medium.com/@gaurav.pan…
- 文中部分链接可能需要梯子。
- 欢迎批评指正。
现如今,web开发者(我们更喜欢被叫做前端工程师)用一门脚本语言就能做任何事情,从提供浏览器中的交互,到开发电脑游戏、桌面工具、跨平台移动应用,甚至可以在服务端部署(如最流行的Node.js)来连结任意数据库。因此,了解Javascript的内部构造很重要,这样才能更优更高效的使用它。这也是本文的主旨所在。
Javascript的生态正在变得越来越复杂。要构建一个现代web应用,会不可避免的用到Webpack、Babel、ESLint、Mocha、Karma、Grunt……我该用哪个?这些都是干嘛的?我找到了这个漫画,它完美诠释了如今的web开发者的水深火热:
在一头扎进框架和库的海洋之前,每个Javascript开发者首先需要了解Javascript在底层是如何实现的。差不多每个JS开发者都听过“V8”这个术语,但有些人可能根本不知道这个词到底什么意思、干嘛用的。在我职业开发生涯的第一年里,我对这些花里胡哨的术语所知甚少,我更关心先完成工作。但这样并不能满足我的好奇心,我好奇Javascript是他喵的怎么能做到这一切的。我决定要深挖一番,我翻遍Google,找到一些优秀的博客,包括Philip Roberts的a great talk at JSConf on the event loop。所以我决定总结我的学习经验并分享出来。鉴于有太多东西要了解,我把本文分为两个部分。这一部分会介绍常用术语,第二部分则会阐述这些术语之间的关联。
Javascript是一个单线程单并发的语言,也就是说它一次只能处理一个任务,执行一条代码。它的调用栈连同堆、队列一起构成了Javascript并发模型(在V8中实现)。让我们一个个地看这几个词。
- 调用栈(Call Stack):它是记录我们在程序中调用函数的数据结构。假如我们调用一个函数来执行,就是在把某种记录推入到调用栈的顶端;当我们从一个函数中返回出来,就从调用栈顶端弹出记录。
当我们运行上图中的代码,我们会先寻找所有执行的开端——主函数。在上例中,一系列执行开始于console.log(bar(6))
,那么这一次执行就被推入调用栈中,它上面一层就是函数bar
及其参数,函数bar
转而调用函数foo
,foo
也被推入栈中;而foo
随即return
了某个值,所以被弹出调用栈;类似地,bar
随后弹出,最后console
语句打印了结果并弹出。所有这些举动都依次发生在须臾之间。
你们肯定都在浏览器控制台见过那个又长又红的报错栈,它用一种从上到下的恰如栈的方式,简单表明了调用栈的当前状态以及在函数中何处报错(见下图)。
有时候,当我们以递归的形式多次调用一个函数,就会陷入无限循环中,而对于Chrome浏览器来说,它对调用栈的大小的限制是16000层,超出限制就会终止程序并抛出达到栈上限错误(见下图)。
- 堆:对象会被分配到堆——内存中的松散结构。所有的针对变量和对象的内存分配都在堆中进行。
- 队列:一种Javascript运行时,包含了一个消息队列,这个队列就是一系列将被处理的信息和要执行的相关回调函数。当调用栈有足够空间,就从队列中取出一条消息并进行处理,该消息调用相关联的函数(并因此产生一个初始化栈层)。当栈再次清空时,消息处理也就结束了。简单说,这些消息被排成队列,指定回调函数来响应外部异步事件(例如鼠标点击或HTTP请求的响应)。诸如用户点击按钮而没有相应回调函数的情况,就不会有消息放入队列中。
事件循环(event loop)
当我们评估JS代码的性能时,要知道调用栈中的函数会让程序或快或慢,console.log()
会很快,但用for
或while
迭代成千上万次就会慢一些,并且让调用栈一直被占用被阻塞着。这就叫做阻塞脚本,你可能在Webpage Speed Insights中见过。
网络请求会慢,图片请求会慢,但万幸,服务请求可以通过AJAX这种异步函数完成。假如那些网络请求用同步函数来完成,将会如何?网络请求发送到服务器——服务器也就是某处的某种机器罢了,现在假设服务器返回响应可能会缓慢,此时,如果我点击一些CTA(call-to-action)按钮,或者其他一些需要完成的渲染,就不会有什么反应,因为调用栈还被之前的网络请求阻塞着。在Ruby等多线程语言中,这种情况可以控制,但像Javascript这种单线程语言,除非调用栈中的函数返回值,否则就一直堵着。浏览器没有任何反应,网页就会崩溃。这样我们可没办法为最终用户提供流畅的用户界面。那我们怎么办?
“JS中的并发——一次只做一件事,异步回调除外”
最早的解决方案就是用异步回调,这意味着我们给某部分代码加一个回调,该回调会在这段代码执行完成后执行。我们肯定都遇到过诸如AJAX请求用的$.get()
、setTimeout()
、setInterval()
、Promises
的异步回调。Node都是基于异步函数执行的。所有那些异步回调不会像console.log()
等同步函数那样立刻运行,而是在之后的某个时刻运行,所以不会立刻就推到调用栈中去。那它们到底去哪里了?怎么控制它们?
如上例,若一个网络请求在Javascript中运行:
1. 请求函数被执行,给`onreadystatechange`事件传一个匿名函数作为回调,用来在将来响应就绪的时候执行。
2. “Script call done!”立刻输出到控制台。
3. 后续某时刻,响应被返回,回调被执行,响应体被输出到控制台。
复制代码
在等待异步操作完成并解除回调执行之时,响应的解耦调用允许Javascript运行时做别的事。浏览器插入进来调用了它的API,这是用C++实现的API,用来创建线程以控制诸如DOM事件、http请求、setTimeout等异步事件。
那些web接口不能自己把执行代码推入调用栈,如果能,那么该接口会随机出现在你的代码中(执行顺序不可控)。上面讨论过的消息回调队列说明了这一点。任何web接口在执行完毕后,都会把回调推入这个队列。事件循环此时就要负责控制队列中的回调的执行,并在栈空时把回调推入栈中。事件循环的基本工作就是监听调用栈和任务队列,当它看到栈空了,就把队列中第一个任务推入栈。每个消息或者回调都在上一个任务处理完再开始处理。
while (queue.waitForMessage()) {
queue.processNextMessage();
}
复制代码
在web浏览器中,一旦某事件发生并绑定了事件监听器,消息就立即添加到队列中。如果没有监听器,那就意味着事件丢失了。因此点击一个绑定了点击事件处理器,就会新增一个消息,其他事件亦如此。对其回调的调用将会是调用栈中的初始层,而由于Javascript是单线程的,在调用栈中所有调用都return
之前,后续的消息的轮询和处理就暂停了。之后的(同步的)函数调用会向调用栈中增加新的调用层。
在下一部分,我会通过一个动画来展示上述过程的代码执行,深入解释什么是不同类型的异步函数、队列中谁优先执行,以及诸如零延迟等功能的技巧。