一 什么是 V8
1. V8 是什么
V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,其核心功能是执行 JavaScript 代码。其主要核心流程分为编译和执行两步。首先需要将 JavaScript 代码转换为低级中间代码或者机器能够理解的机器代码,然后再执行转换后的代码并输出执行结果。
可以把 V8 看成是一个虚构出来的计算机,也称为虚拟机,虚拟机通过模拟实际计算机的各种功能来实现代码的执行,如模拟实际计算机的 CPU、堆栈、寄存器等,虚拟机还具有它自己的一套指令系统。当 V8 执行 JavaScript 代码时,不需要担心现实中不同操作系统的差异,也不需要担心不同体系结构计算机的差异,只需要按照虚拟机的规范写好代码即可。
2. V8 运行环境
V8 必须运行在宿主环境。原因在于,V8 自身没有主线程,由宿主环境(程序)启动,并提供主线程,堆栈,全局对象,以及其他 V8 运行所需的条件。
3. V8 的调试工具
V8 在 Google 的 git 上开源,并在 github 维护有一份镜像。V8 有一个官方文档网站,提供了 V8 的简介,以及下载和编译方法。
随 V8 一起开源的工具中,有一个名为 d8 的工具。它用来调试 V8。使用 d8 必须从官网下载源码并编译。NPM 上有一个实现了 v8 debugger protocol 的第三方包 —— v8-debug。使用它可以更简单的调试 V8。
二 使用 jsvu 安装 JavaScript 引擎
1. 安装 jsvu
jsvu(JavaScript Engine Version Updater) 是一个 NPM 包。提供了交互式命令行,来安装最新的(默认)或指定版本的 JavaScript 引擎。其 NPM 主页中提供了下载和使用方法。
npm install --global jsvu
复制代码
安装完成后,会创建一个全局命令 jsvu
,在任何目录下运行该命令,均可开始安装 JavaScript 引擎。
2. jsvu 使用
(1)选择系统版本
(2)选择要安装的 JavaScript 引擎
使用方向键控制光标,空格来控制是否选中(*为选中)。默认全选,全部安装。这里只需要安装 v8,v8-debug 即可。选择完毕后,回车确认,开始安装。
v8 和 v8-debug 都需要安装,因为有些参数只有 v8-debug 支持
(3)切换目录
使用 jsvu 安装的 JavaScript 引擎,会默认安装在用户家目录下的 .jsvu 目录下(. 开头的目录是隐藏的)。Windows 用户可以使用下面的命令:
cd c:/%HOMEPATH%/.jsvu
复制代码
%HOMEPATH% 是 Windows 的系统变量,只能在 Windows 系统使用,表示当前用户家目录
3. 引擎的两种运行方式
(1)直接输入命令和文件名,来运行指定文件
# 在当前目录创建 test.js 并运行
v8 test.js
复制代码
(2)-e
选项,直接运行代码
# 直接运行 JavaScript 代码
v8 -e "console.log('aaa')"
# aaa
复制代码
4. 输出 v8, v8-debug 帮助文档
因为在使用的时候,需要频繁使用这两个工具的参数和选项,因此,我们必须有一份帮助文档来查阅。通常,命令行工具的 -h
或者 --help
选项都是查阅帮助信息的,但是这两个工具的帮助信息过多,因此,我们可以把输出重定向到文件,这样我们在用文件工具查看就方便多了。
touch v8-help.txt
v8 --help >> v8-help.txt
touch v8-debug.txt
v8-debug --help >> v8-debug-help.txt
复制代码
三 v8-debug 如何使用
下面我会举两个例子,来说明 v8-debug 如何使用,如何加深我们对 JavaScript 的理解。
1. 严格模式
在 JavaScript 中,严格模式与普通模式有许多区别,其中一个就是,全局环境的 this 指向不同。非严格模式下,全局环境 this 只想宿主对象(浏览器环境就是 window),严格模式下,全局环境的 this 为 undefined。
那么如何来验证这个结论呢?Chrome 控制台当然也可以,v8-debug 也能做到这件事。首先,我们新建一个测试文件
function add (x, y) {
const num = 100
let result = x + y + 100
return `RESULT: ${result}`
}
console.log(add(20, 30))
复制代码
然后我们在 v8-debug-help.txt 这个文件中可以找到,--trace
选项用来输出调用栈信息,使用该选项运行文件,有如下输出:
v8-debug --trace test.js
1: ~+0(this=0x029b0828334d <JSGlobal Object>) {
2: ~add+0(this=0x029b0828334d <JSGlobal Object>, 20, 30) {
2: } -> 0x029b08108e59 <String[11]: "RESULT: 150">
RESULT: 150
1: } -> 0x029b080023b5 <undefined>
复制代码
我们可以从输出看出来,add 函数的 this 指向 <JSGlobal Object>
,即当前运行环境的宿主对象。
那么,如果用严格模式来运行,结果是什么呢?从帮助文档中,可以查找到,--use-strict
选项用来开启严格模式,我们可以使用这个选项,而不必手动在代码头部添加 use strict;
,输出如下:
v8-debug --trace --use-strict test.js
1: ~+0(this=0x03d10828334d <JSGlobal Object>) {
2: ~add+0(this=0x03d1080023b5 <undefined>, 20, 30) {
2: } -> 0x03d108108e55 <String[11]: "RESULT: 150">
RESULT: 150
1: } -> 0x03d1080023b5 <undefined>
复制代码
可以明显看到,add 函数的 this,指向了 undefined(全局环境不受影响,依然是 <JSGlobal Object>
)。
2. 惰性编译
(1)什么是惰性编译
V8 在执行一段 JavaScript 代码时,不会一次将所有代码全部编译。而是将这段代码顶层部分编译,所有的函数声明,仅在执行到该函数时才进行编译。这样的编译策略被叫做惰性编译。
使用惰性编译的策略,是为了保证 JavaScript 初始时的运行性能。
(2)如何查看惰性编译
惰性编译是默认开启的,在帮助文档有这样一句:
--lazy (use lazy compilation)
type: bool default: true
复制代码
--lazy
是一个布尔类型的选项,默认为 true,即默认开启惰性编译。那么如何关闭呢?
在帮助文档的开头,有对各种选项如何使用的介绍:
The following syntax for options is accepted (both '-' and '--' are ok):
--flag (bool flags only)
--no-flag (bool flags only)
--flag=value (non-bool flags only, no spaces around '=')
--flag value (non-bool flags only)
复制代码
可以看出,如果是布尔类型的选项,需要添加 no
,前缀,也就是使用 --no-lazy
选项来关闭惰性编译。
那么我们该如何查看惰性编译开启和关闭的区别呢?想要看懂区别,首先我们需要对 JavaScript 的运行机制,和 V8 编译流水线有一个基本的认识。
四 V8 编译流水线
1. V8 编译流程
先上图:
上图展示了 V8 引擎从启动,到编译,到执行结束的整个流程。
2. V8 启动
启动阶段可以分为如下几个子过程:
(1)启动宿主环境
严格来说,这一步与 V8 是无关的,但是 V8 自身是没有启动能力的,也没有运行的主线程,一切的开始,都始于宿主环境启动。
(2)启动 V8 运行时环境
当我们想执行一段 JavaScript 代码时,只需要将代码丢给 V8 虚拟机,V8 便会执行并返回结果。
其实在执行 JavaScript 代码之前,V8 就准备好了代码的运行时环境。这个环境包括了
- 栈空间和堆空间
- 全局执行上下文
- 全局作用域
- 消息队列与事件循环系统
- 以及全局函数,WebAPI 等内置资源
(3)构造堆栈空间
栈空间
栈空间用来管理 JavaScript 调用。每一段 JavaScript 代码(一段脚本,或一个函数),V8 在编译阶段会生成执行这段代码的执行环境,也叫做执行上下文。JavaScript 使用栈来管理执行上下文。编译代码时入栈,执行完成后出栈。
栈空间的最大特点是内存连续,因此栈空间的操作效率非常高。但因为内存中很难分配到一大段连续的空间,因此栈空间通常很小。
堆空间
如果有一些内存占用比较大的数据,或者不需要存储在连续空间中的数据,栈空间就不适合使用。于是 V8 使用了堆空间。
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据。JavaScript 除了原生类型以外,其他的对象类型,诸如函数,数组,浏览器的 window 对象,document 对象等,都是存储在堆空间。
(4)全局执行上下文与全局作用域
V8 初始化了基础的存储空间(栈空间,堆空间)之后,接着会初始化全局执行上下文和全局作用域。
(5)构造事件循环与消息队列
V8 是寄生在宿主环境中的,本身没有自己的主线程,而是使用宿主环境提供的主线程,V8 自身,与 V8 执行的代码,都运行在宿主环境的主线程。
只有一个主线程是不够的,当一个线程的代码执行完成后,线程就自动退出了,下次使用时又要重新启动线程,初始化数据。严重影响运行效率。
因此,主线程需要一个消息队列,存放 V8 内部的,页面响应的,JavaScript 触发的各种任务;还需要一个事件循环,不断地从消息队列中取出任务来执行。
3. 编译解析阶段
初始环境准备好以后,就进入了编译阶段。这里有两件事需要做:词法分析,语法分析。
词法分析
词法分析用于将代码拆分为 “最小的,不可分割的单位”,它被叫做 “token”。比如关键字 for
, if
,和一些直接量,如 123
这样的数字。
语法分析
语法分析用于将已经拆分后的 token ,生成抽象语法树(AST)。之后,再根据 AST 生成执行上下文和作用域。
4. 解释执行
有了 AST,执行上下文,作用域,就可以依据这些,由解释器生成字节码,并执行字节码。到这里,执行结束就会输出结果了。
5. 监控热点代码
在上面解释执行的同时,会有一个监控监控器来监控代码的执行情况。一些频繁执行的代码会被识别为热点代码,这些热点代码会被编译器编译为机器码。
五 执行上下文与作用域
1. 执行上下文
任何一个函数在编译阶段都会创建执行所需要的环境,这个环境叫做执行上下文。V8 不断地执行函数,就会不断地创建执行上下文,这些执行上下文使用栈结构维护,我们称之为执行上下文栈,也被叫做调用栈。
执行上下文是在编译阶段就被确定了的,一个执行上下文会包含以下内容:
- 变量环境,var,function 变量存放在变量环境
- let,const 存放在词法环境
- this 指针域
- 内部存在一个 “outer” 指针,指向上层作用域
2. 举个例子理解执行上下文
示例代码如下:
function bar() {
var mName = 'bar-name'
let test1 = 100
if (true) {
let mName = 'inner-name'
console.log(test) // 执行到此的调用栈
}
}
function foo() {
var mName = 'foo-name'
let test = 2
{
let test = 3
bar()
}
}
var mName = 'global-name'
let test = foo()
复制代码
当代码执行到 console.log(test)
这一行时,其调用栈如下图所示:
需要注意的是,执行上下文是在编译阶段创建并入栈的。即便代码没有执行。但 let,var 这些变量的声明都已经被提升,只是 let,const,这些词法环境的变量,只提升声明,不提升初始化,因此,V8 限制了访问,这被称为“暂时性死区”。
执行上下文以函数来划分,一个函数在编译时,内部所有的块级作用域变量都会被提升
3. 作用域
语言的作用域大致分为两类:
第一种,动态作用域
动态作用域,即对环境变量的访问能力与运行时的执行上下文有关,因为运行时执行上下文是动态的,因此叫动态作用域,又因为与执行上下文有关,因此也叫做上下文作用域。
第二种,静态作用域
静态作用域,即对环境变量的访问能力与声明时的执行上下文有关。因为声明时的执行上下文可以在编译阶段由词法规则确定,因此又叫做词法作用域,因为是编译阶段就确定的,因此是静态的,也叫做静态作用域。
JavaScript 使用的就是静态作用域,也叫做词法作用域,即,变量的作用域取决于变量声明时的上下文,而不是执行时的上下文
4. 举个例子理解词法作用域
还是上文的示例代码,执行到 console.log(test)
这一句,访问 test
变量时,作用域关系如下图所示:
我们可以看到,访问变量 test 时,经过了如下步骤:
- 第一步,查看当前的执行上下文的词法环境是否存在
- 第二步,如果词法环境没有,再去当前上下文的变量环境,查看是否存在
- 第三步,如果变量环境也没有,再去 outer 指向的上层作用域对应的执行上下文中,查看是否存在,依此类推
经过上面三步,整个查询过程就形成了一个 “作用域链”。
我们看到,作用域链和调用栈并不严格相等。例如 bar 函数的上层作用域并不是调用方 foo 函数,而是全局上下文。这就是因为 JavaScript 是词法作用域,bar 内部的变量的作用域,只与声明时的位置有关,bar 函数在全局环境声明,那自然上层作用域是全局上下文。
六 从作用域来看惰性编译
1. 作用域与惰性编译的关系
至此,我们简要介绍了一下 V8 的编译流水线,执行上下文,词法作用域的概念。一句话总结这三者的关系,
基于惰性编译的原则,V8 在遇到全局代码,或一段函数声明时,会开始编译其内部的全部顶层代码(不包含内部的函数声明),在这个编译过程中,生成了执行上下文和词法作用域。
换句话说,假如我们要输出编译阶段的词法作用域内容,那么这个输出就与编译次数相关,而编译次数又与是否是惰性编译有关,因为惰性编译下,函数声明会在执行时编译;非惰性编译下,代码是一次性编译的。
2. 通过作用域查看惰性编译
我们回到最初的示例代码:
function add (x, y) {
const num = 100
let result = x + y + 100
return `RESULT: ${result}`
}
console.log(add(20, 30))
复制代码
--print-scopes
选项可以用来输出编译阶段的作用域情况。
我们运行两次命令,考察是否开启惰性编译带来的区别:
# 开启惰性编译(默认开启)
λ v8-debug --print-scopes test.js
Inner function scope:
function add () { // (0000021602C762E0) (12, 102)
// 2 heap slots
// local vars:
LET result; // (0000021602C785B0) never assigned
VAR x; // (0000021602C784D8) never assigned
VAR y; // (0000021602C78520) never assigned
CONST num; // (0000021602C78568) never assigned
}
Global scope:
global { // (0000021602C760C8) (0, 129)
// will be compiled
// 1 stack slots
// temporary vars:
TEMPORARY .result; // (0000021602C76710) local[0]
// local vars:
VAR add; // (0000021602C76550)
// dynamic vars:
DYNAMIC_GLOBAL console; // (0000021602C767D0) never assigned
function add () { // (0000021602C762E0) (12, 102)
// lazily parsed
// 2 heap slots
}
}
Global scope:
function add (x, y) { // (0000021602C762E0) (12, 102)
// will be compiled
// 2 stack slots
// local vars:
LET result; // (0000021602C76790) local[1], never assigned, hole initialization elided
VAR x; // (0000021602C76558) parameter[0], never assigned
VAR y; // (0000021602C76600) parameter[1], never assigned
CONST num; // (0000021602C766A8) local[0], never assigned, hole initialization elided
}
RESULT: 150
复制代码
再来看一下关闭惰性编译以后,编译阶段的作用域情况:
# 关闭惰性编译
λ v8-debug --print-scopes --no-lazy test.js
Global scope:
global { // (0000027D3AF9DF88) (0, 129)
// will be compiled
// 1 stack slots
// temporary vars:
TEMPORARY .result; // (0000027D3AF9EB78) local[0]
// local vars:
VAR add; // (0000027D3AF9E9B8)
// dynamic vars:
DYNAMIC_GLOBAL console; // (0000027D3AF9EC38) never assigned
function add (x, y) { // (0000027D3AF9E1A0) (12, 102)
// will be compiled
// 2 stack slots
// local vars:
CONST num; // (0000027D3AF9E568) local[0], never assigned, hole initialization elided
VAR y; // (0000027D3AF9E4C0) parameter[1], never assigned
LET result; // (0000027D3AF9E650) local[1], never assigned, hole initialization elided
VAR x; // (0000027D3AF9E418) parameter[0], never assigned
}
}
RESULT: 150
复制代码
我们可以明显看到,关闭惰性编译以后,作用域输出变 “短” 了。但是经过前面的分析,我们已经可以认识到,这并不是说关闭惰性编译后,加载的资源少了,而是编译阶段变少了。
开启惰性编译时,全局环境,add 函数,一共需要编译两次次。自然有两次输出,而关闭了以后,只编译一次,这一次把所有的环境的作用域全部描述完了。
我们应该清楚的是,作用域没有变化,而是编译次数,编译阶段变了
至此,我们能够看出,想要发挥 v8-debug 这个工具的作用,还是需要我们对 JavaScript 有一定理解。同时,该工具也能作为一个辅助,帮助我们验证自己的理解。