编写一个babel插件对前端来说有多难?

274 阅读6分钟

第一次在掘金发文,主要是想看看社会是好人多还是坏人多~

一、前置知识

在编写一个babel插件前,我们至少需要先大概了解一下babel的工作流程,才能知道编写的组件具体在哪个步骤生效或者会产生哪些副作用。

1、Babel原理

Babel在对项目代码进行转换为目标代码时,主要经历这些步骤:解析——>转换——>生成

  • 解析:主要是用babel内置的AST解析器Babylon,将我们项目的代码转换成一棵抽象语法树(也就是AST),然后对树中的节点进行深度遍历完成整个抽象语法树的生成;
  • 转换:可以类比VueReact中的virtual dom的替换,AST本身也是树状结构,babel就是根据目标代码的规则,对树中的每一个节点进行转换,整个过程就涉及到对树的访问了,我们的插件也就是在这个访问过程中生效;
  • 生成:当所有的节点都访问完之后,意味着遍历也结束了,整个AST已经符合目标代码的规则,那么就可以使用babel-generatorAST转回成代码的样子返回;

2、插件工作流程

从上面的 转换 流程可知,整个AST的遍历是深度遍历的,那么我们的插件作为访问者,必然会有类似栈的行为——后进先出

打个比方,有以下代码:

function A(){
    const result = {
    	name:"function A",
    	fnA(){return "A"}
    	};
    return result
}

那么这串代码在经过Babylon的转换后,会变成这样的树结构:

{
// 函数类型的节点
    FunctionDeclaration:{
        type:"FunctionDeclaration",
        start:0,
        end:91
        loc:[...],
        id:{
            type:"Identifier",
            ...
            name:"A"
        },
        ...
        body:{
        // 变量声明类型节点
            VariableDeclaration:{
                type:"VariableDeclaration",
                start:18,
                ...
                id:{
                    type:"Identifier",
                    ...,
                    name:"result
                },
                init:{
                // 对象表达式类型节点
                    ObjectExpression:{
                        type:"ObjectExpression",
                        start:33,
                        end:70
                        ...
                        properties:{...}
                    }
                }
            }
            ...
            // 返回语句类型节点
            ReturnStatement:{
                type:"ReturnStatement",
                ...
                argument:{
                    type:"Identifier",
                    ...,
                    name:"result"
                }
            }
        }
    }
}

从这个AST结构上看,可以知道,整个访问的流程是:

function A --> result --> Object --> name && fnA --> result --> return --> functionA

也就是说,在整个转换过程中,我们的babel插件作为访问者,会有进入--离开某个节点操作,而之所以会存在这样的操作,我个人理解是主要便于开发者编写一些有上下文关联串行的编译逻辑,比方说我在函数的入口删除了某个变量节点,那么我可以在离开的时候在外层做一些判断逻辑,看看有没有与整个变量节点相关的逻辑需要一并删除或修改。

而对于简单的逻辑,其实我们只需要编写一次即可,不需要关心是进入还是离开的环节。

二、开始编写

1、编写前准备:

需要声明的是,本人在编写babel插件时,默认使用的是typescript,并且是作为单独的npm包编写的,其中用到了如下依赖:

  • rollup
  • eslint
  • @babel/types

至于如何配置rollup打包,不是本次分享重点,请自行上网查询相关教程,图省事的也可以使用webpack

2、babel插件规范

babel的插件命名其实是需要遵循一定规范的,对于官方来说,从babel7.x开始,很多插件都已经命名为@babel/plugin-xxx了,当时我试过做类似的命名去上传npm包,但命名空间基本被官方拿下了,目前我们可以通过老方式,用babel-plugin-xxx的规则来命名

3、babel插件的配置

关于babel插件的配置,相信用过Element等UI框架的同学都应该配置过按需引入的功能,就像这样:

{
  "presets": [["es2015", { "modules": false }]],
  "plugins": [
    [
      "component",
      {
        "libraryName": "element-ui",
        "styleLibraryName": "theme-chalk"
      }
    ]
  ]
}

.babelrc或者在package.json,又或者是在webpackbabel-loader中,我们都可以用plugins这个数组来配置插件,plugins数组中每个插件也是用数组来包裹,数组的第一个元素是插件名称,第二个对象就是这个插件可以接受的配置了(这个后面会提到,请记住)

4、编写插件

假设你已经初始化了一个npm包,并且入口文件就是main.js(经过rollup打包ts后的输出文件),那么在入口文件中,我们需要直接导出一个函数,并且函数始终返回一个包含visitor属性的对象(visitor就是访问者),然后我们的主要逻辑都在visitor这个属性中,并且visitor本身也是个对象。

假设我们现在有个需求,需要用babel来删除一些指定依赖的import导入语句,防止webpack把它打包进来增加体积,那么我们就需要在visitor中对import类型的节点进行访问,并判断是否我们配置里传入的指定依赖,然后进行节点的删除即可。

如果还有印象,小伙伴应该还记得刚刚在 babel插件的配置 中有提及,plugins中,每个数组都是一个插件,每个插件的第二个元素都是一个配置对象,那么我们先假设我们编写的插件叫babel-plugin-module-remove,并且可以做这样的配置:

{
  "presets": [["es2015", { "modules": false }]],
  "plugins": [
    [
      "babel-plugin-module-remove",
      {
        target:["echart","antd/form/lib"]
      }
    ]
  ]
}

那么整个配置其实就是一个对象,对象里有个target属性,是一个数组,数组里的元素,就是我们需要删除的目标。

对应到我们的插件逻辑,其实非常简单,假设在入口文件main.ts中(注意,是ts文件,还没经rollup打包的):

import { Visitor } from "@babel/core";
import { Identifier } from "@babel/types";
type Options = {target:Array<string>}
export default function(){
    // Visitor类型支持传入泛型,也就是我们的配置结构,这里的opts,对应是我们在babel插件中配置的{target:["echart","antd/form/lib"]}
    const visitor:Visitor<{opts:Options}> = {
        // ImportDeclaration代的是import类型节点,相应地,函数也有自己的类型节点,变量也有,类型多达十余种,整个就需要小伙伴们自己去查阅了。
        //每个节点函数都包含path参数和state参数,而我们的babel配置默认就是state里的opts属性,当然,state对象里还有很多其他的属性非常有用,这里就不一一介绍了
        ImportDeclaration(path,state){
            // 取出我们的配置target数组
            const {target:targetModules} = state.opts;
            // path参数则是一个辅助工具,它包含了上下节点的内容以及一些对节点的操作方法
            // source属性包含了当前节点的信息,节点的内容就是它的value
            const {source} = path.node;
            // 判断当前访问的import语句中的依赖,是不是我们target里指定的,如果符合就删除整个节点
            if(targetModules.includes(source.value)){
                path.remove();
            }
        },
    };
    //最后返回一个包含了访问器visitor的对象,大功告成
    return {
        visitor
    };
}

当然,以上是一个比较简陋的插件,具体真的要完成这种删除指定依赖的需求时,还需要判断这个节点是require还是import,是直接导出还是变量接收的require副本,情况相当复杂。以上插件能实现的效果是,当遇到下面的语句时,可以进行语句删除:

import "echart";
import {useForm} from "antd/form/lib"

以上就是对babel插件的简单分享了,有问题的欢迎指出一起学习~~