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-loader
对 JavaScript
进行编译。
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
节点,这个节点有 type
、id
、init
等信息,其中 id
指的是表达式声明的变量名,init
指的是声明内容。
通过这样查看/对比 AST
结构,就能分析出原代码和目标代码的特点,然后可以开始动手写程序了。
3.2 查看节点规范
我们要增删改节点,当然要知道节点的一些规范,比如新建一个 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 代码
我们这次需要做的事情很简单,做两件事:
- 寻找当前
AST
中是否含有引用 lib 包的节点 - 如果没引用,则修改
AST
,插入一个ImportDeclaration
节点
我们来分析一下 test.js
的 AST
,看一下这几个节点有什么特征:
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
:
简化一下就是这样:
{
"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.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'));
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
插件,它主要就是做了三件事情:
- 收集依赖:找到
importDeclaration
,分析出包a
和依赖b,c,d....
,假如a
和libraryName
一致,就将b,c,d...
在内部收集起来 - 判断是否使用:在多种情况下(比如文中提到的
CallExpression
)判断 收集到的b,c,d...
是否在代码中被使用,如果有使用的,就调用importMethod
生成新的impport
语句 - 生成引入代码:根据配置项生成代码和样式的
import
语句
6. 总结
希望通过本篇文章,可以让大家了解 Babel
其实并没有那么陌生,大家都可以玩起来 ~
参考链接: