深入原理:Babel原理与Babel插件实践

Jan 28, 2020 21:49 · 384 words · 2 minute read Babel

前言

前端开发从「刀耕火种」过渡到「现代化」自动构建工程体系的过程中,Babel扮演了一个举足轻重的地位,使得我们可以用与时俱进的代码语法且最大程度上不受浏览器版本的约束,本文对如何使用Babel不再赘述,着重介绍Babel的原理和如何实现Babel插件。

什么是Babel?

Babel是JavaScript的编译器,它可以将你ES6(ES2015)+语法的代码编译到ES5版本从而兼容落后的浏览器。Babel也设计了一套插件架构去做代码转换,所以你可以去设计独创的代码转换规则。

在编写Babel插件之前,大家需要了解一下抽象语法树/AST的知识(熟知编译原理的可以快速跳过)。

抽象语法树(AST)

抽象语法树是高级编程语言(Java、JavaScript等)转换成机器语言的桥梁。解析器会根据ECMAScript 标准「JavaScript语言规范」来对代码字符串进行词法分析,拆分成一个个词法单元,再遍历各个词法单元进行语法分析构造出AST。我们通过如下代码来分析原理:

let str = 'test';
str + 'abc';

词法分析(Lexical Analysis)

对代码解析时先进入词法分析阶段,tokenizer(分词器)会将原始代码按照特定字符(如let、var、=等保留字)分成一个个叫token的东西。token由分号、标签、变量、字符串等组成,tokenizertokens构造成如下数据结构。

这是一个JavaScript简易编译器中词法分析的实现:https://github.com/jamiebuilds/the-super-tiny-compiler/blob/master/the-super-tiny-compiler.js#L381

在线词法分析链接

[
    { "type": "Keyword", "value": "let" },
    { "type": "Identifier", "value": "str" },
    { "type": "Punctuator", "value": "=" },
    { "type": "String", "value": "'test'" },
    { "type": "Punctuator", "value": ";" },
    { "type": "Identifier", "value": "str" },
    { "type": "Punctuator", "value": "+" },
    { "type": "String", "value": "'abc'" },
    { "type": "Punctuator", "value": ";" }
]

语法分析(Syntactic Analysis)

词法分析完毕后进入语法分析阶段,语法分析将tokens重新格式化为描述语法各部分及其相互关系的表示形式,这就是抽象语法树。

这个抽象语法树就是写Babel插件的核心概念,因为代码转换就是针对各个节点进行操作。

这是一个JavaScript简易编译器中语法分析的实现:https://github.com/jamiebuilds/the-super-tiny-compiler/blob/master/the-super-tiny-compiler.js#L555

接下来我们来看词法分析后的结果

image

我们用在线AST查看器来查看下这段代码在Babel眼中是什么样的

image

可以看到,代码转成AST后有了清晰的树结构,为转换代码提供了操作的标识,让我们方便地去操作各个节点进行修改。

接下来我们具体分析一下第一行代码

image

先从declarations看起:

  1. 类型是VariableDeclaration(变量声明),拥有两个属性idinit
  2. id表示变量名称节点,type表示类型是一个变量Identifiername属性代表它的声明值
  3. init表示初始值的表达式,type表示类型是一个字面量Literalvalue表示值,raw表示原生代码。

以上就是用白话的形式来描述AST是如何表示这行代码的

Babel编译过程

image

  • 用解析器将代码转换成AST(抽象语法树) @babel/parser
  • 遍历AST后使用插件进行修改 @babel/traverse
  • 将AST转化成代码 @babel/generator

如何使用Babel去转换代码

import { parse } from "@babel/parser";
import traverse from "@babel/traverse";
import generate from "@babel/generator";

/*--------原代码----------*/
const code = `
  let str = 'test';
  str + 'abc';
`;
/*-----------------------*/

// code -> ast
const ast = parse(code);

// ast转换
traverse(ast, {
  enter(path) {
    // 判断变量名为str
    if (path.isIdentifier({ name: "str" })) {
      path.node.name = "transformStr";
    }
  }
});

// ast -> code
const output = generate(ast, {}, code);
console.log(output.code);

/*-------转换后的代码-------*/
let transformStr = 'test';
transformStr + 'abc';
/*-----------------------*/

这段代码展示了如何使用@babel/parser@babel/traverse@babel/generator这三个包来进行转换代码,实现的功能是将代码中的变量str替换成transformStr

编写一个最简单的Babel插件

Babel插件之Vistor

首先Babel大量使用了访问者模式(Visitor pattern),在遍历阶段,Babel将进行深度优先的搜索遍历,并访问AST中的每个节点,你可以在Vistor中指定一个回调方法,这样在访问节点时,Babel将使用当前访问的节点调用回调方法。

Babel插件的基础结构

export default function (babel) {
  const { types: t } = babel;
  return {
    visitor: {
      Identifier(path) {
        // 判断变量名为str
        if (path.isIdentifier({ name: "str" })) {
          path.node.name = "transformStr";
        }
      }
    }
  };
}

这几行代码就上文实现替换变量的Babel插件模板写法。 我们的代码需要写在visitor object中,用以nodetype为名称的函数对当前节点进行修改。

这个函数接受两个参数pathstate

path

path代表着在遍历AST的过程中连接两个节点的路径,你可以通过path.node获取当前的节点path.parent.node获得父节点,它也提供了path.replaceWith, path.remove等API,这样就能通过一定条件来获取特点的节点进行修改了。

state

state官方教程中并没有说的很明白,我认为state是一个global state,你可以在遍历AST中使用和更改它。

@babel/types

@babel/types是一个Babel的npm包,其中包含了Babel中所有的API,我们可以通过这些API来对AST进行操作和判断也可以查看每个表达式的类型如:CallExpressionMemberExpression等。

文档地址:https://babeljs.io/docs/en/babel-types

写一个自定义的插件

最近想进行技术改造,将node端的代码重构成用TypeScript编写的,首当其冲的问题就是将node.js中的CommonJS模块规范改成ES modules模块规范

举例:

  • const xx = require('xxx') -> import xx from 'xxx'
  • module.exports = x -> export default x

有了想法后,我们可以分析如何操作节点就修改成我们想要的样子。

首先,我们分析下const xx = require('xxx')的AST构造,查看它的特点

image

可以看出我们可以使用Babel的visitor去遍历CallExpression,然后找到calleenamerequire便可以锁定这行代码的AST节点,然后提取变量名和依赖名,使用Babel的API来构造import语句并替换其父节点就好了。

通过查阅上文的@babel/types提供的API文档可以在visitor中来这样判断

visitor: {
  CallExpression(path) {
    // const xx = require("xxx")
    if (
      t.isIdentifier(path.node.callee, { name: "require" }) &&
      t.isStringLiteral(path.node.arguments[0]) &&
      path.node.arguments.length === 1
    ) {
      // 提取require的依赖名称
      const depName = path.node.arguments[0].value;
      if (
        t.isVariableDeclarator(path.parentPath.node) &&
        t.isIdentifier(path.parentPath.node.id)
      ) {
        // 提取require的声明变量
        const importName = t.identifier(path.parentPath.node.id.name);
        if (t.isVariableDeclaration(path.parentPath.parentPath.node)) {
          path.parentPath.parentPath.replaceWith(
            // 用@babel/types构造import语句
            t.importDeclaration(
              [t.importDefaultSpecifier(importName)],
              t.stringLiteral(depName)
            )
          );
        }
      }
    }
  }
}

然后我们分析下module.exports = x的AST构造,查看它的特点

image

这里也可以使用Babel的visitor去遍历MemberExpression,通过判断node.object.name === 'module'node.property.property === 'exports'就能定位该节点并构造export default语句将其替换。

visitor: {
  MemberExpression(path) {
    // module.exports = xxx
    // babel推荐使用@babel/types中的api来做判断,不要使用等于号判断
    if (
      t.isIdentifier(path.node.object, { name: "module" }) &&
      t.isIdentifier(path.node.property, { name: "exports" })
    ) {
      // 获取声明该语句的节点
      const assignmentExpression = path.parentPath;
      // 构造export default
      if (t.isExpression(assignmentExpression.node.right)) {
        assignmentExpression.parentPath.replaceWith(
          t.exportDefaultDeclaration(assignmentExpression.node.right)
        );
      }
    }
  }
}

将上述两个Visitor拼接起来就可以得到完整的Babel插件。此插件将CommonJS模块规范改成ES modules模块规范并没有覆盖所有例子,仅作演示使用。

接下来我们看下最终效果

image

代码demo参考:https://github.com/spiritree/babel-plugin-commonjs-esmodule

总结

其实写一个Babel插件并没有我们想象中的难,通过工具解析AST后我们可以清晰的了解到代码的结构与特征,在@babel/typeAPI的加持下,让我有种用jQuery操作DOM树的感觉。在学习过程中我们不能光会配置,理解原理深入工具是很有必要的。

参考资料