前端工程师的编译原理指南-「编译器工作流程」

2,756 阅读10分钟

引言

无论是对于任何语言框架来说,编译部分的知识往往是隐藏在代码内部不为认知但又非常重要的知识。

大部分前端工程师对于编译原理方面的内容或许仅仅停留在表象层面的理解,仅仅”知其然而不知其所以然“。

这也是我开「玩转编译原理」这个专栏的初衷,希望通过通俗的文字帮助到更多前端开发工程师领略编译世界的风景

对于“陌生而遥远”的编译原理知识,我会用最通俗而实际的文字带领大家步入它的世界。

不想当"螺丝钉"?那就来吧!

Why To Do

相信对于前端编译原理相关知识的应用大家或多或少都会有了解,常见业内打包工具比如 Webpack 、 Rollup 等等全部都是基于 Acorn 进行的 抽象语法树(Abstract Syntax Tree) 的分析从而打包我们的代码。

前端框架中大名鼎鼎的 VueJs 也是基于 AST 的分析从而实现模版分析从而实现了特殊的 .vue 文件,同样著名的 React 中的 JSX 语法仍然是经过 AST 将 JSX 进行语法分析转化成为 JS 语法。

前端闭环工程化中的 ESLint 、 TypeScript 、 PostCss 等等著名类库同样离不开抽象语法树的影子。

其实掌握编译原理知识对于一个前端工程师的好处是非常多的,无论是深入框架底层还是实现一个辅助工具都是非常实用且必不可少的知识。

也许身为前端工程师的你从没有了解过 AST 名,又或许你曾经使用过一些知名的 esprima 、 babel/paser 、acorn 等等类库进行 AST 节点的处理,

在这之前无论你是属于哪一种,无关紧要,请你忘记它们。 这里,我们从基础的 JavaScript 出发渐进式的打造一款自己的小型语法解析器。

这篇文章作为专栏的开篇之文,涉及到的概念性讲解会稍微比较多。

编译器工作流程

此时我先会利用 Esprima 结合一个简单的 Demo 先来实现串通整个编译器的工作流程,稍后我们会使用完全自己实现的编译器去编译我们真实案例来复刻一个小型编译器。

所谓一个简单编译器的工作流程,可以大概概括成为以下三个大的方面:

解析阶段 (Parsing)

首先,在编译器的初始阶段会接受一段代码,通常会是一串字符串。

比如我们以下面这段 JSX 代码为例:

<div id="app"><p>hello</p>Jue Jin</div>

编译器到这段字符串代码之后会进入解析阶段,在解析阶段主要会做以下两件事:

词法分析

当编译器接受到上边的字符串时,首先会将传入的字符串按照词法效果分割成为一系列被称为 Token 的东西,这一步通常被称为分词。

我们先来看看利用 Esprima Api 查看将上述代码进行词法分析后的结果。

const esprima = require('esprima');

// 配置支持jsx和tokens 利用parseScript Api 打印对应的tokens
const { tokens } = esprima.parseScript('<div id="app"><p>hello</p>Jue Jin</div>', { jsx: true, tokens: true });

console.log(tokens,'tokens')

此时上方的语句经过词法分析会被一步一步拆分成为这样的结构:

 [
   {"type": "Punctuator", "value": "<"},
   {"type": "JSXIdentifier","value": "div"}, 
   {"type": "JSXIdentifier","value": "id"}, 
   {"type": "Punctuator","value": "="},
   {"type": "String","value": "\"app\""}, 
   {"type": "Punctuator","value": ">"}, 
   {"type": "Punctuator","value": "<"},
   {"type": "JSXIdentifier","value": "p"},
   {"type": "Punctuator","value": ">"},
   {"type": "JSXText","value": "Hello"},
   {"type": "Punctuator","value": "<"},
   {"type": "Punctuator","value": "/"},
   {"type": "JSXIdentifier","value": "p"},
   {"type": "Punctuator","value": ">"},
   {"type": "JSXText","value": "Jue Jin"},
   {"type": "Punctuator","value": "<"},
   {"type": "Punctuator","value": "/"},
   {"type": "JSXIdentifier","value": "div"},
   {"type": "Punctuator","value": ">"}
]

我们可以看到针对上方传入的 JSX 语法被解析成为了一个 Token 组成的数组,数组中每一个对象即代表一个 Token 。

每个 Token 都是拥有对应的 type 属性表示它的类型以及 value 属性表示它的值。

这一步我们通过解析阶段的词法分析将传入的代码分割成为了一个个 Token ,通常使用有限状态机是词法分析的最佳途径。

关于什么是有限状态机,我会在文章的稍后为大家详细来实现它。

语法分析

上一步我们通过词法分析将输入的代码分割成为了一个 tokens 的数组,在这之后我们需要将 tokens 进行语法分析从而转化成为真正的抽象语法树(AST)形式。

所谓抽象语法树,你可以将它理解成为一颗圣诞树。上述 tokens 中每一个 token 都可以看作成为该圣诞树中的一个节点。

语法分析正式将上述分成的每个 Token 抽象成为一棵树,从而描述每个 Token 节点之间的关系。

const esprima = require('esprima');

// 调用parseScript获得输入代码生成的抽象语法树
const ast = esprima.parseScript('<div id="app"><p>hello</p>Jue Jin</div>', { jsx: true });

console.log(ast, 'ast')

上述的 Token 在经过语法分析后会变成这样的数据结构:

{
  "type": "Program",
  "body": [{
    "type": "ExpressionStatement",
    "expression": {
      "type": "JSXElement",
      "openingElement": {
        "type": "JSXOpeningElement",
        "name": {
          "type": "JSXIdentifier",
          "name": "div"
        },
        "selfClosing": false,
        "attributes": [{
          "type": "JSXAttribute",
          "name": {
            "type": "JSXIdentifier",
            "name": "id"
          },
          "value": {
            "type": "Literal",
            "value": "app",
            "raw": "\"app\""
          }
        }]
      },
      "children": [{
        "type": "JSXElement",
        "openingElement": {
          "type": "JSXOpeningElement",
          "name": {
            "type": "JSXIdentifier",
            "name": "p"
          },
          "selfClosing": false,
          "attributes": []
        },
        "children": [{
          "type": "JSXText",
          "value": "Hello",
          "raw": "Hello"
        }],
        "closingElement": {
          "type": "JSXClosingElement",
          "name": {
            "type": "JSXIdentifier",
            "name": "p"
          }
        }
      }, {
        "type": "JSXText",
        "value": "Jue Jin",
        "raw": "Jue Jin"
      }],
      "closingElement": {
        "type": "JSXClosingElement",
        "name": {
          "type": "JSXIdentifier",
          "name": "div"
        }
      }
    }
  }],
  "sourceType": "script"
}

让我们来用一张简单的图来稍微描述一下这棵树:

image.png

所谓的语法分析阶段其实就是将 Tokens 经过一系列语法分析成为这颗树,树中的每个节点都会保存各自节点对应的信息。

同时因为树形的数据结构也很好的反应出了各个节点之间的关系。

比如最顶层的类型为 JSXElelement 的 div 节点,我们可以将它拆分成为这样的结构来看:

  {
    // 节点类型
      "type": "JSXElement",
      "openingElement": {
        "type": "JSXOpeningElement",
        "name": {
          "type": "JSXIdentifier",
          "name": "div"
        },
       // 是否自闭合
        "selfClosing": false,
        // 该节点对应的属性
        "attributes": [{
          "type": "JSXAttribute",
          // 属性名
          "name": {
            "type": "JSXIdentifier",
            "name": "id"
          },
          // 属性值
          "value": {
            "type": "Literal",
            "value": "app",
            "raw": "\"app\""
          }
        }]
      },
      "children": [...], // 保存该节点内部嵌套的子节点
      "closingElement": { // 闭合节点
        "type": "JSXClosingElement", 
        "name": {
          "type": "JSXIdentifier",
          "name": "div"
        }
      }
  }

经过词法分析后的节点,每个节点都拥有一个 type 属性,它的值表示该节点的类型。比如上边的 <div> 转化后的节点它的类型就为 JSXElement ,又比如上述的 "Jue Jin" 的字符串它的 type 为 JSXText 表示这个节点是一个JSX字符串。

根据不同的类型,我们可以利用对应的属性来访问/修改该节点对应的属性、内容等。

编译器在解析阶段的两步工作词法分析语法分析到这里就结束了,简单来说解析阶段就是将我们输入的字符串代码转化成为树形的数据结构(AST)。

截止目前我会尽可能简单的文字来描述这一过程,在之后我会详细带领大家深入如何自己实现它 。

转化阶段 (Transformaiton)

编译器首先经过转移阶段后将输入的代码转变成为 AST 。之后会进入转化阶段,所谓转化阶段本质上就是对于抽象语法树的一个深度遍历过程。

在转化阶段,我们会遍历这颗抽象语法树从而对于匹配节点进行增删改查从而修改树形结构。

比如我想为 p 节点上添加一个 id 为 text 的属性,那么此时在遍历 AST 的过程中遍历到对应节点时修改对应的节点属性即可,当然你也可以直接粗暴的替换整个节点。具体怎么做 Decided by you !

image.png

让我们先借助 Estraverse 来完成这个步骤。

关于 Estraverse ,它是针对 Esprima 生成的抽象语法树进行深度遍历的一个工具库。因为 Estraverse 这个库不支持 JSX 语法,所以这里我们使用它的一个拓展工具库 estraverse-fb 来实现 JSX 转化的抽象语法树的遍历。

const esprima = require('esprima');
// 深度遍历AST的工具库
const esTraverseFb = require('estraverse-fb')
// 生成AST节点的工具
const { builders } = require('ast-types')

const ast = esprima.parseScript('<div id="app"><p>hello</p>Jue Jin</div>', { jsx: true });

// 深度优先的方式
esTraverseFb.traverse(ast, {
  // 进入每个节点时都会出发enter函数
  enter: function (node) {
    const { type, openingElement } = node
    // 判断当前进入的节点是否是匹配的p节点
    if (type === 'JSXElement' && openingElement.name.name === 'p') {
      // 生成当前需要添加的属性节点
      const attribute = builders.jsxAttribute(
        // 第一个参数是name
        builders.jsxIdentifier('id'),
        // 第二个参数是value
        builders.literal('text')
      )
      // 为该节点的开始标签中添加生成的属性 id='text'
      openingElement.attributes.push(attribute)
    }
  },
  // 离开每个节点时会触发leave函数
  leave: function () {
    // nothing
  }
})

此时经过上述的转化,我们更改了原本的 AST 结构。我们将原始的 p 标签对应的节点修改成为了这样的结构:

{
        "type": "JSXElement",
        "openingElement": {
          "type": "JSXOpeningElement",
          "name": {
            "type": "JSXIdentifier",
            "name": "p"
          },
          "selfClosing": false,
          // 这里我们为attributes中添加了一个属性节点
          "attributes": [{
            "name": {
              "name": "id",
              "loc": null,
              "type": "JSXIdentifier",
              "comments": null,
              "optional": false,
              "typeAnnotation": null
            },
            "value": {
              "value": "text",
              "loc": null,
              "type": "Literal",
              "comments": null,
              "regex": null
            },
            "loc": null,
            "type": "JSXAttribute",
            "comments": null
          }]
        }

也许你会好奇所谓转化阶段的 AST 的遍历和如何匹配对应节点从而进行增删改查,这里在心里对于编译器的转化阶段保留一个大概的构思即可。

同样会在之后我会带你一步一步来击破这个过程。

生成阶段 (Code Generation)

上述我们经过解析阶段 (Parsing) 将输入的字符串转化成了抽象语法树 AST 结构。

之后我们经过转化阶段 (Transformaiton) 对于生成的抽象语法树进行深度遍历节点,从而对于某些节点进行了修改。

此时编译器拥有了经过处理后的抽象语法树,此时需要做的当然是将所谓的树形结构的抽象语法树转化成为新的代码。

这一步通常我们成为生成阶段(Code Generation):我们通过抽象语法树反向转化成为生成的代码,此时最新的代码是根据修改后的 AST 生成的代码。

在生成阶段本质上就是遍历抽象语法树,根据抽象语法树上每个节点的类型和属性递归调用从而生成对应的字符串代码。

在代码生成阶段,我们可以借助 EscodeGen 将 AST 转化成为新的字符串代码。

因为 EscodeGen 对于 JSX 语法并不支持,所以这里具体我就不详细演示用法了,有兴趣的朋友可以自行尝试。

比如上方我们将代码修改的抽象语法树会生成新的代码:

<div id="app"><p id="text">hello</p>Jue Jin</div>

编译器最后一步的生成阶段,将最新的抽象语法树转化为对应的代码结构。代码生成这一步不同的编译器会有不同的实现方式,我们会在之后主要使用递归修改后的 AST 节点这方方式进行代码的生成。

到这一步也就完全完成了代码转化的功能,简单来说整个编译器做的过程就是这三步解析、转化、生成

结尾

一个完整的小型编译器大概工作原理在这里我已经为大家描述完毕。

上边我们提到了一次编译器工作流程中解析、转化、生成这三个完整过程以及每一步处理的事情,当然这三个看似简单的过程中存在很多细节我们还没有讲述。

首篇文章我的初衷更多还是一个抛砖引玉的作用,这里大家对于文中提到的解析、转化、生成三个阶段了解各个阶段的作用以及每个阶段获得的结果即可。

至于如何具体实现解析、转化、生成这三个阶段包括上边提到的有限状态机,之后我会结合一个实际的 Demo 逐步带大家去实现一个小型 JSX 编译器。

对前端编译原理感兴趣的朋友可以关注我的专栏玩转编译原理