前言

Babel 是一个通用的多功能的 JavaScript 编译器,让一些新版本的语法或者语法糖能够在低版本的浏览器上跑起来。
它有三个次要解决步骤 Parse -> Transform -> Generate。
在 Transform 转换过程中通过将插件(或预设)利用到配置文件来启用代码转换。

AST

编写 Babel 插件非常复杂,须要有相当的基础知识,在讲插件之前必须得提起 AST 的概念。
AST 全称 Abstract Syntax Tree 形象语法树,这棵树定义了代码的构造,通过操作这棵树的增删改查实现对代码的变动和优化,并最终在Generate步骤构建出转换后的代码字符串。
astexplorer是一款十分好用的在线转换工具,能够帮忙咱们更直观的意识到 AST 节点。

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

通过网站解析后,失去

{  "type": "Program",  "start": 0,  "end": 38,  "body": [    {      "type": "FunctionDeclaration",      "start": 0,      "end": 38,      "id": {        "type": "Identifier",        "start": 9,        "end": 15,        "name": "square"      },      "expression": false,      "generator": false,      "async": false,      "params": [        {          "type": "Identifier",          "start": 16,          "end": 17,          "name": "n"        }      ],      "body": {        "type": "BlockStatement",        "start": 19,        "end": 38,        "body": [          {            "type": "ReturnStatement",            "start": 23,            "end": 36,            "argument": {              "type": "BinaryExpression",              "start": 30,              "end": 35,              "left": {                "type": "Identifier",                "start": 30,                "end": 31,                "name": "n"              },              "operator": "*",              "right": {                "type": "Identifier",                "start": 34,                "end": 35,                "name": "n"              }            }          }        ]      }    }  ],  "sourceType": "module"}

这里不是本文的重点,大略相熟下数据结构就行,前面实例中用到了会再具体解说。

简介

visitor

转换阶段 @babel/traverse 会遍历拜访每一个 AST 节点传递给插件,插件依据须要抉择感兴趣的节点进行转换操作,这种设计模式称为访问者模式(visitor)。
这样做的益处是:

  • 对立执行遍历操作
  • 对立执行节点的转换方法
设想一下,Babel 有那么多插件,如果每个插件本人去遍历AST,对不同的节点进行不同的操作,保护本人的状态。这样子不仅低效,它们的逻辑扩散在各处,会让整个零碎变得难以了解和调试。

来看一个最简略的插件构造:

export default function({ types: t }) {  return {    visitor: {      Identifier(path, state) {        console.log(path, state);      },    }  };};

它在每次进入一个标识符 Identifier 的时候会打印以后的 pathstate
留神:

Identifier() {}  ↑ 简写Identifier: {  enter() {}}

如果须要拜访到残缺的生命周期(蕴含来到事件),应用如下写法:

Identifier: {  enter() {    console.log('Entered!');  },  exit() {    console.log('Exited!');  }}

@babel/traverse

遍历并负责替换、移除和增加 AST 节点。

path

示意节点之间的关联关系,详见path源码:

// 数据{  "parent": {...}, // 父节点数据结构  "parentPath": null, // 父节点门路  "node": {...}, // 节点数据结构  "scope": null, // 作用域  ...等等}
// 办法{  "remove", // 移除以后节点  "replaceWith", // 替换以后节点  ...等等}

state

用来拜访插件信息、配置参数,也能够用来存储插件处理过程中的自定义状态。

@babel/types

蕴含了结构、验证、变换 AST 节点的办法的工具库。
咱们以上述 square 办法为例,写一个把 n 重命名为 x 的访问者的疾速实现:

enter(path) {  if (path.node.type === 'Identifier' && path.node.name === 'n') {    path.node.name = 'x';  }}

联合 @babel/types 能够更简洁且语义化:

import * as t from '@babel/types';enter(path) {  if (t.isIdentifier(path.node, { name: 'n' })) {    path.node.name = 'x';  }}

实例

babel-plugin-import

应用过 react 组件库 ant-design 或者 vue 组件库 vant 的小伙伴肯定都不会对按需引入(import-on-demand)这个概念生疏,具体概念文档可见antd 按需加载、vant疾速上手,都举荐应用 babel-plugin-import 这款插件反对主动按需。
这里须要留神的是,大部分构建工具反对对 ESM 产物基于 Tree Shaking 的按需加载,那么这个插件是不是曾经无用武之地了?
答案是否定的:

  • Tree Shaking 收到简单环境影响(如副作用)导致失败
  • 构建工具无Tree Shaking 或 组件库无 ESM 产物
  • css 得手动按需屡次引入

讲完了它的不可替代性,接下来咱们看看这个插件做了什么

// 在.babelrc 中增加配置{  "plugins": [    ["import", {      "libraryName": "vant",      "libraryDirectory": "es",      "style": true    }]  ]}
import { Button } from 'vant';      ↓ ↓ ↓ ↓ ↓ ↓import "vant/es/button/style";import _Button from "vant/es/button";

如果去掉插件成果会怎么样呢?

import { Button } from 'vant';      ↓ ↓ ↓ ↓ ↓ ↓var _vant = require("vant");var Button = _vant.Button;

能够显著看到会将整个组件库全副引入,重大影响了包大小。
铺垫了这么多,进入主题剖析源码吧。先晓得须要做什么,从树上收集到要害一些关键字 ImportDeclarationspecifiers.local.namesource.value

针对这些要害节点,开始做状态收集,源码如下:

ImportDeclaration(path, state) {  const { node } = path;  // path maybe removed by prev instances.  if (!node) return;  const { value } = node.source;  const { libraryName } = this;  // @babel/types 工具库  const { types } = this;  // 外部保护的状态  const pluginState = this.getPluginState(state);  if (value === libraryName) {    node.specifiers.forEach(spec => {      if (types.isImportSpecifier(spec)) {        pluginState.specified[spec.local.name] = spec.imported.name;      } else {        pluginState.libraryObjs[spec.local.name] = true;      }    });    pluginState.pathsToRemove.push(path);  }}

剖析得出,做了以下几件事:

  1. 判断引入的包名是否与参数libraryName雷同
  2. 遍历 specifiers 关键字,判断是否 ImportSpecifier 类型(大括号形式),别离存入不同的外部状态
  3. 将以后节点存入外部状态,最初对立删除

收集完状态后,寻找所有可能援用到 Import 的节点,对他们所有进行解决。因为须要判断的节点太多,这里不多做赘述,波及到的能够查看源码如下:

const methods = [  'ImportDeclaration',  'CallExpression', // 函数调用表达式 React.createElement(Button)  'MemberExpression', // 属性成员表达式 vant.Button  'Property', // 对象属性值 const obj = { btn: Button }  'VariableDeclarator', // 变量申明 const btn = Button  'ArrayExpression', // 数组表达式 [Button, Input]  'LogicalExpression', // 逻辑运算符表达式 Button || 1  'ConditionalExpression', // 条件运算符 true ? Button : Input  'IfStatement',  'ExpressionStatement', // 表达式语句 module.export = Button  'ReturnStatement',  'ExportDefaultDeclaration',  'BinaryExpression', // 二元表达式 Button | 1  'NewExpression',  'ClassDeclaration', // 类申明 class btn extends Button {}  'SwitchStatement',  'SwitchCase',];

一些显著能看懂的办法名就不一一正文了,须要特地阐明的是非大括号形式的状态会在 MemberExpression 办法中将 vant.Button 转为 _Button,

import vant from 'vant'; // 对应pluginState.libraryObjsconst Button = vant.Button;      ↓ ↓ ↓ ↓ ↓ ↓import "vant/es/button/style";import _Button from "vant/es/button";const Button = _Button;

这些办法最终都会调用 importMethod 函数,它承受3个参数:

  • methodName 原组件名
  • file 以后文件path.hub.file
  • pluginState 外部状态
importMethod(methodName, file, pluginState) {  if (!pluginState.selectedMethods[methodName]) {    const { style, libraryDirectory } = this;    const transformedMethodName = this.camel2UnderlineComponentName // eslint-disable-line      ? transCamel(methodName, '_')      : this.camel2DashComponentName      ? transCamel(methodName, '-')      : methodName;    const path = winPath(      this.customName        ? this.customName(transformedMethodName, file)        : join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line    );    pluginState.selectedMethods[methodName] = this.transformToDefaultImport // eslint-disable-line      ? addDefault(file.path, path, { nameHint: methodName })      : addNamed(file.path, methodName, path);    if (this.customStyleName) {      const stylePath = winPath(this.customStyleName(transformedMethodName, file));      addSideEffect(file.path, `${stylePath}`);    } else if (this.styleLibraryDirectory) {      const stylePath = winPath(        join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),      );      addSideEffect(file.path, `${stylePath}`);    } else if (style === true) {      addSideEffect(file.path, `${path}/style`);    } else if (style === 'css') {      addSideEffect(file.path, `${path}/style/css`);    } else if (typeof style === 'function') {      const stylePath = style(path, file);      if (stylePath) {        addSideEffect(file.path, stylePath);      }    }  }  return { ...pluginState.selectedMethods[methodName] };}

剖析得出,做了以下几件事:

  1. 通过methodName进行去重,确保importMethod函数不会被屡次调用
  2. 对组件名methodName进行转换
  3. 依据不同配置生成 import 语句和import 款式

这里还用到了babel官网的辅助函数包 @babel/helper-module-imports 办法 addDefaultaddNamedaddSideEffect,具体作用如下:

import { addDefault } from "@babel/helper-module-imports";// If 'hintedName' exists in scope, the name will be '_hintedName2', '_hintedName3', ...addDefault(path, 'source', { nameHint: "hintedName" })      ↓ ↓ ↓ ↓ ↓ ↓import _hintedName from "source"
import { addNamed } from "@babel/helper-module-imports";// if the hintedName isn't set, the function will gennerate a uuid as hintedName itself such as '_named'addNamed(path, 'named', 'source');      ↓ ↓ ↓ ↓ ↓ ↓import { named as _named } from "source"
import { addSideEffect } from "@babel/helper-module-imports";addSideEffect(path, 'source');      ↓ ↓ ↓ ↓ ↓ ↓import "source"

最初,在exit来到事件中做好善后工作,删除掉旧的 import 导入。

ProgramExit(path, state) {  this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());}

总结一下整个 babel-import-plugin 的流程:

  1. 解析 import 引入的所有标识符并外部缓存
  2. 枚举所有可能应用到这些标识符的节点,去匹配缓存中的标识符
  3. 匹配胜利则用辅助函数生成新的节点
  4. 对立删除旧节点

附言

写这篇文章的初衷是因为本系列的兄弟篇:
编写webpack的loader和plugin(附实例)
编写markdown-it的插件和规定
都曾经写完了(顺便安利一波),怎么能没有弱小的 babel 篇呢。通过上述例子也能够看出,每个plugin都要思考到各种简单状况生成的不同 AST 树,须要大量的常识储备,不同于之前的文章,自己没有在我的项目中实际过本人的 babel plugin实例,心愿之后能有机会补上。

参考文档

babel插件手册