如何用 Babel 为代码自动引入依赖

3,575 阅读10分钟

0. 前言---作者:一彬


最近在尝试玩一玩已经被大家玩腻的 Babel,今天给大家分享如何用 Babel 为代码自动引入依赖,通过一个简单的例子入门 Babel 插件开发。


1. 需求


const a = require('a');
import b from 'b';

console.log(lib.say('hello babel'));


同学们都知道,如果运行上面的代码,一定是会报错的:


VM105:2 Uncaught ReferenceError: lib is not defined


我们得首先通过 import lib from 'lib' 引入 lib 之后才能使用。。


为了防止这种情况发生(一般来说我们都会手动引入),或者为你省去引入这个包的麻烦(其实有些编译器也会帮我们做了),我们可以在打包阶段分析每个代码文件,把这个事情做了。


在这里,我们就基于最简单的场景做最简单的处理,在代码文件顶部加一句引用语句:


import lib from 'lib';
console.log(lib.say('hello babel'));


2. 前置知识


2.1 什么是 Babel


简单地说,Babel 能够转译 ECMAScript 2015+ 的代码,使它在旧的浏览器或者环境中也能够运行。我们日常开发中,都会通过 webpack 使用 babel-loaderJavaScript 进行编译。


2.2 Babel 是如何工作的


首先得要先了解一个概念:抽象语法树(Abstract Syntax Tree, AST),Babel 本质上就是在操作 AST 来完成代码的转译。


了解了 AST 是什么样的,就可以开始研究 Babel 的工作过程了。


Babel 的功能其实很纯粹,它只是一个编译器。


大多数编译器的工作过程可以分为三部分,如图所示:



  • Parse(解析) 将源代码转换成更加抽象的表示方法(例如抽象语法树)
  • Transform(转换) 对(抽象语法树)做一些特殊处理,让它符合编译器的期望
  • Generate(代码生成) 将第二步经过转换过的(抽象语法树)生成新的代码


所以我们如果想要修改 Code,就可以在 Transform 阶段做一些事情,也就是操作 AST


2.3 AST 节点


我们可以看到 AST 中有很多相似的元素,它们都有一个 type 属性,这样的元素被称作节点。一个节点通常含有若干属性,可以用于描述 AST 的部分信息。


比如这是一个最常见的 Identifier 节点:


{
  type: 'Identifier',
  name: 'add'
}


所以,操作 AST 也就是操作其中的节点,可以增删改这些节点,从而转换成实际需要的 AST


更多的节点规范可以查阅 github.com/estree/estr…


2.4 AST 遍历


AST 是深度优先遍历的,遍历规则不用我们自己写,我们可以通过特定的语法找到的指定的节点。


Babel 会维护一个称作 Visitor 的对象,这个对象定义了用于 AST 中获取具体节点的方法。


一个 Visitor 一般是这样:


const visitor = {
  ArrowFunction(path) {
    console.log('我是箭头函数');
  },
  IfStatement(path) {
    console.log('我是一个if语句');
  },
  CallExpression(path) {}
};


visitor 上挂载以节点 type 命名的方法,当遍历 AST 的时候,如果匹配上 type,就会执行对应的方法。


2.5 操作 AST 的例子


通过上面简单的介绍,我们就可以开始任意造作了,肆意修改 AST 了。先来个简单的例子热热身。


箭头函数是 ES5 不支持的语法,所以 Babel 得把它转换成普通函数,一层层遍历下去,找到了 ArrowFunctionExpression 节点,这时候就需要把它替换成 FunctionDeclaration 节点。所以,箭头函数可能是这样处理的:


import * as t from "@babel/types";

const visitor = {
  ArrowFunction(path) {
    path.replaceWith(t.FunctionDeclaration(id, params, body));
  }
};


3. 前置工作


在开始写代码之前,我们还有一些事情要做一下:


3.1 分析 AST


原代码目标代码都解析成 AST,观察它们的特点,找找看如何增删改 AST 节点,从而达到自己的目的。


我们可以在 astexplorer.net 上完成这个工作,比如文章最初提到的代码:


const a = require('a');
import b from 'b';
console.log(axuebin.say('hello babel'));


转换成 AST 之后是这样的:



可以看出,这个 body 数组对应的就是根节点的三条语句,分别是:


  • VariableDeclaration: const a = require('a')
  • ImportDeclaration: import b from 'b'
  • ExpressionStatement: console.log(lib.say('hello babel'))


我们可以打开 VariableDeclaration 节点看看:



它包含了一个 declarations 数组,里面有一个 VariableDeclarator 节点,这个节点有 typeidinit 等信息,其中 id 指的是表达式声明的变量名,init 指的是声明内容。


通过这样查看/对比 AST 结构,就能分析出原代码目标代码的特点,然后可以开始动手写程序了。


3.2 查看节点规范


节点规范:github.com/estree/estr…


我们要增删改节点,当然要知道节点的一些规范,比如新建一个 ImportDeclaration 需要传递哪些参数。


4. 开发插件


准备工作都做好了,那就开始吧。


4.1 初始化代码


我们的 index.js 代码为:


// index.js
const path = require('path');
const fs = require('fs');
const babel = require('@babel/core');

const TARGET_PKG_NAME = 'lib';

function transform(file) {
  const content = fs.readFileSync(file, {
    encoding: 'utf8',
  });
  const { code } = babel.transformSync(content, {
    sourceMaps: false,
    plugins: [
      babel.createConfigItem(({ types: t }) => ({
        visitor: {
        }
      }))
    ]
  });
  return code;
}


然后我们准备一个测试文件 test.js,代码为:


// test.js
const a = require('a');
import b from 'b';
require('c');
import 'd';
console.log(lib.say('hello babel'));


4.2 分析 AST / 编写对应 type 代码


我们这次需要做的事情很简单,做两件事:


  1. 寻找当前 AST 中是否含有引用 lib 包的节点
  2. 如果没引用,则修改 AST,插入一个 ImportDeclaration 节点


我们来分析一下 test.jsAST,看一下这几个节点有什么特征:


4.2.1 ImportDeclaration 节点



ImportDeclaration 节点的 AST 如图所示,我们需要关心的特征是 value 是否等于 lib,

代码这样写:


if (path.isImportDeclaration()) {
  return path.get('source').isStringLiteral() && path.get('source').node.value === TARGET_PKG_NAME;
}


其中,可以通过 path.get 来获取对应节点的 path,嗯,比较规范。如果想获取对应的真实节点,还需要 .node


满足上述条件则可以认为当前代码已经引入了 lib 包,不用再做处理了。


4.2.2 VariableDeclaration 节点



对于 VariableDeclaration 而言,我们需要关心的特征是,它是否是一个 require 语句,并且 require 的是 lib,代码如下:


/**
 * 判断是否 require 了正确的包
 * @param {*} node 节点
 */
const isTrueRequire = node => {
  const { callee, arguments } = node;
  return callee.name === 'require' && arguments.some(item => item.value === TARGET_PKG_NAME);
};


if (path.isVariableDeclaration()) {
  const declaration = path.get('declarations')[0];
  return declaration.get('init').isCallExpression && isTrueRequire(declaration.get('init').node);
}


4.2.3 ExpressionStatement 节点



require('c'),语句我们一般不会用到,我们也来看一下吧,它对应的是 ExpressionStatement 节点,我们需要关心的特征和 VariableDeclaration 一致,这也是我把 isTrueRequire 抽出来的原因,所以代码如下:


if (path.isExpressionStatement()) {
  return isTrueRequire(path.get('expression').node);
}


4.3 插入引用语句


如果上述分析都没找到代码里引用了 lib,我们就需要手动插入一个引用:


import lib from 'lib';


通过 AST 分析,我们发现它是一个 ImportDeclaration


image.png


简化一下就是这样:


{
  "type": "ImportDeclaration",
  "specifiers": [
    "type": "ImportDefaultSpecifier",
    "local": {
      "type": "Identifier",
      "name": "lib"
    }
  ],
  "source": {
    "type": "StringLiteral",
    "value": "lib"
  }
}


当然,不是直接构建这个对象放进去就好了,需要通过 babel 的语法来构建这个节点(遵循规范):


const importDefaultSpecifier = [t.ImportDefaultSpecifier(t.Identifier(TARGET_PKG_NAME))];
const importDeclaration = t.ImportDeclaration(importDefaultSpecifier, t.StringLiteral(TARGET_PKG_NAME));
path.get('body')[0].insertBefore(importDeclaration);


这样就插入了一个 import 语句。


Babel Types 模块是一个用于 AST 节点的 Lodash 式工具库,它包含了构造、验证以及变换 AST 节点的方法。


4.4 测试


我们执行一下 index.jstest.js 就变成:


import axuebin from "axuebin"; // 已经自动加在代码最上边
const a = require('a');
import b from 'b';
require('c');
import 'd';
console.log(axuebin.say('hello babel'));


5 彩蛋:实现一个简易版 babel-plugin-import


babel-plugin-import 的作用是为组件库提供按需加载的功能,并且支持自动引入其样式,比如:


import { Button } from 'antd';
      ↓ ↓ ↓ ↓ ↓ ↓
var _button = require('antd/lib/button');
require('antd/lib/button/style');


更多关于 babel-plugin-impor 的作用以及使用方法可以见:github.com/ant-design/…


这里主要说一下如何实现一个简易版的 babel-plugin-import


5.1 最简功能实现


通过文档我们可以发现,最重要的配置项只有三个:


{
  "libraryName": "antd",
  "libraryDirectory": "lib",
  "style": true,
}


所以我们也就只实现这三个配置项。


并且,真实情况中会有多种方式来调用一个组件,这里我们也不处理这些复杂情况,只实现最常见的 <Button /> 调用。


5.2 入口文件


入口文件的作用是获取用户传入的配置项并且将核心插件代码作用到 ast 上。


import Plugin from './Plugin';

export default function ({ types }) {
  let plugins = null;

  // 将插件作用到节点上
  function applyInstance(method, args, context) {
    for (const plugin of plugins) {
      if (plugin[method]) {
        plugin[method].apply(plugin, [...args, context]);
      }
    }
  }

  const Program = {
    // ast 入口
    enter(path, { opts = {} }) {
      // 初始化插件实例
      if (!plugins) {
        plugins = [
          new Plugin(
            opts.libraryName,
            opts.libraryDirectory,
            opts.style,
            types,
          ),
        ];
      }
      applyInstance('ProgramEnter', arguments, this);
    },
    // ast 出口
    exit() {
      applyInstance('ProgramExit', arguments, this);
    },
  };

  const ret = {
    visitor: { Program },
  };

  // 插件只作用在 ImportDeclaration 和 CallExpression 上
  ['ImportDeclaration', 'CallExpression'].forEach(method => {
    ret.visitor[method] = function () {
      applyInstance(method, arguments, ret.visitor);
    };
  });

  return ret;
}


5.3 核心代码


真正修改 ast 的代码是在 plugin 实现的:


import { join } from 'path';
import { addSideEffect, addDefault } from '@babel/helper-module-imports';

/**
 * 转换成小写,添加连接符
 * @param {*} _str   字符串
 * @param {*} symbol 连接符
 */
function transCamel(_str, symbol) {
  const str = _str[0].toLowerCase() + _str.substr(1);
  return str.replace(/([A-Z])/g, $1 => `${symbol}${$1.toLowerCase()}`);
}

/**
 * 兼容 Windows 路径
 * @param {*} path 
 */
function winPath(path) {
  return path.replace(/\\/g, '/');
}

export default class Plugin {
  constructor(
    libraryName,                                   // 需要使用按需加载的包名
    libraryDirectory = 'lib',                      // 按需加载的目录
    style = false,                                 // 是否加载样式
    types,                                         // babel-type 工具函数
  ) {
    this.libraryName = libraryName;
    this.libraryDirectory = libraryDirectory;
    this.style = style;
    this.types = types;
  }

  /**
   * 获取内部状态,收集依赖
   * @param {*} state 
   */
  getPluginState(state) {
    if (!state) {
      state = {};
    }
    return state;
  }

  /**
   * 生成 import 语句(核心代码)
   * @param {*} methodName 
   * @param {*} file 
   * @param {*} pluginState 
   */
  importMethod(methodName, file, pluginState) {
    if (!pluginState.selectedMethods[methodName]) {
      // libraryDirectory:目录,默认 lib
      // style:是否引入样式
      const { style, libraryDirectory } = this;
      // 组件名转换规则
      const transformedMethodName = transCamel(methodName, '');
      // 兼容 windows 路径
      // path.join('antd/lib/button') == 'antd/lib/button'
      const path = winPath(join(this.libraryName, libraryDirectory, transformedMethodName));
      // 生成 import 语句
      // import Button from 'antd/lib/button'
      pluginState.selectedMethods[methodName] = addDefault(file.path, path, { nameHint: methodName });
      if (style) {
        // 生成样式 import 语句
        // import 'antd/lib/button/style'
        addSideEffect(file.path, `${path}/style`);
      }
    }
    return { ...pluginState.selectedMethods[methodName] };
  }
  
  ProgramEnter(path, state) {
    const pluginState = this.getPluginState(state);
    pluginState.specified = Object.create(null);
    pluginState.selectedMethods = Object.create(null);
    pluginState.pathsToRemove = [];
  }

  ProgramExit(path, state) {
    // 删除旧的 import
    this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());
  }

  /**
   * ImportDeclaration 节点的处理方法
   * @param {*} path 
   * @param {*} state 
   */
  ImportDeclaration(path, state) {
    const { node } = path;
    if (!node) return;
    // 代码里 import 的包名
    const { value } = node.source;
    // 配在插件 options 的包名
    const { libraryName } = this;
    // babel-type 工具函数
    const { types } = this;
    // 内部状态
    const pluginState = this.getPluginState(state);
    // 判断是不是需要使用该插件的包
    if (value === libraryName) {
      // node.specifiers 表示 import 了什么
      node.specifiers.forEach(spec => {
        // 判断是不是 ImportSpecifier 类型的节点,也就是是否是大括号的
        if (types.isImportSpecifier(spec)) {
          // 收集依赖
          // 也就是 pluginState.specified.Button = Button
          // local.name 是导入进来的别名,比如 import { Button as MyButton } from 'antd' 的 MyButton
          // imported.name 是真实导出的变量名
          pluginState.specified[spec.local.name] = spec.imported.name;
        } else { 
          // ImportDefaultSpecifier 和 ImportNamespaceSpecifier
          pluginState.libraryObjs[spec.local.name] = true;
        }
      });
      // 收集旧的依赖
      pluginState.pathsToRemove.push(path);
    }
  }


  /**
   * React.createElement 对应的节点处理方法
   * @param {*} path 
   * @param {*} state 
   */
  CallExpression(path, state) {
    const { node } = path;
    const file = (path && path.hub && path.hub.file) || (state && state.file);
    // 方法调用者的 name
    const { name } = node.callee;
    // babel-type 工具函数
    const { types } = this;
    // 内部状态
    const pluginState = this.getPluginState(state);

    // 如果方法调用者是 Identifier 类型
    if (types.isIdentifier(node.callee)) {
      if (pluginState.specified[name]) {
        node.callee = this.importMethod(pluginState.specified[name], file, pluginState);
      }
    }

    // 遍历 arguments 找我们要的 specifier
    node.arguments = node.arguments.map(arg => {
      const { name: argName } = arg;
      if (
        pluginState.specified[argName] &&
        path.scope.hasBinding(argName) &&
        path.scope.getBinding(argName).path.type === 'ImportSpecifier'
      ) {
        // 找到 specifier,调用 importMethod 方法
        return this.importMethod(pluginState.specified[argName], file, pluginState);
      }
      return arg;
    });
  }
}


这样就实现了一个最简单的 babel-plugin-import 插件,它主要就是做了三件事情:


  1. 收集依赖:找到 importDeclaration,分析出包 a 和依赖 b,c,d....,假如 alibraryName 一致,就将 b,c,d... 在内部收集起来
  2. 判断是否使用:在多种情况下(比如文中提到的 CallExpression)判断 收集到的 b,c,d... 是否在代码中被使用,如果有使用的,就调用 importMethod 生成新的 impport 语句
  3. 生成引入代码:根据配置项生成代码和样式的 import 语句

6. 总结


希望通过本篇文章,可以让大家了解 Babel 其实并没有那么陌生,大家都可以玩起来 ~


参考链接:


image.png