vv13
博客订阅

Babel 插件起手式

前言

据圣经记载,曾经有一种很高很高的塔,是由一群说着同样语言、勤劳而又团结的人民兴修的,他们希望由此能通往天堂,上帝拦阻了人的计划,是出于爱和保护,让人依靠上帝认识上帝,于是将他们的语言打乱,让他们再也不能明白对方的意思,并把他们分散到了世界各地。因此曾经高耸入云的塔,被世人称作“巴别塔(Babel)”,也称为混乱之塔。

木秀于林,风必摧之,JavaScript 也没能逃过这种命运。它自诞生以来,以迅雷不及掩耳之势,凭借着自身的灵活性与易用性,在浏览器端大放异彩,广泛的应用于不同标准的各个浏览器。可是好景不长,一个被称作 ECMA 的邪恶组织在暗中不断对 JavaScript 进行着实验,将其培养为恐怖的生化武器。科学家们们为了满足各自的私欲,在 ES4 上集成了各自所需的特性,以此想要达成对语言规范的控制权,可被寄予厚望的 ES4 还是没能顶住压力,最终因难产而死。为了继续将实验进行下去,名为 DC 和 M$ 的科学家起了一个更为保守、渐进的提案,被人们广泛接受并时隔两年问世,称为 ES5。长期以来,名为 TC39 的实验室在暗中制定了 TC39 process 流水线,它包含 5 个 Stage:

  • Stage 0Strawman阶段)- 该阶段是一个开放提交阶段,任何在TC39注册过的贡献都或TC39成员都可以进行提交
  • Stage 1Proposal阶段)- 该阶段是对所提交新特性的正式建议
  • Stage 2Draft阶段)- 该阶段是会出现标准中的第一个版本
  • Stage 3Canidate阶段)- 该阶段的提议已接近完成
  • Stage 4Finished阶段)- 该阶段的会被包括到标准之中

自 2015 年来,JavaScript 迈入了一个崭新的 ES6 纪元,它代表着集众家之长的 ES2015 的问世,这使得 JavaScript 它不仅拥有了自己的 ES Module 规范,还解锁了 Proxy、Async、Class、Generator等特性,它已经逐渐成长为一个健壮的语言,并且凭着高性能的 Node 框架开始占领服务端市场,近几年携手 React Native 角逐移动开发,它高喊着自由、民主,逐渐俘获一个又一个少年少女的心扉。

任何语言都依赖于一个执行环境,对于 JavaScript 这样的脚本语言来讲,它始终依赖于 JavaScript 引擎,而引擎一般会附带在浏览器上,不同浏览器间的引擎版本与实现是不同的,因此就很容易带来一个问题——各个浏览器对 JavaScript 语言的解析结果上会有很大的不同。对于开发者而言,我们需要放弃语言新特性并写出兼容代码以此来支持不同的浏览器用户的使用;对于用户来讲,强制用户更换最新浏览器是不合理也不现实的。

这种状况直到 Babel 的出现才得以解决,Babel 是一个 JavaScript 编译器,主要用于将 ES2015+ 语法标准的代码转换为向后兼容的版本,以此来适应老版本的运行环境。Babel 不仅是一个编译器,它更是 JavaScript 走向统一、标准化的桥梁,软件开发者能够以偏好的编程语言或风格来写作源代码,并将其利用 Babel 翻译成统一的 JavaScript 形式。

Babel 是混乱诞生之地,同时也是混乱终结之地,为了世界的和平,我们都需要尝试学习一下 Babel 插件的基础知识,以备不时之需。

抽象语法树

在计算机科学中,抽象语法和抽象语法树其实是源代码的抽象语法结构的树状表现形式,又称为 AST(Abstract Syntax Tree)。AST 常用来进行语法检查、代码风格的检查、代码的格式、代码的高亮、代码错误提示、代码自动补全等,它的应用十分广泛,在 JavaScript 里 AST 遵循 ESTree 的规范。

为了直观展示,我们先来定义一个函数:

function square(n) {
  return n * n;
}

它的 AST 转换结果如下(省略了一些空字段和位置字段):

{
  "type": "File",
  "program": {
    "type": "Program",
    "body": [
      {
        "type": "FunctionDeclaration",
        },
        "id": {
          "type": "Identifier",
            "identifierName": "square"
          },
          "name": "square"
        },
        "params": [
          {
            "type": "Identifier",
            "name": "n"
          }
        ],
        "body": {
          "type": "BlockStatement",
          "body": [
            {
              "type": "ReturnStatement",
              "argument": {
                "type": "BinaryExpression",
                "left": {
                  "type": "Identifier",
                  "name": "n"
                },
                "operator": "*",
                "right": {
                  "type": "Identifier",
                  "name": "n"
                }
              }
            }
          ],
        }
      }
    ],
  },
}

AST 既然是树形结构,那我们就可以将它看作是一个个 Node,每个 Node 都实现了以下规范:

interface Node {
  type: string;
  loc: SourceLocation | null;
}

type 表示不同的语法类型,上面的 AST 中具有 FunctionDeclaration、BlockStatement、ReturnStatement 等类型,我们可以通过每个 Node 中的 type 字段进行分别,所有 type 可见文档。 
除此之外还有许多标识字段,比如 loc 表示节点的位置信息,我们可在编写 Babel 插件时再根据涉及到的相关类型进行深入了解。 

工作流程 

通过配置 Babel 的 presets、plugin等信息,Babel 会将源代码进行特定的转换,并输出更为通用的目标代码,其中最主要的三部分为:编译(parse)、转换(transform)、生成(generate)。

image.png

编译

Babel 的编译功能主要由 @babel/parser 完成,它的最终目标是转换为 AST 抽象语法树,在此过程中主要包含两个步骤:

  1. 词法分析(Lexical Analysis),它会将源代码转换为扁平的语法片段数组,也称作令牌流(tokens)
  2. 语法分析(Syntactic Analysis),它将上阶段得到的令牌流转换成 AST 形式

为了得到编译结果,我们引入 @babel/parser 包,对一段普通函数进行编译,然后查看打印结果:

import * as parser from '@babel/parser';

function square(n) {
  return n * n;
}

const ast = parser.parse(square.toString());
console.log(ast);

转换

转换步骤会对 AST 进行节点遍历,并对节点进行 CRUD 操作。在 Babel 中是通过 @babel/traverse 完成的,我们接着上一段代码的编译过程进行编写,我们希望将 n * n ,转化为 Math.pow(n, 2) :

import traverse from '@babel/traverse';
// ...
const ast = parser.parse(square.toString());

traverse(ast, {
  enter(path) {
    if (t.isReturnStatement(path.parent) && t.isBinaryExpression(path.node)) {
      path.replaceWith(t.callExpression(
        t.memberExpression(t.identifier('Math'), t.identifier('pow')),
        [t.stringLiteral('n'), t.numericLiteral(2)]
      ))
    }
  }
});

console.log(JSON.stringify(ast));

在此过程中,我们使用了 @babel/types 用来做类型判断与生成指定类型的节点。

生成

在 Babel 中主要是用 @babel/generator 进行生成,它将经过转换的 AST 重新生成为代码字符串。根据上面 Demo,改写下代码:

import generator from '@babel/generator';
// ...同上
console.log(generator(ast));

最终我们得到了转化后的代码结果:

{ 
  code: 'function square(n) {\n  return Math.pow("n", 2);\n}',
  map: null,
  rawMappings: null
}

插件构造

我们先来看来定义一个插件基本结构:

// plugins/hello.js
export default function(babel) {
  return {
    visitor: {}
  };
}

然后我们在配置文件中可以按以下方式进行简单引用:

// babel.config.js
module.exports = { plugins: ['./plugins/hello.js'] };

visitor

在插件中,有个 visitor 对象,它代表访问者模式,Babel 内部是通过上面提到的 @babel/traverse 进行遍历节点,我们可以通过指定节点类型进行访问 AST:

module.exports = function(babel) {
  return {
    visitor: {
      Identifier(path) {
        console.log('visiting:', path.node.name)
      }
    }
  };
};

这样当进行编译 n * n 时,就能看到两次输出。visitor 也提供针对节点的 enter 与exit 访问方式,让我们改写下程序:

    visitor: {
      Identifier: {
        enter(path) {
          console.log('enter:', path.node.name);
        },
        exit(path) {
          console.log('exit:', path.node.name);
        }
      }
    }

这样一来,再编译刚才的程序,就有了 4 次打印,visitor 是按照 AST 的自上到下进行深度优先遍历,进入节点时会访问节点一次,退出节点时也会访问一次。让我们写一段代码来测试一下 traverse 的访问顺序:

import * as parser from '@babel/parser';
import traverse from '@babel/traverse';

function square(n) {
  return n * n;
}
const ast = parser.parse(square.toString());

traverse(ast, {
  enter(path) {
    console.log('enter:', path.node.type, path.node.name || '');
  },
  exit(path) {
    console.log('exit:', path.node.type, path.node.name || '');
  }
});

打印结果:

enter: Program
enter: FunctionDeclaration
enter: Identifier square
exit: Identifier square
enter: Identifier n
exit: Identifier n
enter: BlockStatement
enter: ReturnStatement
enter: BinaryExpression
enter: Identifier n
exit: Identifier n
enter: Identifier n
exit: Identifier n
exit: BinaryExpression
exit: ReturnStatement
exit: BlockStatement
exit: FunctionDeclaration
exit: Program

path

path 作为节点访问的第一个参数,它表示节点的访问路径,基础结构是这样的:

{
  "parent": {
    "type": "FunctionDeclaration",
    "id": {...},
    ....
  },
  "node": {
    "type": "Identifier",
    "name": "..."
  }
}

其中 node 代表当前节点,parent 代表父节点,同时 path 还包含一些 node 元信息和操作节点的一些方法:

  • findParent  向父节点搜寻节点
  • getSibling 获取兄弟节点
  • replaceWith  用AST节点替换该节点
  • replaceWithMultiple 用多个AST节点替换该节点
  • insertBefore  在节点前插入节点
  • insertAfter 在节点后插入节点
  • remove   删除节点

路径是一个节点在树中的位置以及关于该节点各种信息的响应式 Reactive 表示。 当你调用一个修改树的方法后,路径信息也会被更新。 Babel 帮你管理这一切,从而使得节点操作简单,尽可能做到无状态。

opts

在使用插件时,用户可传人 babel 插件配置信息,插件再根据不同配置来处理代码,首先,在引入插件时,修改为数组引入方式,数组中第一个对象为路径,第二个元素为配置项 opts:

module.exports = {
  presets,
  plugins: [
    [
      './src/plugins/xxx.js',
      {
        op1: true
      }
    ]
  ]
};

在插件中,可通过 state 进行访问:

module.exports = function(babel) {
  return {
    visitor: {
      Identifier: {
        enter(_, state) {
          console.log(state.opts)
          // { op1: true }
        }
      }
    }
  };
};

nodes

当在编写 Babel 插件时,我们时常需要对 AST 节点进行插入或修改操作,这时可以使用 @babel/types 提供的内置函数进行构造节点,以下两种方式等效:

import * as t from '@babel/types';
module.exports = function({ types: t }) {}

构建 Node 的函数名通常与 type 相符,除了首字母小写,比如构建一个 MemberExpression 对象就使用 t.memberExpression(...) 方法,其中构造参数取决于节点的定义。

Babel 插件实践

上面列举了一些 Babel 插件基本的用法,最重要的还是在于在代码工程中进行实践,想象一下哪些场景我们可以通过编写 Babel 插件来解决实际问题,然后 Just Do It。

一个最简单的插件实例

为了抛砖引玉,我们来举一个最简单的示例。在代码调试过程中,我们常常使用到 Debugger 这个语句,便于进行函数运行时调试,我们希望通过使用 Babel 插件,当在开发环境时打印当前 Debugger 节点的位置,便于提醒我们,而在生产环境直接将节点删除。

为了实现这样的插件,首先通过 ASTExplorer 找到 Debugger 的 Node type 为 DebuggerStatement,我们需要使用这个节点访问器,再通过 NODE_ENV 判断运行环境,若为 production 则调用 path.remove方法,否则打印堆栈信息。

首先,创建一个名为 babel-plugin-drop-debugger.js 的插件,并编写代码:

module.exports = function() {
  return {
    name: 'drop-debugger',
    visitor: {
      DebuggerStatement(path, state) {
        if (process.env.NODE_ENV === 'production') {
          path.remove();
          return;
        }
        const {
          start: { line, column }
        } = path.node.loc;
        console.log(
          `Debugger exists in file: ${
            state.filename
          }, at line ${line}, column: ${column}`
        );
      }
    }
  };
};

然后在 babel.config.js 中引用插件:

module.exports = {
  plugins: ['./babel-plugin-drop-debugger.js']
};

再创建一个测试文件 test-plugin.js :

function square(n) {
  debugger;
  return () => 2 * n;
}

当我们执行: npx babel test-plugin.js 时打印:

Debugger exists in file: /Users/xxx/test-plugin.js, at line 2, column: 2

若执行: NODE_ENV=production npx babel test-plugin.js 时打印:

function square(n) {
  return () => 2 * n;
}

总结

目前在工程中还没遇到需要 Babel 解决问题的场景,因此就先不再继续深入了,希望之后能进行补充。在这篇文章中我们对 Babel 插件有了一个基本的印象,若要了解 Babel 插件的基本使用方式请访问用户手册

Babel 主要由三部分组成:编译(parse)、转换(transform)、生成(generate),插件机制不开以下几个核心库:

  • @babel/parser ,Babel AST 解析器,原名为 babylon,由 acorn 改造而来
  • @babel/traverse ,对 AST Node 进行遍历与更新
  • @babel/generator ,根据 AST 与相关选项重新构建代码
  • @babel/types ,判断 AST 节点类型与构造新的节点

以下为一些实用的开发辅助:

  • 在线快速调试 AST 与 插件: ASTExplorer 
  • 查看所有 AST 节点类型的定义:AST spec
  • AST 规范: ESTree 

Babel 官方唯一的参考资料就是 Handbook,感觉官方 Github 库的 API 文档和 Doc 十分欠缺,若要进行插件开发的探索只能查看源代码和看看官方或第三方的插件实现,否则连构造参数都找不到。

参考