Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Open
axuebin opened this issue May 7, 2020 · 0 comments
Open

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

axuebin opened this issue May 7, 2020 · 0 comments

Comments

@axuebin
Copy link
Owner

axuebin commented May 7, 2020

前言

最近在尝试玩一玩已经被大家玩腻的 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

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

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 节点,从而达到自己的目的。

我们可以在 https://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 结构,就能分析出原代码目标代码的特点,然后可以开始动手写程序了。

查看节点规范

节点规范:https://github.com/estree/estree

我们要增删改节点,当然要知道节点的一些规范,比如新建一个 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 其实并没有那么陌生,大家都可以玩起来 ~

完整代码见:https://github.com/axuebin/babel-inject-dep-demo

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

No branches or pull requests

1 participant