关于babel:保姆级教学这次一定学会开发babel插件

10次阅读

共计 11746 个字符,预计需要花费 30 分钟才能阅读完成。

如果你有 babel 相干常识根底倡议间接跳过 前置常识 局部,间接返回 “ 插件编写 ” 局部。

前置常识

什么是 AST

学习 babel, 必备常识就是了解 AST。

那什么是 AST 呢?

先来看下维基百科的解释:

在计算机科学中,形象语法树(Abstract Syntax Tree,AST),或简称语法树(Syntax tree),是源代码语法结构的一种形象示意。它以树状的模式体现编程语言的语法结构,树上的每个节点都示意源代码中的一种构造

源代码语法结构的一种形象示意” 这几个字要划重点,是咱们了解 AST 的要害,说人话就是依照某种约定好的标准,以树形的数据结构把咱们的代码形容进去,让 js 引擎和转译器可能了解。

举个例子:

就好比当初框架会利用 ` 虚构 dom` 这种形式把 ` 实在 dom` 构造形容进去再进行操作一样,而对于更底层的代码来说,AST 就是用来形容代码的好工具。

当然 AST 不是 JS 特有的,每个语言的代码都能转换成对应的 AST, 并且 AST 构造的标准也有很多,js 里所应用的标准大部分是 estree,当然这个只做简略理解即可。

AST 到底长啥样

理解了 AST 的基本概念,那 AST 到底长啥样呢?

astexplorer.net 这个网站能够在线生成 AST, 咱们能够在外面进行尝试生成 AST,用来学习一下构造

babel 的处理过程

问:把冰箱塞进大象有几个阶段?

关上冰箱 -> 塞进大象 -> 关上冰箱

babel 也是如此,babel 利用 AST 的形式对代码进行编译,首先天然是须要将代码变为 AST,再对 AST 进行解决,解决完当前呢再将 AST 转换回来

也就是如下的流程

code 转换为 AST -> 解决 AST -> AST 转换为 code

而后咱们再给它们一个业余一点的名字

解析 -> 转换 -> 生成

解析(parse)

通过 parser 把源码转成形象语法树(AST)

这个阶段的次要工作就是将 code 转为 AST, 其中会通过两个阶段,别离是词法剖析和语法分析。
当 parse 阶段开始时,首先会进行文档扫描,并在此期间进行词法剖析。那怎么了解此法剖析呢
如果把咱们所写的一段 code 比喻成句子,词法剖析所做的事件就是在拆分这个句子。
如同 “我正在吃饭” 这句话,能够被拆解为 “我”“正在”“吃饭” 一样, code 也是如此。
比方:

const a = '1'

会被拆解为一个个最细粒度的单词(tokon):

'const', 'a', '=', '1'

这就是词法分析阶段所做的事件。

词法剖析完结后,将剖析所失去的 tokens 交给语法分析,语法分析阶段的工作就是依据 tokens 生成 AST。它会对 tokens 进行遍历,最终依照特定的构造生成一个 tree 这个 tree 就是 AST。

如下图, 能够看到下面语句的到的构造,咱们找到了几个重要信息, 最外层是一个 VariableDeclaration 意思是变量申明,所应用的类型是 const, 字段 declarations 内还有一个 VariableDeclarator[变量申明符] 对象,找到了 a, 1 两个关键字。

除了这些关键字认为,还能够找到例如行号等等的重要信息,这里就不一一开展论述。总之,这就是咱们最终失去的 AST 模样。

那问题来了,babel 里该如何将 code 转为 AST 呢?
在这个阶段咱们会用到 babel 提供的解析器 @babel/parser,之前叫 Babylon,它并非由 babel 团队本人开发的,而是基于 fork 的 acorn 我的项目。

它为咱们提供了将 code 转换为 AST 的办法,根本用法如下:

更多信息能够拜访官网文档查看 @babel/parser

转换(transform)

在 parse 阶段后,咱们曾经胜利失去了 AST。babel 接管到 AST 后,会应用 @babel/traverse 对其进行深度优先遍历,插件会在这个阶段被触发,以 vistor 函数的模式拜访每种不同类型的 AST 节点。
以下面代码为例, 咱们能够编写 VariableDeclaration 函数对 VariableDeclaration节点进行拜访,每当遇到该类型节点时都会触发该办法。
如下:

该办法承受两个参数,

path

path 为以后拜访的门路, 并且蕴含了节点的信息、父节点信息以及对节点操作许多办法。能够利用这些办法对 ATS 进行增加、更新、挪动和删除等等。

state

state 蕴含了以后 plugin 的信息和参数信息等等,并且也能够用来自定义在节点之间传递数据。

生成(generate)

generate:把转换后的 AST 打印成指标代码,并生成 sourcemap

这个阶段就比较简单了,在 transform 阶段解决 AST 完结后,该阶段的工作就是将 AST 转换回 code, 在此期间会对 AST 进行深度优先遍历,依据节点所蕴含的信息生成对应的代码,并且会生成对应的 sourcemap。

经典案例尝试

俗话说,最好的学习就是入手,咱们来一起尝试一个简略的经典案例:
将下面案例中的 es6 的 const 转变为 es5 的 var

第一步: 转换为 AST

应用 @babel/parser 生成 AST
比较简单,跟下面的案例是一样的,此时咱们 ast 变量中就是转换后的 AST

const parser = require('@babel/parser');
const ast = parser.parse('const a = 1');

第二步:解决 AST

应用 @babel/traverse 解决 AST

在这个阶段咱们通过剖析所生成的 AST 构造,确定了在 VariableDeclaration 中由 kind 字段管制 const,所以咱们是不是能够尝试着把 kind 改写成咱们想要的 var?既然如此,咱们来尝试一下

const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default

const ast = parser.parse('const a = 1');
traverse(ast, {VariableDeclaration(path, state) {
      // 通过 path.node 拜访理论的 AST 节点
      path.node.kind = 'var'
    }
});

好,此时咱们凭借着猜测批改了 kind,将其改写为了 var, 然而咱们还不能晓得理论是否无效,所以咱们须要将其再转换回 code 看看成果。

第三步:生成 code

应用 @babel/generator 解决 AST

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

const ast = parser.parse('const a = 1');
traverse(ast, {VariableDeclaration(path, state) {path.node.kind = 'var'}
});

// 将解决好的 AST 放入 generate
const transformedCode = generate(ast).code
console.log(transformedCode)

咱们再来看看成果:

执行实现,胜利了,是咱们想要的成果~

如何开发插件

通过下面这个经典案例,大略理解了 babel 的应用,但咱们平时的插件该如何去写呢?

实际上插件的开发和下面的基本思路是一样的,只是作为插件咱们只须要关注这其中的 转换 阶段

咱们的插件须要导出一个函数 / 对象,如果是函数则须要返回一个对象, 咱们只须要在改对象的 visitor 内做同样的事件即可,并且函数会承受几个参数,api 继承了 babel 提供的一系列办法,options 是咱们应用插件时所传递的参数,dirname 为解决期间的文件门路。

以下面的案例革新为如下:

module.exports = {
    visitor: {VariableDeclaration(path, state) {path.node.kind = 'var'}
    }
}
// 或是函数模式
module.exports = (api, options, dirname) => {
    return {
        visitor: {VariableDeclaration(path, state) {path.node.kind = 'var'}
        }
    }
}

插件编写

在有前置常识的根底上,咱们来一步步的解说开发一个 babel 插件。
首先咱们明确接下来要开发的插件的外围需要:

  • 可主动插入某个函数并调用。
  • 主动导入插入函数的相干依赖。
  • 能够通过正文指定须要插入的函数和须要被插入的函数,若未用正文指定则默认插入地位在第一列。

根本成果展现如下:

解决前

// log 申明须要被插入并被调用的办法
// @inject:log
function fn() {console.log(1)
    // 用 @inject:code 指定插入行
    // @inject:code
    console.log(2)
}

解决后

// 导入包 xxx 之后要在插件参数内提供配置
import log from 'xxx'
function fn() {console.log(1)
    log()
    console.log(2)
}

思路整顿

理解了大略的需要,先不焦急入手,咱们要先想想要怎么开始做,曾经构想一下过程中须要解决的问题。

  1. 找到带有 @inject 标记的函数,再查看其外部是否有 @inject:code 的地位标记。
  2. 导入所有插入函数的相应包。
  3. 匹配到了标记,要做的就是插入函数,同时咱们还要须要解决各种状况下的函数,如:对象办法、iife、箭头函数等等状况。

设计插件参数

为了晋升插件的灵便度,咱们须要设计一个较为适合的参数规定。
插件参数承受一个对象。

  • key 作为插入函数的函数名。
  • kind 示意导入模式。有三种导入形式 named、default、namespaced, 此设计参考 babel-helper-module-imports

    • named 对应 import {a} from "b" 模式
    • default 对应 import a from "b" 模式
    • namespaced 对应 import * as a from "b" 模式
  • require 为依赖的包名

比方,我须要插入 log 办法,它须要从 log4js 这个包里导入,并且是以 named 模式,参数便为如下模式。

// babel.config.js
module.exports = {
  plugins: [
    // 填写咱们的 plugin 的 js 文件地址
    ['./babel-plugin-myplugin.js', {
      log: {
        // 导入形式为 named
        kind: 'named',
        require: 'log4js'
      }
    }]
  ]
}

起步

好,晓得了具体要做什么事件并且设计好了参数的规定,咱们就能够开始入手了。

首先咱们进入 https://astexplorer.net/ 将待处理的 code 生成 AST 不便咱们梳理构造,而后咱们在进行具体编码

首先是函数申明语句,咱们剖析一下其 AST 构造以及该如何解决,来看一下 demo

// @inject:log
function fn() {console.log('fn')
}

其生成的 AST 构造如下,能够看到有比拟要害的两个属性:

  • leadingComments 示意后方正文,能够看到外部有一个元素,就是咱们 demo 里所写的 @inject:log
  • body 是函数体的具体内容,demo 所写的 console.log('fn') 此时就在外面,咱们等会代码的插入操作就是须要操作它

好,晓得了能够通过 leadingComments 来获知函数是否须要被插入, 对 body 操作能够实现咱们的代码插入需要。。

首先咱们得先找到 FunctionDeclaration 这一层,因为只有这一层才有 leadingComments 属性,而后咱们须要遍历它,匹配出须要插入的函数。再将匹配到的函数插入至 body 只中,但咱们这里须要留神可插入的 body 所在层级,FunctionDeclaration 内的 body 他不是一个数组而是 BlockStatement,这示意函数的函数体,并且它也有 body , 所以咱们实际操作地位就在这个BlockStatement 的 body 内

代码如下:

module.exports = (api, options, dirname) => {

  return {
    visitor: {
      // 匹配函数申明节点
      FunctionDeclaration(path, state) {// path.get('body') 相当于 path.node.body
        const pathBody = path.get('body')
        if(path.node.leadingComments) {
          // 过滤出所有匹配 @inject:xxx 字符 的正文
          const leadingComments = path.node.leadingComments.filter(comment => /\@inject:(\w+)/.test(comment.value) )
          leadingComments.forEach(comment => {const injectTypeMatchRes = comment.value.match(/\@inject:(\w+)/)
            // 匹配胜利
            if(injectTypeMatchRes) {
              // 匹配后果的第一个为 @inject:xxx 中的 xxx ,  咱们将它取出来
              const injectType = injectTypeMatchRes[1]
              // 获取插件参数的 key,看 xxx 是否在插件的参数中申明过
              const sourceModuleList = Object.keys(options)
              if(sourceModuleList.includes(injectType) ) {
                // 搜寻 body 外部是否有 @code:xxx 正文
                // 因为无奈间接拜访到 comment,所以须要拜访 body 内每个 AST 节点的 leadingComments 属性
                const codeIndex = pathBody.node.body.findIndex(block => block.leadingComments && block.leadingComments.some(comment => new RegExp(`@code:\s?${injectType}`).test(comment.value) ))
                // 未声明则默认插入地位为第一行
                if(codeIndex === -1) {
                  // 操作 `BlockStatement` 的 body
                  pathBody.node.body.unshift(api.template.statement(`${state.options[injectType].identifierName}()`)());
                }else {pathBody.node.body.splice(codeIndex, 0, api.template.statement(`${state.options[injectType].identifierName}()`)());
                }
              }
            }
          })
        }
      }
  }
})

编写完后咱们看看后果,log胜利被插入了,因为咱们没有应用 @code:log所以就默认插入在了第一行

而后咱们试试应用 @code:log 标识符, 咱们将 demo 的代码改为如下

// @inject:log
function fn() {console.log('fn')
    // @code:log
}

再次运行代码查看后果,的确是在 @code:log 地位胜利插入了

解决完了咱们第一个案例函数申明,这时候可能有人会问了,那箭头函数这种没有函数体的你怎么办,
比方:

// @inject:log
() => true

这有问题吗?没有问题!

没有函数体咱们给它一个函数体就是了,怎么做呢?

首先咱们还是先学会来剖析一下 AST 构造,首先看到最外层其实是一个 ExpressionStatement 表达式申明,而后其外部才是 ArrowFunctionExpression箭头函数表达式, 可见跟咱们之前的函数申明生成的构造是大有不同,其实咱们不必被这么多层构造迷了眼睛,咱们只须要找对咱们有用的信息就能够了,一句话:哪一层有 leadingComments 咱们就找哪一层。这里的 leadingCommentsExpressionStatement 上,所以咱们找它就行

剖析完了构造,那怎么判断是否有函数体呢?
还记得下面处理函数申明时,咱们在 body 中看到的 BlockStatement 吗,而你看到咱们箭头函数的 body 却是 BooleanLiteral。所以,咱们能够判断其 body 类型来得悉是否有函数体 具体方法能够应用 babel 提供的类型判断办法 path.isBlockStatement() 来辨别是否有函数体。

module.exports = (api, options, dirname) => {

  return {
    visitor: {ExpressionStatement(path, state) {
        // 拜访到 ArrowFunctionExpression
        const expression = path.get('expression')
        const pathBody = expression.get('body')
        if(path.node.leadingComments) {
          // 正则匹配 comment 是否有 @inject:xxx 字符
          const leadingComments = path.node.leadingComments.filter(comment => /\@inject:(\w+)/.test(comment.value) )
          
          leadingComments.forEach(comment => {const injectTypeMatchRes = comment.value.match(/\@inject:(\w+)/)
            // 匹配胜利
            if(injectTypeMatchRes) {
              // 匹配后果的第一个为 @inject:xxx 中的 xxx ,  咱们将它取出来
              const injectType = injectTypeMatchRes[1]
              // 获取插件参数的 key,看 xxx 是否在插件的参数中申明过


              const sourceModuleList = Object.keys(options)
              if(sourceModuleList.includes(injectType) ) {
                // 判断是否有函数体
                if (pathBody.isBlockStatement()) {
                  // 搜寻 body 外部是否有 @code:xxx 正文
                  // 因为无奈间接拜访到 comment,所以须要拜访 body 内每个 AST 节点的 leadingComments 属性
                  const codeIndex = pathBody.node.body.findIndex(block => block.leadingComments && block.leadingComments.some(comment => new RegExp(`@code:\s?${injectType}`).test(comment.value) ))
                  // 未声明则默认插入地位为第一行
                  if(codeIndex === -1) {pathBody.node.body.unshift(api.template.statement(`${injectType}()`)());
                  }else {pathBody.node.body.splice(codeIndex, 0, api.template.statement(`${injectType}()`)());
                  }
                }else {
                  // 无函数体状况
                  // 应用 ast 提供的 `@babel/template`  api,用代码段生成 ast
                  const ast = api.template.statement(`{${injectType}();return BODY;}`)({BODY: pathBody.node});
                 // 替换本来的 body
                  pathBody.replaceWith(ast);
                }
              }
            }
          })
        }
      }
  }
}
}

能够看到除了新增的函数体判断,生成函数体插入代码再用新的 AST 替换本来的节点,除掉这些之外,大体上的逻辑跟之前的函数申明的处理过程没有区别。

生成 AST 所应用的 @babel/template 的 API 相干用法能够查看文档 @babel/template

针对不同状况的下的函数大体上雷同,总结就是:

剖析 AST 找到 leadingComments 所在节点 -> 找到可插入的 body 所在节点 -> 编写插入逻辑

理论解决的状况还有很多,如:对象属性、iife、函数表达式等很多,解决思路都是一样的,这里就不过反复论述。我会将插件残缺代码发在文章底部。

主动引入

第一条实现了,那需要的第二条,咱们应用的包如何主动引入呢,如下面案例应用的 log4js,那么咱们解决后的代码就应该主动加上:

import {log} from 'log4js'

此时,咱们能够思考一下,咱们须要解决以下两种状况

  1. log 曾经被导入过了
  2. log 变量名曾经被占用

针对 问题 1 咱们须要先检索一下是否有导入过 log4js,并且以 named 的模式导入了 log
针对 问题 2 咱们须要给 log 一个惟一的别名,并且要保障在后续的代码插入中也应用这个别名。所以这就要求了咱们要在文件的一开始就解决实现主动引入的逻辑。

有了大略的思路,然而咱们如何提前完成主动引入逻辑呢。抱着疑难,咱们再来看看 AST 的构造。
能够看到 AST 最外层是 File 节点, 他有一个 comments 属性,它蕴含了以后文件里所有的正文,有了这个咱们就能够解析出文件里须要插入的函数,并提前进行引入。咱们再往下看,外部是一个 Program, 咱们将首先拜访它,因为它会在其余类型的节点之前被调用,所以咱们要在此阶段实现主动引入逻辑。

小常识:babel 提供了 path.traverse 办法,能够用来同步拜访解决以后节点下的子节点。

如图:

代码如下:

const importModule = require('@babel/helper-module-imports');

// ......
{
    visitor: {Program(path, state) {
        // 拷贝一份 options 挂在 state 上,  本来的 options 不能操作
        state.options = JSON.parse(JSON.stringify(options))

        path.traverse({
          // 首先拜访原有的 import 节点,检测 log 是否曾经被导入过
          ImportDeclaration (curPath) {const requirePath = curPath.get('source').node.value;
            // 遍历 options
            Object.keys(state.options).forEach(key => {const option = state.options[key]
              // 判断包雷同
              if(option.require === requirePath) {const specifiers = curPath.get('specifiers')
                specifiers.forEach(specifier => {

                  // 如果是默认 type 导入
                  if(option.kind === 'default') {
                    // 判断导入类型
                    if(specifier.isImportDefaultSpecifier() ) {
                      // 找到已有 default 类型的引入
                      if(specifier.node.imported.name === key) {
                        // 挂到 identifierName 以供后续调用获取
                        option.identifierName = specifier.get('local').toString()}
                    }
                  }

                    // 如果是 named 模式的导入
                  if(option.kind === 'named') {
                    // 
                    if(specifier.isImportSpecifier() ) {
                      // 找到已有 default 类型的引入
                      if(specifier.node.imported.name === key) {option.identifierName = specifier.get('local').toString()}
                    }
                  }
                })
              }
            })
          }
        });


        // 解决未被引入的包
        Object.keys(state.options).forEach(key => {const option = state.options[key]
          // 须要 require 并且未找到 identifierName 字段
          if(option.require && !option.identifierName)  {
            
            // default 模式
            if(option.kind === 'default') {
              // 减少 default 导入
              // 生成一个随机变量名, 大抵上是这样 _log2
              option.identifierName = importModule.addDefault(path, option.require, {nameHint: path.scope.generateUid(key)
              }).name;
            }

            // named 模式
            if(option.kind === 'named') {
              option.identifierName = importModule.addNamed(path, key, option.require, {nameHint: path.scope.generateUid(key)
              }).name
            }
          }

          // 如果没有传递 require 会认为是全局办法,不做导入解决
          if(!option.require) {option.identifierName = key}
        })
    }
  }
}

Program 节点内咱们先将接管到的插件配置 options 拷贝了一份,挂到了 state 上,之前有说过 state 能够用作 AST 节点之间的数据传递,而后咱们首先拜访 Program 下的 ImportDeclaration 也就是 import 语句, 看看 log4js 是否有被导入过,若引入过便会记录到 identifierName 字段上,实现对 import 语句的拜访后,咱们就可依据 identifierName 字段判断是否已被引入,若未引入则应用 @babel/helper-module-imports 创立 import,并用 babel 提供的 generateUid 办法创立惟一的变量名。

这样在之前的代码咱们也须要稍微调整,不能间接应用从正文 @inject:xxx 提取出的办法名,
而是应该应用 identifierName,要害局部代码批改如下:

if(sourceModuleList.includes(injectType) ) {
  // 判断是否有函数体
  if (pathBody.isBlockStatement()) {
    // 搜寻 body 外部是否有 @code:xxx 正文
    // 因为无奈间接拜访到 comment,所以须要拜访 body 内每个 AST 节点的 leadingComments 属性
    const codeIndex = pathBody.node.body.findIndex(block => block.leadingComments && block.leadingComments.some(comment => new RegExp(`@code:\s?${injectType}`).test(comment.value) ))
    // 未声明则默认插入地位为第一行
    if(codeIndex === -1) {
      // 应用 identifierName 
      pathBody.node.body.unshift(api.template.statement(`${state.options[injectType].identifierName}()`)());
    }else {
      // 应用 identifierName 
      pathBody.node.body.splice(codeIndex, 0, api.template.statement(`${state.options[injectType].identifierName}()`)());
    }
  }else {
    // 无函数体状况
    // 应用 ast 提供的 `@babel/template`  api,用代码段生成 ast

    // 应用 identifierName 
    const ast = api.template.statement(`{${state.options[injectType].identifierName}();return BODY;}`)({BODY: pathBody.node});
    // 替换本来的 body
    pathBody.replaceWith(ast);
  }
}

最终成果如下:

咱们实现了函数主动插入并主动引入依赖包。

结尾

本篇文章是对本人学习“Babel 插件通关秘籍”小册子后的一个记录总结,我开始和大部分想写 babel 插件却无从下手的同学一样,所以这篇文章次要也是按本人写插件时摸索的思路去写。心愿也是能给大家提供一个思路。

完整版已反对 自定义代码片段 的插入,残缺代码已上传至 github,同时也公布至了 npm。
欢送大家 star 和 issue。

给 star 是人情,不给是事变,哈哈。

正文完
 0