初探 Babel 插件开发

3,297 阅读7分钟

本篇文章分享来自小伙伴「huanxing」的一次学习总结分享,希望跟社区的同学一起探讨。

Babel api 介绍

之前介绍了 babel 的编译流程和 AST,我们就大概知道了 babel 做了什么。但是还需要了解下 babel 的 api,然后通过这些 api 来操作 AST,完成代码的转换。

我们知道 babel 的编译流程分为三步:parse、transform、generate,每一步都暴露了一些 api 出来。

  • parse 阶段有@babel/parser,功能是把源码转成 AST
  • transform 阶段有 @babel/traverse,可以遍历 AST,并调用 visitor 函数修改 AST,修改 AST 自然涉及到 AST 的判断、创建、修改等,这时候就需要 @babel/types 了,当需要批量创建 AST 的时候可以使用 @babel/template 来简化 AST 创建逻辑。
  • generate 阶段会把 AST 打印为目标代码字符串,同时生成 sourcemap,需要 @babel/generator 包,中途遇到错误想打印代码位置的时候,可以使用 @babel/code-frame 包
  • babel 的整体功能通过 @babel/core 提供,基于上面的包完成 babel 整体的编译流程,并应用 plugin 和 preset。

这些包的 api 都可以在文档里查看。

@babel/parser

babel parser 默认只能 parse js 代码,jsx、flow、typescript 这些非标准的语法的解析需要指定语法插件。

它提供了有两个 api:parse 和 parseExpression。两者都是把源码转成 AST,不过 parse 返回的 AST 根节点是 File(整个 AST),parseExpression 返回的 AST 根节点是是 Expression(表达式的 AST),粒度不同。

function parse(input: string, options?: ParserOptions): File
function parseExpression(input: string, options?: ParserOptions): Expression
require('@babel/parser').parse(source, {

sourceType: 'module',

plugins: ['jsx', 'flow', 'classProperties', 'decorator', 'decorators-legacy'],

});

plugins: 指定jsx、typescript、flow 等插件来解析对应的语法。

sourceType: module、script、unambiguous 3个取值,module 是解析 es module 语法。通常一般默认unambiguous,可以根据内容是否有 import 和 export 来自动设置 module 还是 script。

@babel/traverse

parse 出的 AST 由 @babel/traverse 来遍历和修改,babel traverse 包提供了 traverse 方法

require("@babel/traverse").default(parent, opts)

常用的就前面两个参数,parent 指定要遍历的 AST 节点,opts 指定 visitor 函数。babel 会在遍历 parent 对应的 AST 时调用相应的 visitor 函数。

visitor 是指定对什么 AST 做什么处理的函数,babel 会在遍历到对应的 AST 时回调它们。而且可以指定刚开始遍历(enter)和遍历结束后(exit)两个阶段的回调函数,

require("@babel/traverse").default(ast, {
  /** - 1.进入节点时调用(一般不用) */
  enter(path) {
    console.log('__enter__');
  },
  /** - 2.离开节点时调用(一般不用) */
  exit(path) {
    console.log('__exit__');
  },
  /** - 3.当遍历到指定节点类型时调用,比如这里是:FunctionDeclaration(函数声明)(建议方案) */
  FunctionDeclaration(path) {
    console.log('__FunctionDeclaration__');
  },
  /** - 4.你可以单独监听某个节点类型的进入或者离开 */
  FunctionDeclaration: {
    enter(path) {
      console.log('__FunctionDeclaration_enter_');
    },
    exit(path) {
      console.log('__FunctionDeclaration_exit_');
    },
  },
  /** - 5.当遍历到 FunctionDeclaration|ReturnStatement 节点时调用(这种方式会覆盖前面几种方式) */
  ['FunctionDeclaration|ReturnStatement'](path) {
    console.log('__FunctionDeclaration|ReturnStatement');
  },
});

具体的类型有哪些可以在babel-types 的类型定义中查。

path

每个 visitor 都有 path 和 state 的参数,path 记录了 AST 在遍历过程中的路径。

我们可以通过 path 对象表示节点之间的关联关系。通过这个对象提供的属性方法,我们可以操作 AST 语法树。

// 属性
path.state // Babel 插件信息,可通过 state.opts 获取传入的 Options;
path.node // 当前遍历到的 node 节点,可通过它访问节点上的属性,对于 Ast 节点;
path.parent // 父级 node,无法进行替换;
path.parentPath // 父级 path,可进行替换;
path.scope // 作用域相关,可用于变量重命名,变量绑定关系检测等;
path.key // 获取路径所在容器的索引
path.listKey // 获取容器的 key
path.container // 获取路径的容器(包含所有同级节点的数组)
path.inList // 判断路径是否有同级节点

// 方法
path.toString() // 当前路径所对应的源代码;
path.isXXX // XXX为节点类型,可以判断是否符合节点类型。比如我们需要判断路径是否为 StringLiteral 类型 → path.isStringLiteral;
path.get(key) // 获取子节点 path,例如:path.get('body.0') 可以理解为 path.node.body[0]这样的形式,让我们更加方便的拿到子路径,但是注意仅路径可以这样操作,访问属性是不允许的!
path.set(key) // 设置子节点 path;
path.remove() // 删除 path;
path.replaceWith() // 用AST节点替换该节点,如:path.replaceWith({ type: 'NumericLiteral', value: 3 }),创建节点可以使用 @babel/types,如果是多路径则使用 relaceWithMultiple([AST...]);
path.replaceWidthSourceString() // 用字符串替换源码
path.find((path) => path.isObjectExpression()) // 向下搜寻节点
path.findParent() // 向父节点搜寻节点
path.getSibling()、path.getNextSibling()、path.getPrevSibling() // 获取兄弟路径;
path.getFunctionParent() // 向上获取最近的 Function 类型节点;
path.getStatementParent() // 向上获取最近的 Statement 类型节点;
path.insertBefore() // 在之前插入兄弟节点
path.insertAfter() // 在之后插入兄弟节点
path.pushContainer() // 将AST push到节点属性里面
path.traverse() // 递归的形式消除全局状态(官网 >> 例子已经不错了)
path.stop() // 停止遍历
path.skip() // 不往下遍历,跳过该节点

state

第二个参数 state 则是遍历过程中在不同节点之间传递数据的机制,插件会通过 state 传递 options 和 file 信息,我们也可以通过 state 存储一些遍历过程中的共享数据。

@babel/types

遍历 AST 的过程中需要创建一些 AST 和判断 AST 的类型,这时候就需要 @babel/types 包。

举例来说,如果要创建IfStatement就可以调用

t.ifStatement(test, consequent, alternate); 

而判断节点是否是 IfStatement 就可以调用 isIfStatement 或者 assertIfStatement

t.isIfStatement(node, opts); t.assertIfStatement(node, opts); 

opts 可以指定一些属性是什么值,增加更多限制条件,做更精确的判断。

t.isIdentifier(node, { name: "paths" }) 

isXxx 和 assertXxx 看起来很像,但是功能不大一样:isXxx 会返回 boolean,而 assertXxx 则会在类型不一致时抛异常

@babel/generator

当我们对AST进行遍历操作之后,就可以通过 @babel/generator 将 AST 生成目标代码了,具体使用如下:

const generator = require('@babel/generator');

// ...

// → 将AST输出为目标代码
const code = generator.default(ast).code;
console.log(code);

@babel/template

通过 @babel/types 创建 AST 还是比较麻烦的,要一个个的创建然后组装,如果 AST 节点比较多的话需要写很多代码,这时候就可以使用 @babel/template 包来批量创建。 如:

const ast = template(code, [opts])(args);
const ast = template.ast(code, [opts]); // 返回的是整个 AST
const ast = template.program(code, [opts]); // 返回的是 Program 根节点。
const ast = template.expression(code, [opts]); // 返回创建的 expression 的 AST。
const ast = template.statements(code, [opts]) // 返回创建的 statems 数组的 AST。

模版也支持占位符,可以在模版里设置一些占位符,调用时再传入这些占位符参数对应的 AST 节点。

const fn = template(`console.log(NAME)`);
// const fn = template(`console.log(%%NAME%%)`);

const ast = fn({
  NAME: t.stringLiteral("guang"),
});

加不加 %% 都行,当占位符和其他变量名冲突时可以加上。

@babel/core

@babel/core 包基于前面的包完成整个编译流程,从源码到目标代码,生成 sourcemap,其语法形式如下:

// → 同步方法
transformSync(code, options); // => { code, map, ast }
transformFileSync(filename, options); // => { code, map, ast }
transformFromAstSync(parsedAst, sourceCode, options); // => { code, map, ast }
// → 异步方法
transformAsync('code();', options).then((result) => {});
transformFileAsync('filename.js', options).then((result) => {});
transformFromAstAsync(parsedAst, sourceCode, options).then((result) => {});

插件开发实践

知道了 babel 的编译流程、AST、api 之后,我们已经可以做一些有趣的事情了。比如我们在开发的时候经常会使用 console 来调试代码,为了让打印信息更清晰,我们可以在 console.log 等 api 中插入文件名和行列号的参数,方便定位到代码。如:

console.log(1); -> console.log('文件名(行号,列号):', 1);

我们可以先看下 console.log(1) 的 AST 结构 image.png 函数调用表达式的 AST 是 CallExpression。

那我们要做的是在遍历 AST 的时候对 console.log、console.info 等 api 自动插入一些参数,也就是要通过 visitor 指定对 CallExpression 的 AST 做一些修改。

CallExrpession 节点有两个属性,callee 和 arguments,分别对应调用的函数名和参数, 所以我们要判断当 callee 是 console.xx 时,在 arguments 的数组中插入一个 AST 节点。如:

const parser = require('@babel/parser');
const generate = require('@babel/generator').default;
const traverse = require('@babel/traverse').default;
const t = require('@babel/types')
const path = require("path")

/**
 * 给console插入行列序号
 */
const sourceCode = `function func() {
    console.info(2);
}`

const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous'
})
const stringCode = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`)
const fileName = path.basename(__dirname)
traverse(ast, {
    CallExpression(path, state){
        if(t.isMemberExpression(path.node.callee) && path.node.callee.object.name === 'console' && ['log', 'info', 'error', 'debug'].includes(path.node.callee.property.name)) {
            const {line, column} = path.node.loc.start 
            path.node.arguments.unshift(t.stringLiteral(`${fileName}: (${line},${column})`))
        }
    }
})

const {code} = generate(ast)

console.log(code)
// 输出结果为:
// function func() {
//   console.info("fileName: (2,4)", 2);
// }

虽然结果是符合预期的,但是我们可以发现 if 的判断条件过于复杂,我们可以优化一下。可以通过path.toString() 把 callee 的 AST 转换成代码字符串再进行判断,如:

traverse(ast, {
    CallExpression(path, state){
        if (stringCode.includes(path.get('callee').toString())) {
            const {line, column} = path.node.loc.start 
            path.node.arguments.unshift(t.stringLiteral(`${fileName}: (${line},${column})`))
        }
    }
})

如果我们想在 console 之前打印行列号,如:

console.log(1);

// 转换成
console.log('文件名(行号,列号):');
console.log(1);

基本逻辑是差不多的,不过这里需要插入 AST,会用到 path.insertBefore 的 api。而且要跳过新的节点的处理,就需要在节点上加一个标记,如果有这个标记的就跳过。如:

const parser = require('@babel/parser');
const generate = require('@babel/generator').default;
const traverse = require('@babel/traverse').default;
const t = require('@babel/types')
const template = require("@babel/template").default;
const path = require("path")

/**
 * 给console插入行列序号
 */
const sourceCode = `function func() {
    console.info(2);
}`

const ast = parser.parse(sourceCode, {
    sourceType: 'unambiguous'
})
const stringCode = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`)
const fileName = path.basename(__dirname)
traverse(ast, {
    CallExpression(path, state){
        if (path.node.isNew) {
            return
        }
        if (stringCode.includes(path.get('callee').toString())) {
            const {line, column} = path.node.loc.start 
            const newAst = template.expression(`console.log("${fileName}: (${line},${column}")`)()
            newAst.isNew = true
            path.insertBefore(newAst)
        }
    }
})

const {code} = generate(ast)

console.log(code)

// 输出结果
// function func() {
//   console.log("babel-1: (2,4")
//   console.info(2);
// }

封装成 Babel 插件

babel 插件的形式就是函数返回一个对象,对象有 visitor 属性。

函数的第一个参数可以拿到 types、template 等常用包的 api,这样我们就不需要单独引入这些包了。 而且作为插件用的时候,并不需要自己调用 parse、traverse、generate,这些都是通用流程,babel 会做,我们只需要提供一个 visitor 函数,在这个函数内完成转换功能就行了。

函数的第二个参数 state 中可以拿到插件的配置信息 options 等,比如 filename 就可以通过 state.filename 来取。

const stringCode = ['log', 'info', 'error', 'debug'].map(item => `console.${item}`)

module.exports = function({types, template}) {
    return {
        name: 'parameters-insert-plugin',
        visitor: {
            CallExpression(path, state) {
                if (path.node.isNew) {
                    return;
                }

                if (stringCode.includes(path.get('callee').toString())) {
                    const {line, column} = path.node.loc.start 
                    const newAst = template.expression(`console.log("${state.filename}: (${line},${column}")`)()
                    newAst.isNew = true
                    path.insertBefore(newAst)
                }
            }
        }
    }
}

然后通过 @babel/core 的 transformSync 方法来编译代码,并引入上面的插件:

const { transformFileSync } = require('@babel/core');
const insertParametersPlugin = require('./plugin/parameters-insert-plugin');
const path = require('path');

const { code } = transformFileSync(path.join(__dirname, './sourceCode.js'), {
    plugins: [insertParametersPlugin],
    parserOpts: {
        sourceType: 'unambiguous'    
    }
});

console.log(code);

这样我们就成功的把前面调用 parse、traverse、generate 的代码改造成了 babel 插件的形式。

参考

  1. juejin.cn/post/699213…
  2. blog.csdn.net/lunahaijiao…