阅读 774

【前端工程化基础 - Babel 篇】如何用 Babel 为代码自动引入依赖

2020年5月写的一篇文章,还没在掘金发过。原文链接

前言

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

需求

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

console.log(axuebin.say('hello babel'));
复制代码

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

VM105:2 Uncaught ReferenceError: axuebin is not defined
复制代码

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

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

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

import axuebin from 'axuebin';
console.log(axuebin.say('hello babel'));
复制代码

前置知识

什么是 Babel

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

Babel 是如何工作的

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

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

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

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

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

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

AST 节点

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

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

{
  type: 'Identifier',
  name: 'add'
}
复制代码

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

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

AST 遍历

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

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

一个 Visitor 一般是这样:

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

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

操作 AST 的例子

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

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

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

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

开发 Babel 插件的前置工作

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

分析 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(axuebin.say('hello babel'))

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

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

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

查看节点规范

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

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

写代码

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

初始化代码

我们的 index.js 代码为:

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

const TARGET_PKG_NAME = 'axuebin';

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(axuebin.say('hello babel'));
复制代码

分析 AST / 编写对应 type 代码

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

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

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

ImportDeclaration 节点

ImportDeclaration 节点的 AST 如图所示,我们需要关心的特征是 value 是否等于 axuebin, 代码这样写:

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

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

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

VariableDeclaration 节点

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

/**
 * 判断是否 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);
}
复制代码

ExpressionStatement 节点

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

if (path.isExpressionStatement()) {
  return isTrueRequire(path.get('expression').node);
}
复制代码

插入引用语句

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

import axuebin from 'axuebin';
复制代码

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

简化一下就是这样:

{
  "type": "ImportDeclaration",
  "specifiers": [
    "type": "ImportDefaultSpecifier",
    "local": {
      "type": "Identifier",
      "name": "axuebin"
    }
  ],
  "source": {
    "type": "StringLiteral",
    "value": "axuebin"
  }
}
复制代码

当然,不是直接构建这个对象放进去就好了,需要通过 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 节点的方法。

结果

我们 node index.js 一下,test.js 就变成:

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

彩蛋

如果我们还想帮他再多做一点事,还能做什么呢?

既然都自动引用了,那当然也要自动安装一下这个包呀!

/**
 * 判断是否安装了某个包
 * @param {string} pkg 包名
 */
const hasPkg = pkg => {
  const pkgPath = path.join(process.cwd(), `package.json`);
  const pkgJson = fs.existsSync(pkgPath) ? fse.readJsonSync(pkgPath) : {};
  const { dependencies = {}, devDependencies = {} } = pkgJson;
  return dependencies[pkg] || devDependencies[pkg];
}

/**
 * 通过 npm 安装包
 * @param {string} pkg 包名
 */
const installPkg = pkg => {
  console.log(`开始安装 ${pkg}`);
  const npm = shell.which('npm');
  if (!npm) {
    console.log('请先安装 npm');
    return;
  }
  const { code } = shell.exec(`${npm.stdout} install ${pkg} -S`);
  if (code) {
    console.log(`安装 ${pkg} 失败,请手动安装`);
  }
};

// biu~
if (!hasPkg(TARGET_PKG_NAME)) {
  installPkg(TARGET_PKG_NAME);
}
复制代码

判断一个应用是否安装了某个依赖,有没有更好的办法呢?

总结

我也是刚开始学 Babel,希望通过这个 Babel 插件的入门例子,可以让大家了解 Babel 其实并没有那么陌生,大家都可以玩起来 ~

更多文章可以关注公众号「前端试炼」,分享每日前端精选文章。

完整代码见:github.com/axuebin/bab…