【译】JS运行时环境

原文地址: The Javascript Runtime Environment

原文作者: Jamie Uttariello

译者语:
本文是在学习的过程中发现的一篇讲述JS机制比较明了的文章,因此尝试翻译了一下。
不是专业的,因此难免有偏颇,欢迎交流指正。
复制代码

通过本文,我们一起了解一下浏览器的JS运行时环境,探究Chrome浏览器V8引擎是如何解析代码,以及事件循环(Event Loop)机制是如何实现在JS单线程中以同步的方式以及某种意义上的异步的方式运行代码。最后,通过一个常见的例子来更加清楚的解释一下这一系列过程是如何进行的。

回到最初的起点

当你用诸如chrome、火狐、Edge或者Safari等浏览器访问一个wed站点时,事实上每个浏览器都有一个JS运行时环境。浏览器对外暴露的供开发者使用的Web API就位于其中。

AJAX、DOM树、以及其他的API,都是Javascript的一部分,它们本质上就是浏览器提供的、在JS运行时环境中可调用的、拥有一些列属性和方法的对象

除此之外,用来解析代码的Javascript引擎也是位于JS运行时环境中的。每一个浏览器的JS引擎都有自己的版本。Chrome浏览器用的是自产的V8引擎,后文中我们将以它为例进行分析。

V8 JS引擎

当Chrome接收到JS代码或网页上的脚本,V8引擎就开始解析工作。首先,它会检查语法错误,如果没有,按编写顺序解读代码最终的目标是将JS代码转换成计算机可以识别的机器语言。但是,在我们搞清楚JS引擎到底做了什么来解析代码之前,我们首先需要知道解析工作所发生的环境。

JS运行时环境

我们可以把JS的运行时环境看作一个大的容器,里面有一些其他的小容器。当JS引擎解析代码时,就是把代码片段分发到不同的容器里。

JS运行时示意图

运行时环境中的第一个容器就是堆内存,它也是V8引擎的一部分。当V8引擎遇到变量声明和函数声明的时候,就把它们存储在里面。

环境中的第二个容器叫做调用栈, 它也是V8引擎的一部分。当引擎遇到像函数调用之类的可执行单元,就会把它们推入调用栈

当函数一被推入执行栈,JS引擎就开始解析函数体,在堆里创建变量、把新的函数调用推入栈顶、或者把自身分发给WEB API调用所在的第三个容器。

当函数有了返回值,或者被分发到Web API容器,它就会被弹出栈同时下一个函数调用会被推入栈顶。如果JS引擎执行完一个函数,并且该函数没有明确的指明返回值,JS引擎会默认的返回undefined然后再将之弹出栈。人们通常说的JS同步运行指的就是JS引擎解析函数然后弹出栈(再运行下一个函数)的运行流程。简言之,在单线程下同一时间只做一件事。

Note:栈是一种LOFO的数据结构 - 后进先出。只有栈顶的函数会被处理,除非前一个函数(处理完毕)被弹出栈,否则JS引擎不会去处理下一个函数。

Web API容器

调用栈内的Web API调用会被分发到Web API容器内,比如事件监听函数、HTTP/AJAX请求、或者是定时器函数,这些事件会在该容器内直到达到触发条件。要么是一个点击事件被触发、或者是HTTP请求完成从数据源获取数据、或者是定时器达到触发的时间点,一旦达到触发条件,一个回调函数就会被推入第四个也是最后一个容器: 回调队列。

回调队列

回调队列会按照添加的顺序存储所有的回调函数,然后等待执行栈为空。当执行栈为空的时候,回调队列会把队列首部的那个回调函数推入执行栈。当执行栈再次为空的时候,再将此时队列首部函数推入。

Note: 队列是一个FIFO的数据结构-先进先出。相比于栈的在尾部添加、移除数据,队列是在尾部添加数据,在首部移除数据。

事件循环

事件循环可以被看作是JS运行时环境中的这样的一个东西:它的工作是持续的检测调用栈和回调队列,如果检测到调用栈为空,它就会通知回调队列把队列中的第一个回调函数推入执行栈。调用栈和执行队列可能在某一段时间内是闲置的,但是事件循环是永不停歇的检测前两者的。在任意时间,只要Web API容器中的事件达到触发条件,就可以把回调函数添加到回调队列中去。

这就是人们常说的***JS可以以异步的方式运行***的含义。这种说法事实上是不正确的,只是看起来像那么回事儿。JS在同一时间只能执行一个函数,无论在栈顶的是什么,它是一个同步语言。但是因为Web API模块可以不断的向回调队列添加回调函数,而回调队列又可以不断的把回调函数函数推入执行栈,我们可以认为JS是在异步运行。这就的是这门语言的强大之处。只拥有同步的能力,却能够以异步的方式运行,像魔法一样!

阻塞与非阻塞I/O

当我们谈起阻塞I/O,想象一个函数在被无限循环调用。当函数永不停止的运行时,它就永远不会被推出栈,因此栈内的下一个函数就会被阻塞的永远无法被调用。另一种情况是一个有极其复杂的逻辑和算法的函数,它必然会花费大量的时间来执行,那么就会阻塞下一个函数的执行。上述的会造成阻塞场景是我们编码的时候需要知道的,但是相比于语言的的设计缺点,还会有更多的编码错误和糟糕的写法会造成阻塞。

常见的一个阻塞I/O的操作就是HTTP请求,例如向某个外部网站发送数据请求,你必须等待该网站的回应。而可能永远得不到回应,那么你的代码就会被阻塞。好在JS运行时环境中会处理这种情况。它把HTTP请求分发到Web API模块,然后把请求操作弹出栈,这样当请求在Web API模块内等待响应数据的时候,执行栈内的下一个函数就可以被执行。即使请求无法得到数据,程序的其他部分也可以正常执行。这就是我们所说的JS是一个非阻塞的语言。

一个典型的例子

很多教学视频和文章都会用类似这样的例子来解释JS运行时环境的工作机制:

setTImeout(function(){
    console.log('Hey, Why am I last?')
}, 0)

function sayHi(){
    console.log('Hello')
}

function sayBye(){
    console.log('Goodbye')
}

sayHi()
saybye()
复制代码

如果你把这段代码粘贴在控制台,将会看到先打印出'Hello', 然后是'Goodbye', 然后是undefined,最后是'Hey, why am I last?'. 尽管setTimeout函数最先被调用并且延迟0ms运行,但是它却是最后输出的。逐行检查代码尝试理解JS引擎解析代码的机制。尝试理解为什么setTimeout函数在sayHi和sayBye函数之后打印输出。

思考完毕,我们一起来看看V8JS引擎到底是怎样处理这段代码的...

  1. JS引擎会检查整段代码的语法错误,如果没有错误,就从头开始深度解析

  2. 首先遇到setTimeout函数调用,把它推入执行栈顶

  3. 解析函数体,发现setTimeout函数是Web API的一种,因此就把它分发到Web API模块然后推出栈

  4. 因为定时器设置了0ms延迟,因此Web API模块立即把它的匿名回调函数推入到回调函数函数队列。事件循环检测执行栈是否是空闲,但是当前栈并不空闲,因为...

  5. (6) 当setTimeout函数一被分发到Web API模块,JS引擎发现了两个函数声明,把它们存储在堆内存里,然后遇到了sayHi函数的调用,就把它推入了栈顶

  6. 同5同时

  7. sayHi函数调用了console.log函数,因此console.log就被推入了栈顶

  8. JS引擎开始解析console.log的函数体,它接收了一个消息去打印‘Hi’,然后被弹出栈

  9. JS引擎返回到函数sayHi的执行,遇到函数的结束符号}之后,把它弹出栈

  10. sayHi函数一出栈,紧接着sayBye函数被调用,它就被推入栈顶,被解析,调用console.log,把console.log推入栈顶,打印一条消息,弹出栈。然后sayBye函数弹出栈

  11. 同10同时发生

  12. 同10同时发生

  13. 事件循环检测到执行栈终于空闲了,通知回调队列,然后回调队列把其中的匿名函数推入执行栈

  14. 匿名函数(就是setTimeout的回调函数)被解析、调用console.log,console.log推入栈顶

  15. console.log执行完毕、再出栈

  16. 匿名函数再被推出栈,程序结束。

PS:如果你复制代码在控制台打印,你会发现有一个undefined被输出,这是因为程序中所有的主函数都没有返回值,它们只是调用console.log函数,当log函数被执行并弹出之后,解析器执行至主函数的结尾,并没有发现返回值。因此它返回undefined,然后把函数弹出栈。

浏览器环境vsNode.js环境

需要注意的时,本文中所讨论的环境是浏览器下的JS执行环境。虽然Node.js也是用GoogleV8引擎驱动的,但是它提供了一个完全不一样的运行时环境. Node.js 不会提供DOM树、AJAX、以及其他的Web API。但是,在Node环境下你可以安装你需要的包 来构建你的程序。

猜你喜欢

转载自juejin.im/post/5c7be69e51882555a8223325