最近在尝试玩一玩已经被大家玩腻的 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
能够转译 ECMAScript 2015+
的代码,使它在旧的浏览器或者环境中也能够运行。我们日常开发中,都会通过 webpack
使用 babel-loader
对 JavaScript
进行编译。
首先得要先了解一个概念:抽象语法树(Abstract Syntax Tree, AST),Babel
本质上就是在操作 AST
来完成代码的转译。
了解了 AST
是什么样的,就可以开始研究 Babel
的工作过程了。
Babel
的功能其实很纯粹,它只是一个编译器。
大多数编译器的工作过程可以分为三部分,如图所示:
「Parse(解析)」 将源代码转换成更加抽象的表示方法(例如抽象语法树)
「Transform(转换)」 对(抽象语法树)做一些特殊处理,让它符合编译器的期望
「Generate(代码生成)」 将第二步经过转换过的(抽象语法树)生成新的代码
所以我们如果想要修改 Code
,就可以在 Transform
阶段做一些事情,也就是操作 AST
。
我们可以看到 AST
中有很多相似的元素,它们都有一个 type
属性,这样的元素被称作「节点」。一个节点通常含有若干属性,可以用于描述 AST
的部分信息。
比如这是一个最常见的 Identifier
节点:
{
type: 'Identifier',
name: 'add'
}
所以,操作 AST
也就是操作其中的节点,可以增删改这些节点,从而转换成实际需要的 AST
。
更多的节点规范可以查阅 https://github.com/estree/estree
AST
是深度优先遍历的,遍历规则不用我们自己写,我们可以通过特定的语法找到的指定的节点。
Babel
会维护一个称作 Visitor
的对象,这个对象定义了用于 AST
中获取具体节点的方法。
一个 Visitor
一般是这样:
const visitor = {
ArrowFunction(path) {
console.log('我是箭头函数');
},
IfStatement(path) {
console.log('我是一个if语句');
},
CallExpression(path) {}
};
visitor
上挂载以节点 type
命名的方法,当遍历 AST
的时候,如果匹配上 type
,就会执行对应的方法。
通过上面简单的介绍,我们就可以开始任意造作了,肆意修改 AST
了。先来个简单的例子热热身。
箭头函数是 ES5
不支持的语法,所以 Babel
得把它转换成普通函数,一层层遍历下去,找到了 ArrowFunctionExpression
节点,这时候就需要把它替换成 FunctionDeclaration
节点。所以,箭头函数可能是这样处理的:
import * as t from "@babel/types";
const visitor = {
ArrowFunction(path) {
path.replaceWith(t.FunctionDeclaration(id, params, body));
}
};
在开始写代码之前,我们还有一些事情要做一下:
将「原代码」和「目标代码」都解析成 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
节点,这个节点有 type
、id
、init
等信息,其中 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
中是否含有引用 axuebin
包的节点
如果没引用,则修改 AST
,插入一个 ImportDeclaration
节点
我们来分析一下 test.js
的 AST
,看一下这几个节点有什么特征:
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
而言,我们需要关心的特征是,它是否是一个 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);
}
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
Babel 用户手册
Babel 插件手册
ast 分析
节点规范
最后
欢迎加我微信(winty230),拉你进技术群,长期交流学习...
欢迎关注「前端Q」,认真学前端,做个专业的技术人...
点个在看支持我吧