网络日志

编写babel的插件

前言

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';
  }
}

只要确定节点的类型(type 属性)后,根据类型到官方文档查找。

实例

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.libraryObjs
const 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插件手册