代码编译初探(下) - babel原理 - 前端必看

6,514 阅读4分钟

前言

经过初探代码编译一系列文章[公众号:前端小菜鸟001]的介绍与实践,大家应该对 compiler [编译器]的实现与执行过程有了一定的认识与理解了。

下面我们来进一步探究一下 编译器是怎么实现不同语言之间的转换的

(语言A => 语言B 并且不影响程序的正确执行结果)

正文

为了更明白更清晰的理解 编译器是怎么实现语言A转换成语言B 的, 下面用一个张概略图说明一下。

(为了方便 本文将语言A 简写为 L-A, 语言B简写为 L-B)

  • 1. L-A / L-B

首先来介绍一下,需要转换的 原始语言A 与 目标语言B 的差异:

// L-A
(add 2 (subtract 4 2))
// L-B
add(2, subtract(4, 2))

其对应的 AST 语法树结构:

聪明的人应该可以看出来,在实现对不同语言的代码转换过程中,核心的工作除了常用的的词法分析、语法分析外,还有就是对 AST 的 transform 和 对新生成 AST 的目标代码生成环节。 下面对每一步进行一一介绍。

  • 2. 词法分析 (tokenizer)

词法分析的过程 在之前的几篇文章中已经多次进行介绍与实践,

在这我们不对其进行不展开详细讨论。

tokens 如下所示:

const input = '(add 2 (subtract 4 2))';

const tokens = [
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'add'      },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: '('        },
  { type: 'name',   value: 'subtract' },
  { type: 'number', value: '4'        },
  { type: 'number', value: '2'        },
  { type: 'paren',  value: ')'        },
  { type: 'paren',  value: ')'        }
];

function tokenizer(input) {
  let tokens = []
  let current = 0;
  while(current < input.length) {
    let char = input[current]
    // '('  ')' 括号
    if(char === '(' || char === ')') {
      tokens.push({
        type: 'para',
        value: char
      })
      current++;
      continue;
    }
    //  空格
    let WHITESPACE = /\s/;
    if(WHITESPACE.test(char)) {
      current++;
      continue;
    }
    // 0-9 数字
    let NUMBERS = /[0-9]/;
    if(NUMBERS.test(char)) {
      //...
      let value = '';
      while(NUMBERS.test(char)) {
        value += char;
        char = input[++current]
      }
      tokens.push({
        type: 'number',
        value
      })
      continue;
    }
    // string "xx"  字符串
    if(char === '"') {
      // ...
    }
    // name
    let LETTERS = /[a-z]/i;
    if(LETTERS) {
      // ...
    }
    throw new TypeErroe(char)
  }
  return tokens
}
  • 3. 语法分析 (parser)

根据词法分析得到的tokens, 以及上面所描述的 L-A AST的语法结构,可以对 parser 进行编程实现,把 tokens 转换成 L-A 对应的 AST 语法树。

function parser(tokens) {
 const current = 0;
 // 定义 ast 根结点
 let ast = {
    type: 'Program',
    body: [],
 };
 while(current < tokens.length) {
   ast.body.push(walk());
 }
 // 定义一个递归调用的`walk`函数
 function walk() {
    // 获取当前要处理的 token
    let token = tokens[current];
    // 如果当前值是 number 或者 string 则返回一个ast节点
    if (token.type === 'number') {
      current++;
      return {
        type: 'NumberLiteral',
        value: token.value,
      };
    }
    if (token.type === 'string') {
       current++;
       return {
         type: 'StringLiteral',
         value: token.value,
       };
    } 
    // 如果是 '(' 括号则创建一个 `CallExpression` ast 节点
    if (token.type === 'paren' && token.value === '(') {
      // 获取'('括号后面的 token     
      token = tokens[++current];
      // 创建一个 `CallExpression` ast节点,并把当前token设置为其name  
      let node = {
        type: 'CallExpression',
        name: token.value,
        params: [],
      };
      // 获取 name 后面的 token
      token = tokens[++current];   
      // 处理 `CallExpression` 节点的后代节点 
      while ((token.type !== 'paren') ||
         (token.type === 'paren' && token.value !== ')')
       ) {
          // 递归调用`walk`函数,将其返回的节点并挂到node.params
          node.params.push(walk());
          token = tokens[current];
       }
       // 跳过 ')' 括号
       current++;
       return node;    
    }          
 }
 return ast;
}

// 最终 ast 结果
const ast = {
  type: 'Program',
  body: [{
    type: 'CallExpression',
    name: 'add',
    params: [{
      type: 'NumberLiteral',
      value: '2'
    }, {
      type: 'CallExpression',
      name: 'subtract',
      params: [{
        type: 'NumberLiteral',
        value: '4'
      }, {
        type: 'NumberLiteral',
        value: '2'
      }]
    }]
  }]
};

至此,我们得到了 L-A 的 AST, 后面将进入极其重要的 transform 环节。

  • 4. 转换 (transformer)

转换阶段,这一步是从上面最后一步获取到的 AST 并对其进行更改。 它可以操纵AST 当作相同的语言进行处理,也可以将其翻译成一种全新的语言。

下面看一下 如何转换 AST的。

或许已经发现,在AST 中,包含着看起来都很相似的元素,这些对象都有 type 属性,每一个都属于一个 AST 节点,这些定义了属性的节点在其上都描述了一个树的独立的部分。

在转换AST时,可以通过 添加/删除/替换属性 来操作节点,可以添加新节点、删除节点、或者丢弃现有的 AST,并基于它创建一个全新的 AST。

因为这里的目标是转换成一种新的语言,因此将重点放在创建一种针对目标语言的全新 AST。

为了能访问到所有的节点,需要利用深度优先算法来遍历整个 AST。

如果我们直接手工操作此 AST(增删改),而不是创建单独的AST,则可能会引入各种抽象概念,但是,对于我们这里将要做的工作,只需要 逐个的访问树上每一个节点就够了。

visitor 具体定义如下:

// 定义一个带有 node-types处理的 visitor, 为了更有效这里添加了对父节点的引用
var visitor = {
  NumberLiteral(node, parent) {},
  CallExpression(node, parent) {},
};
// ast 结构
- Program
  - CallExpression
    - NumberLiteral
    - CallExpression
      - NumberLiteral
      - NumberLiteral
      
// ast深度优先算法处理 //回溯
-> Program (enter)
  -> CallExpression (enter)
    -> Number Literal (enter)
    <- Number Literal (exit)
    -> Call Expression (enter)
       -> Number Literal (enter)
       <- Number Literal (exit)
       -> Number Literal (enter)
       <- Number Literal (exit)
    <- CallExpression (exit)
  <- CallExpression (exit)
<- Program (exit)
// 为了支持 enter/exit处理,最终将 visitor定义成这样
var visitor = {
  NumberLiteral: {
    enter(node, parent) {},
    exit(node, parent) {},
  }
  // ...
};

现在,有了 visitor 我们就可以对不相同的 AST node-type 进行处理了,

下面我们定义一个可以接受AST和访问者的遍历器函数。

function traverser(ast, visitor) {
  // 支持数组类型的遍历
  function traverseArray(array, parent) {
    array.forEach(child => {
      traverseNode(child, parent);
    });
  }
  //
  function traverseNode(node, parent) {
    let methods = visitor[node.type];
    // 节点enter时钩子
    if (methods && methods.enter) {
      methods.enter(node, parent);
    }
    switch (node.type) {
      case 'Program':
        traverseArray(node.body, node);
        break;
      // 
      case 'CallExpression':
        traverseArray(node.params, node);
        break;

      // `NumberLiteral` 和 `StringLiteral` 类型不做任何 node 处理
      case 'NumberLiteral':
      case 'StringLiteral':
        break;
    } 
    // 节点exit 时钩子
    if (methods && methods.exit) {
      methods.exit(node, parent);
    }
  }      
  // 遍历 ast 节点
  traverseNode(ast, null);  
}

接下来 实现一个 transformer ,构建一个新的 AST

function transformer(ast) {
  // 创建新的 ast根结点
  let newAst = {
    type: 'Program',
    body: [],
  };
  // 为了方便,这里将 body的引用 copy到 ast._context
  ast._context = newAst.body;
  // 遍历 ast
  traverser(ast, {
    NumberLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'NumberLiteral',
          value: node.value,
        });
      },
    },
    StringLiteral: {
      enter(node, parent) {
        parent._context.push({
          type: 'StringLiteral',
          value: node.value,
        });
      },
    },
    CallExpression: {
      enter(node, parent) {
        // 创建一个 'CallExpression' 
        let expression = {
          type: 'CallExpression',
          callee: {
            type: 'Identifier',
            name: node.name,
          },
          arguments: [],
        };
        // 在'CallExpression' 节点上 定义一个新的引用 
        node._context = expression.arguments;
        // 
        if (parent.type !== 'CallExpression') {
          // 用`ExpressionStatement`包装一下`CallExpression`节点。
          expression = {
            type: 'ExpressionStatement',
            expression: expression,
          };
        }
        parent._context.push(expression);
      }
    }    
  })
  return newAst;
}

const newAst = {
  type: 'Program',
  body: [{
    type: 'ExpressionStatement',
    expression: {
      type: 'CallExpression',
      callee: {
        type: 'Identifier',
        name: 'add'
      },
      arguments: [{
        type: 'NumberLiteral',
        value: '2'
      }, {
        type: 'CallExpression',
        callee: {
          type: 'Identifier',
          name: 'subtract'
        },
        arguments: [{
          type: 'NumberLiteral',
          value: '4'
        }, {
          type: 'NumberLiteral',
          value: '2'
        }]
      }]
    }
  }]
};
  • 5. 代码生成 (code-generator)

最后一个阶段代码生成,代码生成器将递归调用自身,以将树中的每个节点输出为一个长字符串。

function codeGenerator(node) {
  switch (node.type) {
    // 如果是 'Program', 将会 map 遍历其 body 属性,并插入换行符
    case 'Program':
      return node.body.map(codeGenerator)
        .join('\n');
    // 如果是`ExpressionStatement` 用括号包装并且嵌套调用代码生成器表达式,并加上分号
    case 'ExpressionStatement':
      return (
        codeGenerator(node.expression) +
        ';'
      );
    // 对于`CallExpression`,则打印 `callee`并添加一个左括号,
    // 然后将遍历`arguments`数组中的每个节点,并通过代码生成器运行它们,
    // 然后用逗号将它们连接起来,然后添加右括号。 用括号对其包装
    case 'CallExpression':
      return (
        codeGenerator(node.callee) +
        '(' +
        node.arguments.map(codeGenerator)
          .join(', ') +
        ')'
      );
    
    case 'Identifier':
      return node.name;
    // 对于 `NumberLiteral` 直接返回
    case 'NumberLiteral':
      return node.value;
    // 对于 `StringLiteral`, 用 “ ” 包装
    case 'StringLiteral':
      return '"' + node.value + '"';
  }  
}
const output = "add(2, subtract(4, 2))";

到此,编译器已经完整的具备了 L-A 转换成L-B 的全部功能。从 input 到 output, 经历了四个阶段 最终完成了编译器的代码转换。

1. input  => tokenizer   => tokens
2. tokens => parser      => ast
3. ast    => transformer => newAst
4. newAst => generator   => output

如果你已经看到这里,那么恭喜你,你已经超越了 80% 的读者

结语

到此为止,初探代码编译系列到此告一段落,我想如果你能把这四篇文章全部搞懂并且手动去实现其涉及的代码,对前端日常开发肯定会有一个新的认识,不论是 工程化中用到对 babel 还是 eslint 等等, 其底层原理都是很相似的。

参考文献:

  1. github.com/babel/babel

ps:如有不足 欢迎指正


一只前端小菜鸟 | 求知若渴 | 梦想与爱皆不可辜负