一文说清 JS 运行时环境(Event Loop)
在本文中,我们将介绍浏览器的 javaScript 运行时环境。我们将学习 Chrome 的JS V8 引擎如何解析代码,并了解事件循环如何帮助代码在单个线程上同步和异步运行。最后,我们将看一个常见示例,该示例可以更清楚地解释此过程的工作原理。
JS 运行时环境
当您访问网站时,您可以利用各种各样的网络浏览器进行访问,例如 Chrome, Firefox, Edge 或 Safari。每个浏览器都有一个JS 运行时环境。开发人员通过访问JS 运行时提供的各种WEB API来构建程序。
由浏览器 JS运行时环境 提供的 AJAX, DOM, BOM, Event,以及其他的 WEB API 加上 ECMAscript 形成了完整的 JavaScript(actionScript)。
在运行时环境中,还有一个解析代码的JavaScript 引擎。每个浏览器都有自己版本的JS引擎。Chrome 使用它所谓的JS V8 引擎,我将以它为例分析。
将 JS 运行时环境视为一个大容器。那么在这个大容器内还有几个较小的容器成员。当JS引擎解析代码时,它开始将代码片段放入不同的容器中。
简洁明了的 JS 运行时环境:
JS V8 引擎
一旦 Chrome 在网页上接收到 javaScript 代码或脚本,JS V8 引擎就会开始解析它。首先,它将检查代码语法错误。如果找不到,它将开始从上到下读取代码。它的最终目标是将 javaScript 代码编译为计算机可以理解的机器代码。但是,在我们了解它对代码的确切作用之前,我们必须了解解析它的内部环境。
V8 引擎内的堆
环境中的第一个容器,组成 V8 引擎的一部分,被称为内存堆。它的主要作用是存储声明的变量和声明的函数。
V8 引擎内的栈
环境中的第二个容器,也是组成 V8 引擎的另一部分,被称为执行栈(调用栈)。栈是一种LIFO(last input first output)后进先出的数据结构。
只有栈顶的函数会被处理执行,除非前一个函数(处理完毕)被弹出栈,否则JS引擎不会去处理下一个函数。它的主要作用是将函数调用之类的可执行单元推入调用栈。
当函数被推入执行栈,JS 引擎就开始解析函数体,在堆里存储此函数内部声明的变量、把新的函数调用推入栈顶,或是分发到WEB API容器。
当函数有了返回值,或者被分发到 WEB API 容器,它就会被弹出栈,同时下一个函数调用会被推入栈顶。
如果 JS 引擎执行完一个函数,并且该函数没有明确的指明返回值,JS 引擎会默认的返回undefined
然后再将之弹出栈。人们通常说的 JS 同步运行指的就是 JS 引擎解析函数然后弹出栈(再运行下一个函数)的运行流程。简言之,在单线程下同一时间只做一件事。
WEB API容器
环境中的第三个容器。从栈发送到 WEB API 容器内的 WEB API 调用(比如事件监听处理函数、HTTP/AJAX 请求、或者是 Timers 定时器函数)会一直在 WEB API 容器内,直到触发操作为止。这些触发操作可能是一个点击事件被触发、或者是 HTTP 请求完成从数据源获取数据、或者是定时器达到触发的时间点,一旦达到触发条件,一个回调函数就会被推入第四个也是最后一个容器回调队列。
回调队列
回调队列将按添加的顺序存储所有回调函数。当调用栈函数任务为空时,它会将队列开头的回调函数发送到执行栈。当执行栈函数任务再次清空时,它将发送下一个队列首位的回调函数。回调队列是一种FIFO(first input first output)先进先出的数据结构。
事件循环
事件循环是环境内的循环机制。它的工作是持续的检测调用栈和回调队列,如果检测到调用栈为空,它就会通知回调队列把队列中的第一个回调函数推入调用栈。
调用栈和回调队列可能在某一段时间内是闲置的,但是事件循环则永不停歇的检测着这两者。只要 WEB API 容器中的事件达到了触发条件,随时都可以把回调函数添加到回调队列中去。
上面这种循环机制就是我们常讲的 JS 的异步执行。但 JS 本身是同步执行的语言,说白了就是某一个时间只干一件事。
因为 WEB API 模块可以不断的向回调队列添加回调函数,而回调队列又可以不断的把回调函数函数推入执行栈,我们可以认为JS是在异步运行。表现形式上异步,实质上还是同步。说穿了就是把一时完成不了的异步任务,放在需要及时执行的同步任务之后执行。
阻塞和非阻塞I/O
提到阻塞I/O,可以想象一个函数在被无限循环调用。当函数永不停止的执行时,它就永远不会被推出栈,因此栈内的下一个函数就会被永远阻塞无法被调用。另一种情况是一个有极其复杂的逻辑和算法的函数,它必然会花费大量的时间来执行,那么就会阻塞下一个函数的执行。上面会造成阻塞的两个场景是我们编码时需要避免的,但是相比于语言的的设计缺点,更多的阻塞来自开发者的编码错误和糟糕的代码写法风格。
常见的一个阻塞I/O的操作就是 HTTP 请求,例如向某个外部网站发送数据请求,你必须等待该网站的回应。而可能永远得不到回应,那么你的代码就会被阻塞。好在 JS 运行时环境中会处理这种情况。它把 HTTP 请求分发到 WEB API 模块,然后把请求操作弹出栈。这样当请求在 WEB API 模块内等待响应数据的时候,执行栈内的下一个函数就可以被执行。即使请求无法得到数据,程序的其他部分也可以正常执行。这就是我们所说的 JS 是一个非阻塞的异步语言。
经典案例
setTImeout(function(){
console.log('Hey, Why am I last?')
}, 0)
function sayHi(){
console.log('Hello')
}
function sayBye(){
console.log('Goodbye')
}
sayHi()
saybye()
在控制台执行并思考结果后,让我们来看看 JS V8 引擎处理此代码的确切方式...
-
JS 引擎先检查代码语法错误,然后代码从顶部开始更全面地解析代码。
-
首先解析到一个
setTimeout()
函数调用,将其推入执行栈的顶部,然后将其第一个参数也就是那个函数声明存储在内存堆中。 -
发现
setTimeout
函数是 WEB API 的一种,因此就把它分发到 WEB API 容器,然后将其推出执行栈。 -
由于计时器设置为 0 秒,因此 WEB API 容器会立即将其匿名函数推送到回调队列。事件循环会检查执行栈以查看它是否为空,但它不是,因为后面还有俩同步任务
-
setTimeout
函数被分发到 WEB API 容器,Timer()
倒计时 0 秒后将匿名函数添加到任务队列(回调函数准备就绪)。 -
在
Timer()
倒计时的同时,JS V8 引擎向下解析出两个函数声明,并将它们存储在内存堆中。 -
再往下解析出函数
sayHi()
函数调用,sayHi()
推入执行栈顶部。sayHi()
函数内部调用console.log()
,又将console.log()
推入执行栈的顶部。 -
JS引擎开始解析
console.log()
的函数体,它接收了一个消息去打印 "Hello" ,执行完毕,然后被推出执行栈。 -
JS 引擎移知道
sayHi()
内部代码已全部执行完毕,所以sayHi()
被推出执行栈。 -
saybye()
的过程与上面如出一辙。 -
然后一直运行的事件循环检测到执行栈为空。立刻通知任务队列,任务队列将准备就绪的匿名回调函数推送到执行栈栈顶。
-
解析匿名函数发现调用
console.log()
,console.log()
被推入到执行栈栈顶。 -
console.log()
执行完毕推出执行栈,紧接着匿名函数执行完毕推出执行栈。
提示:如果你复制代码在控制台执行,你会发现有一个undefined
被输出,这是因为程序中所有的主函数都没有返回值,它们只是调用了console.log()
函数,当console.log()
方法被执行并弹出之后,解析器执行至主函数的结尾,并没有发现返回值。因此它返回undefined
,然后被推出执行栈。
注意:本文中所讨论的环境是浏览器下的 JS 执行环境。虽然 Node.js 也是用 GoogleV8 引擎驱动的,但是它提供了一个完全不一样的运行时环境。Node.js 不会提供 DOM树、AJAX、以及其他的 WEB API 。但是,在 Node.js 环境下你可以安装你需要的扩展包来构建你的程序。
原文地址The Javascript Runtime Environment
原文作者:Jamie Uttariello(杰米·乌塔里洛)