其他分享
首页 > 其他分享> > 从0到1手写babel插件

从0到1手写babel插件

作者:互联网

概要

当我们对babel工作原理有了较为深入的了解后,我们就可以根据日常的业务场景开发一些实用的babel插件用于优化我们的业务代码,使我们打包后的代码更加小巧快速。这篇文章主要介绍如何实现babel插件的开发,从0到1手摸手,成为大佬不是梦。

餐前准备

一顿好的饭菜不仅需要高超的技艺,还需要必要的基础烹饪知识和得心应手的工具等。因此在开发babel插件之前,很有必要了解一些插件开发基础知识,先让我们上几道前菜解解馋。

Babel 很有用的那些库

Babel7 用了 npm 的 private scope,把全部的包都挂在在 @babel 下,所以17年文档中有些库名已经变了,简单介绍一下比较重要的几个库:

Babel 工作流程

  1. 步骤一:解析
    使用 @babel/parser 解析器进行语法解析,获得 AST。

  2. 步骤二:转换
    使用 @babel/traverse 对 AST 进行深度遍历,处理各种 AST 节点。
    遍历过程中,能对每一种节点进行处理,这里可以使用到  @babel/types 对节点进行增删查改,或者也可以使用 @babel/template 来生成大量 AST 进行修改。

  3. 步骤三:生成
    使用 @babel/generator 将处理后的 AST 转换回正常代码。

一句话描述:input string -> @babel/parser parser -> AST -> @babel/traverse transformer[s] -> AST -> @babel/generator -> output string

Babel 插件开发三大问题

前菜吃的差不多了,现在想想我们开发一个插件还需要解决那些问题,善于提问题的人才有可能去解决问题,因此我们总结出了如下三大哲学问题:

带着这些问题,我们开始了漫长的道路探索。

如何访问到需要处理的 AST 节点

首先我们看看如何访问到需要处理 AST 节点。首先我们要处理的节点一般来说是有某种特种的某种类型的节点,比如我要找到所有的 console.log() ,那么我们首先会发现这一定是一个函数调用(CallExpression),所以我们首先要找到 CallExpression 的 AST 节点。
babel 已经为我们处理好了 如何找到不同类型节点 这一步。在上一段我们知道,babel 工作主要经过了三个流程,而我们的插件则是在 转换 这一步被调用的。而根据 babel 插件文档,我们的插件本质上是返回一个符合 babel 插件规范的对象,其中最核心的是对象中的 visitor 属性。
babel 在使用 @babel/traverse 对 AST 进行深度遍历时,会 访问 每个 AST 节点,这个便是跟我们的 visitor 有关了(这个名字来自 访问者模式(visitor))。babel 会在 访问 AST 节点的时候,调用 visitor  中对应节点类型的方法,这便是 babel 插件暴露给开发者的核心。

visitor = {
  CallExpression() {}, // 当节点是一个函数调用表达式时
  MemberExpression() {}, // 当节点是一个成员表达式时,如 foo.bar
  FunctionDeclaration() {}, // 当节点是一个函数声明时
}

对于 babel 暴露了哪些类型供开发者处理,请参考 @babel/types文档

如何获取到 AST 节点

由此,插件开发者便可以针对不同类型的 AST 节点编写代码了。但是我们发现,babel 只是调用了我们处理不同类型节点的方法,所以下一步,就是如何获取 AST 节点了。当我们如上文访问一个函数调用表达式时,babel 会向我们的方法传递一些参数: CallExpression(path, state) {} ,其中 path 对象便是我们获取 AST 节点的入口。我们看看 path 对象里都有啥:

{
  "parent": {},
  "node": {},
  "hub": {},
  "contexts": [],
  "data": {},
  "_traverseFlags": 0,
  "skipKeys": null,
  "state": {},
  "opts": {},
  "parentPath": {},
  "context": {},
  "container": {},
  "listKey": [],
  "key": "expression",
  "scope": {},
  "type": "CallExpression",
  ...
}

其中比较重要的有:

通过 path.node ,我们便拿到了一个 AST 节点。

如何操作 AST 节点

下一步,就是针对 AST 节点进行增删改查。babel 最后仍然会使用根节点的 AST 树重新生成代码,同时由于 JavaScript 对于对象是地址引用,因此我们只要操作这个 node 对象即可,不需要额外有其他返回操作,babel 会使用原来的引用。
但是直接对 node 对象(也就是 AST 节点)进行操作成本不低,特别是需要构造出一些比较复杂的 AST 节点对原节点 进行 插入(增)替换(改)等操作的时候。所以 @babel/types 也贴心的提供了诸多构造出一个新的 AST 节点的方法,比如:

这样我们便实现了对 AST 节点的增删改查。如果你的 AST 极其复杂,可以考虑使用上文提到的 @babel/template 来将一段字符串转化为 AST 节点,从而不必再从一个一个最原始的节点开始构造

开始做饭了--插件开发

先看看我们今天要做一道什么菜,初级选手就来个西红柿炒鸡蛋吧。
假设今天我们要开发一个插件,用来把 ** 运算符 转为 Math.pow() (毕竟 幂运算法 在 ES6 出现的,所以这个需求也是合理的)。

理清思路

首先我们分析一下需求,可以发现有两种情况需要处理:

  1. a ** b => Math.pow(a, b)
  2. a **= b => var _a = a; a = Math.pow(_a, b)

注意, _a 只是一个例子,我们需要的是作用域内不存在的一个变量名,不然就会炸,可以使用这个来处理:babel-helper-explode-assignable-expression

首先我们进入 AST Explorer 看看两种情况的 AST :

如图,a ** b 被认为是一个 BinaryExpression (二元表达式)。而 a **= b 被认为是一个 AssignmentExpression (赋值表达式)。
那我们再看看 var _a = a; a = Math.pow(_a, b) 又是什么样的呢?

观察发现,var _a = a 是一个 VariableDeclaration (变量申明),a = Math.pow(_a, b) 则是一个 AssignmentExpression (赋值表达式),其右是一个 CallExpression (调用表达式),同时调用表达式的调用方是一个 MemberExpression (成员表达式)。
到此,我们的思路清晰了起来:

访问到 BinaryExpression 时,看它的操作符是不是 ** 
如果是的话,将整个表达式替换为 一个函数调用(Math.pow(a, b)

访问到 AssignmentExpression 时,看它的操作符是不是 **= 
如果是的话,将整个表达式替换为 一个变量声明(var _a = a) + 一个函数调用&赋值(a = Math.pow(_a, b)

开始开发插件

首先我们装一下依赖

npm i @babel/cli @babel/core @babel/helper-explode-assignable-expression @babel/types -S

然后在 src/index.js 中开始编写代码:

const t = require("@babel/types");

const operator = '**';

module.exports = function() {
  return {
    name: 'babel-plugin-exponentiation-operator',
    visitor: {
      AssignmentExpression(path) {
        const { node, scope } = path;
        // 只处理 **=
        if (node.operator === `${operator}=`) {
        	// 修改 AST
        }
      },

      BinaryExpression(path) {
        const { node } = path;
        if (node.operator === operator) {
          // 修改 AST
        }
      },
    }
  };
}

首先我们按照刚刚的分析,我们需要处理的是 AssignmentExpression 和 BinaryExpression ,于是在插件 visitor 属性中加入这两种节点类型的处理方法,并且排除掉不是我们需要处理的运算符。
第二步,就是构造出我们需要的 callExpression , memberExpressionassignmentExpression 等并把原来的 AST 替换掉了

如何构造 Math.pow(a, b) 
首先 Math.pow(a, b) 整体是一个函数表达式,查文档可以得到其构造需要的参数: t.callExpression(callee, arguments) 
其次 Math.pow 部分又是一个成员表达式,查文档可以得到其构造需要的参数: t.memberExpression(object, property, computed, optional)
最后,如何构造出 Mathpow 这两个变量名呢?只需要使用 identifier(name) 就可以啦

所以最终我们构造出来的代码如下,其中 leftright 为 Math.pow(a, b) 中 a, b 两个参数

const mathPowExpression = t.callExpression(
  t.memberExpression(t.identifier("Math"), t.identifier("pow")),
  [left, right],
);

如何构造 _a = Math.pow(a, b)
这里比上一步多了一个 赋值(Assignment) 操作,通过查文档我们可以找到如何使用 AssignmentExpression :t.assignmentExpression(operator, left, right)

const mathPowExpression = t.callExpression(
  t.memberExpression(t.identifier("Math"), t.identifier("pow")),
  [left, right],
);
t.assignmentExpression(
  "=",
  identifier("_a"),
  mathPowExpression,
),

如何替换 AST 节点
path.replaceWith : 接受一个 AST 节点对象
path.replaceWithMultiple :接受一个 AST 节点对象数组
path.replaceWithSourceString : 接受一串字符串

因此我们替换节点只需要如下写法即可

const mathPowExpression = t.callExpression(
  t.memberExpression(t.identifier("Math"), t.identifier("pow")),
  [left, right],
);
path.replaceWith(mathPowExpression);

如何把 a 变成 _a (如何找到一个当前作用域唯一的变量名)
使用官方插件 babel-helper-explode-assignable-expression
调用方式:explode(node, nodes, file, scope);
参数说明:

其不但会返回新的变量节点(uid)和旧的变量节点(ref),还会将变量赋值给塞到入参的 nodes
所以我们在这里要获得一个新的变量名,只需要:

// 找一个不会炸掉的变量名
const exploded = explode(node.left, nodes, this, scope);

完整的代码如下:

// src/index.js
const explode = require("@babel/helper-explode-assignable-expression").default;
const t = require("@babel/types");

const operator = '**';
const getMathPowExpression = (left, right) => {
  return t.callExpression(
    t.memberExpression(t.identifier("Math"), t.identifier("pow")),
    [left, right],
  );
}

module.exports = function() {
  return {
    name: 'babel-plugin-exponentiation-operator',
    visitor: {
      AssignmentExpression(path) {
        const { node, scope } = path;
        // 只处理 **=
        if (node.operator === `${operator}=`) {
          const nodes = [];
          // 找一个不会炸掉的变量名
          const exploded = explode(node.left, nodes, this, scope);
          nodes.push(
            t.assignmentExpression(
              "=",
              exploded.ref,
              getMathPowExpression(exploded.uid, node.right),
            ),
          );
          path.replaceWithMultiple(nodes);
        }
      },

      BinaryExpression(path) {
        const { node } = path;
        if (node.operator === operator) {
          path.replaceWith(getMathPowExpression(node.left, node.right));
        }
      },
    }
  };
}

菜肴品鉴--插件测试

简单自测

如果需要简单自测,则只需要在 .babelrc 中配置上插件的本地路径即可:

{
    "plugins": [["./src/index.js"]]
}

然后

npx babel test/index.js

使用 babel-plugin-tester 进行测试

编写完 babel 插件后,我们虽然简单进行了测试,但是对于复杂一些的插件来说,我们需要对其有更加完善的单元测试并尽可能覆盖多的情况。
这个测试工具需要和 jest 一同使用,本质上是简化使用 jest 对 babel-plugin 的测试成本。
首先我们按照文档进行安装(同时还要安装一下 jest)

npm install --save-dev babel-plugin-tester jest

由于你可能有大量的 case 需要验证,为了简化测试代码的编写,建议使用 jest Snapshot(快照) 的形式配合 babel-plugin-tester 的 fixtures 来进行测试。snapshot 通常用来测试 UI ,Ant-design 便是使用这种方法进行测试的。简单来说就是测试前有一份基准快照,每次运行测试用例的时候,便是将测试输出结果和原来的基准快照进行对比,看结果是不是一致。对于我们的 babel-plugin 测试来说,这一点非常适合,因为我们主要是对比文件转换后是否符合预期。甚至只要先编写了测试输入和预期的测试结果,我们可以用 TDD 的方式进行开发。

fixtures 目录结构:

.__fixtures__
├── first-test # test title will be: "first test"
│   ├── code.js # required
│   └── output.js # required
└── second-test
    ├── .babelrc # optional
    ├── options.json # optional
    ├── code.js
    └── output.js

测试入口代码编写:(注意,jest 只会扫描 xx.test.js 和 xx.spec.js 文件作为测试文件,所以注意文件命名)

// import pluginTester from "babel-plugin-tester";
const pluginTester = require('babel-plugin-tester').default
const myPlugin = require("../src/index")
const path = require("path")

pluginTester({
  plugin: myPlugin,
  pluginName: 'myPlugin',
  title: 'describe block title',
  pluginOptions: {
    optionA: true
  },
  snapshot: true,
  fixtures: path.join(__dirname, '__fixtures__')
});

最后,在 package.json 中加入以下代码方便测试:

"scripts": {
    "compiler": "babel test/index.js --out-dir lib --watch",
    "test": "jest --verbose --watchAll"
  },

文章引用

从0到1开发并测试Babel插件&Babel简易源码分析

标签:node,插件,AST,babel,path,手写,节点
来源: https://www.cnblogs.com/smart-elwin/p/15861701.html