深入浅出理解编译器:前端视角
一、编译器究竟是什么?
在前端开发的世界里,我们经常会听到 “编译器” 这个词。就拿 Babel 来说,在它的官网上,最显眼的一句话就是:“Babel is a JavaScript compiler”。那什么是 JavaScript 编译器呢?又该如何去学习和理解编译器呢?让我们一起来揭开它的神秘面纱。
编译器,简单来说,就是一种电脑程序,它的主要工作就是把用某种编程语言写的源代码,转换成另一种编程语言。比如说,Babel 就可以把 ES6、ES7 或者 JSX 这些新的语法转换成 ES5 或者其他指定版本,就像是给代码做了一次 “变形记”。
其实,在我们日常的前端开发中,有很多工具都用到了编译器的技术,比如 Less/Saas 用于处理样式,TypeScript/coffeeScript 用于处理脚本,还有 Eslint 用于代码检查等等。正是因为有了这些工具,前端工程化才能发展得越来越好。
二、编译器的基本思路
(一)词法分析 (Lexical Analysis)
目的
词法分析的主要任务就是把我们写的文本代码分割成一个个小小的 “token”,就像是把一句话拆分成一个个的单词。比如说,“init、main、x、;、=、3” 这些都可以是 token。同时,它还会把那些注释、空格、回车等对代码运行没有实际作用的字符去掉,让代码变得更加 “纯粹”。
生成方式
生成 token 主要有两种办法:
- 正则表达式:这种方法需要写大量的正则表达式,而且这些正则之间还可能会有冲突,维护起来比较麻烦,性能也不是很高。所以,正则表达式一般只适合一些简单的模板语法,对于真正复杂的语言就不太合适了。而且有些语言可能还没有自带的正则引擎呢。
- 自动机:自动机就像是一个智能的小机器,可以很好地生成 token。其中,有穷状态自动机(finite state machine)是一个很重要的概念。它在有限个输入的情况下,会在不同的状态之间转移,最终达到一个终止状态。有穷状态自动机又可以分为 “确定有穷状态自动机”(DFA - Deterministic finite automaton)和 “非确定有穷自动机”(NFA - Non-deterministic finite automaton)。DFA 在输入一个状态时,只会得到一个固定的状态,可以认为是一种特殊的 NFA;而 NFA 当输入一个字符或者条件时,会得到一个状态机的集合。JavaScript 正则采用的就是 NFA 引擎。
(二)语法分析 (Syntactic Analysis)
我们平时说的编译原理,其实就是把一种语言转换为另一种语言。编译原理研究的是形式语言,这种语言不需要知道太多的背景知识,而且没有歧义。和自然语言不一样,自然语言很难处理,因为很难分清哪些是名词、动词、形容词。比如说 “进口汽车”,这里的 “进口” 到底是动词还是形容词呢?所以,我们要解析一门语言,前提是这门语言有严格的语法规定。
在 1956 年,乔姆斯基把文法按照规范的严格性分成了 0 型、1 型、2 型和 3 型共 4 种。一般的计算机语言用的是 2 型文法,也叫上下文无关文法(CFG)。因为 0 型和 1 型文法定义太宽松,会增加解析的难度,降低效率;而 3 型文法限制又太多,不利于语言设计的灵活性。
语法分析的目的就是把词法分析得到的 token 流,结合文法规则,通过一定的算法生成一颗抽象语法树(AST)。AST 在前端领域非常重要,比如说 Babel 插件的原理就是:es6 代码通过 Babylon.parse 生成 AST,然后用 babel-traverse 处理得到新的 AST,最后再生成 es5 代码。
从生成 AST 的效率和实现难度来看,主要有两种解析算法:自顶向下的分析方法和自底向上的分析方法。自底向上算法可以分析的文法范围比较广,但是实现起来比较难;自顶向下算法实现相对简单,能解析的文法范围也不错,所以一般的编译器都会采用深度优先索引的方式。
(三)代码转换(Transformation)
当我们得到 AST 之后,通常会先把它转换成另一种 AST,这样可以生成更符合我们预期的 AST,这个过程就叫做代码转换。代码转换有很多优势:
- 易移植:它和具体的机器没有关系,所以可以作为中间语言,为生成多种不同型号的目标机器码服务。
- 机器无关优化:可以对中间码进行优化,提高代码的质量。
- 层次清晰:把 AST 先映射成中间代码,再映射成目标代码,这样可以让编译算法更加清晰。
对于一个编译器来说,在转换阶段通常有两种形式:
同语言的 AST 转换和 AST 转换为新语言的 AST。
一般的做法是,对之前的 AST 从上至下进行解析(称为 traversal),然后有一个映射表(称为 visitor),把对应的类型做相应的转换。
(四)代码生成 (Code Generation)
在实际处理代码的过程中,我们可能会递归地分析最终生成的 AST,对于每种 type 都有一个对应的函数来处理。最终,我们的目标代码就会在这一步输出。在前端领域,我们的目标代码可能就是 HTML 了。
三、完整链路 (Compiler)
一个完整的编译器过程可以用下面的代码来表示:
input => tokenizer => tokens; // 词法分析
tokens => parser => ast; // 语法分析,生成 AST
ast => transformer => newAst; // 中间层代码转换
newAst => generator => output; // 生成目标代码