窥视js的编译原理(V8)

635 阅读8分钟

javascript的编译原理(V8)

js 真的是纯正的解释型语言吗?

V8编译过程中将源代码转换为抽象语法树, 并生成执行上下文. 解释器(Ignition)根据 AST 生成字节码, 并解释执行字节码. (之前V8 并没有字节码,而是直接将 AST 转换为机器码. 为了解决内存占用问题, 引入了字节码)

img

理解编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)

编译器(Compiler)和解释器(Interpreter)

img

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。

而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。比如 Python、JavaScript 等都属于解释型语言。

执行过程大概是:

1.在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析语法分析生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码

如果编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。

Our conclusion there is that JS is most accurately portrayed as a compiled language. -- Kyle Simpson (the author or YDKJS)

2.在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来执行程序、输出结果

Compilation first, then execution

The separation of a parsing/compilation phase from the subsequent execution phase is observable fact, not theory or opinion. While the JS specification does not require "compilation" explicitly, it requires behavior that is essentially only practical with a compile-then-execute approach.

There are three program characteristics you can observe to prove this to yourself: syntax errors, early errors, and hoisting.

解析/编译阶段与后续执行阶段的分离是可以观察到的事实,而不是理论或观点。虽然JS规范不需要显式地“编译”,但它需要的行为本质上却适用于先编译后执行的方法。

您可以观察到三个程序特性来向自己证明这一点:语法错误、早期错误和提升。

Syntax Errors from the Start

var greeting = "Hello";
console.log(greeting);
greeting = ."Hi";
// SyntaxError: unexpected token .

"Hello" 并没有被打印, 而是抛出一个 'SyntaxError'. 由于语法错误是发生在 'console.log()' 之后, 如果JS是自顶向下运行的话, 那么在抛出语法错误之前, 会打印出"Hello". 然而并没有发生.

实际上,在执行第一行和第二行之前,JS引擎能够知道第三行的语法错误的唯一方法是在执行任何一行之前首先解析整个程序

Early Errors

console.log("Howdy");

saySomething("Hello","Hi");
// Uncaught SyntaxError: Duplicate parameter name not
// allowed in this context

function saySomething(greeting,greeting) {
    "use strict";
    console.log(greeting);
}

在这种情况下,这是因为严格模式(这里只选择了'saySomething(…)'函数)禁止函数有重复的参数名;这在非严格模式下始终是允许的。

但是JS引擎如何知道“greeting”参数被复制了呢?它如何知道“saySomething(…)”函数在处理参数列表时甚至处于严格模式(“use strict”`只在函数体的后面出现)?

同样,唯一合理的解释是,在任何执行发生之前,必须首先对代码进行完全解析

Hoisting

function saySomething() {
    var greeting = "Hello";
    {
        greeting = "Howdy";  // error comes from here
        let greeting = "Hi";
        console.log(greeting);
    }
}

saySomething();
// ReferenceError: Cannot access 'greeting' before
// initialization

这种情况是,该语句的 greeting变量属于下一行的声明 let greeting=“Hi” ,而不是 'var greeting=“Hello” 语句。

JS引擎能够知道 next statement 将声明一个同名的块作用域变量(greeting)的唯一方法是,JS引擎已经在前面的过程中处理了此代码,并且已经设置了所有作用域及其相关变量。这种作用域和声明的处理只能通过在执行前解析程序来准确地完成。

这里的“ReferenceError”来自greeting=”Howdy“过早访问“greeting”变量,这是一种称为暂时死区(TDZ)的冲突。

生成抽象语法树(AST)和执行上下文

resources.jointjs.com/demos/javas… 在线解析AST

以一段代码和这段代码的AST为例:

var myName = "极客时间"
function foo(){
  return 23;
}
myName = "geektime"
foo()
img

具体环节:

1.从网络, 缓存或服务器中加载字节流, 对其进行解码

图片

2.分词(tokenize), 词法分析

字节流解码器进行词法分析, 其作用是将一行行的源码拆解成一个个 token. 所谓 token, 指的是语法上不可能再分的最小的单个字符或字符串.

img

从图中可以看出,通过var myName = “极客时间”简单地定义了一个变量, 其中关键字(keyword)“var”、标识符(identifier)“myName” 、赋值运算符(assignment)“=”、字符串(Literal)“极客时间”四个都是 token, 而且它们代表的属性还不一样。

token被创建, 并被发送到解析器(parser). 其余的字节流也依次被发送到解析器, 具体如下图:

图片

例如,0066解码为f0075解码为u006e解码为n,0063解码为c0074解码为t0069解码为i006f解码为o,006e解码为n,接着后面是一个空格。组合起来就是 function

3.解析(parse), 语法分析

其作用是将上一步生成的 token 数据, 根据语法规则转为 AST. 如果源码符合语法规则, 这一步就会顺利完成. 但如果源码存在语法错误, 这一步就会终止, 并抛出一个“语法错误”.

该引擎使用两个解析器: 预解析器和解析器. 为了减少加载网站的时间, 该引擎试图避免解析那些不需要立即使用的代码.

预解析器处理以后可能会用到的代码, 而解析器则处理立即需要的代码!

如果某个函数只有在用户点击某个按钮后才会被调用, 那么就没有必要为了加载网站而立即编译这段代码了.(预解析)

如果用户最终点击了按钮, 需要那段代码, 它就会被送到解析器中.

图片

Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。除了 Babel 外,还有 ESLint 也使用 AST。

ESLint 是一个用来检查 JavaScript 编写规范的插件,其检测流程也是需要将源码转换为 AST,然后再利用 AST 来检查代码规范化的问题。

生成字节码(Bytecode)

解释器(Ignition)登场, 它会根据 AST 生成字节码, 并解释执行字节码. 一旦字节码被完全生成, AST就会被删除, 从而清除内存空间.

字节码就是介于 AST 和机器码之间的一种代码. 但是与特定类型的机器码无关, 字节码需要通过解释器将其转换为机器码后才能执行.

img

从图中可以看出, 机器码所占用的空间远远超过了字节码, 所以使用字节码可以减少系统的内存使用.

图片

即时编译器(JIT), 执行代码

在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

图片

V8的解释器 Ignition 是点火器的意思,编译器 TurboFan 是涡轮增压的意思,寓意着代码启动时通过点火器慢慢发动,一旦启动,涡轮增压介入,其执行效率随着执行时间越来越高效率,因为热点代码都被编译器 TurboFan 转换了机器码,直接执行机器码就省去了字节码“翻译”为机器码的过程。

字节码配合解释器和编译器的技术称为即时编译(JIT)。具体到 V8,就是指解释器 Ignition 在解释执行字节码的同时,收集代码信息,当它发现某一部分代码变热了之后,TurboFan 编译器便闪亮登场,把热点的字节码转换为机器码,并把转换后的机器码保存起来,以备下次使用。

img

REF: Compiler and Interpreter:

You don't know JS Yet, 2nd

dev.to/lydiahallie… V8引擎动图

time.geekbang.org/column/arti… V8是如何执行一段JavaScript代码的?