共计 6778 个字符,预计需要花费 17 分钟才能阅读完成。
前言
Babel
是一个通用的多功能的 JavaScript
编译器,让一些新版本的语法或者语法糖能够在低版本的浏览器上跑起来。
它有三个次要解决步骤 Parse -> Transform -> Generate。
在 Transform 转换过程中通过将插件(或预设)利用到配置文件来启用代码转换。
AST
编写 Babel
插件非常复杂,须要有相当的基础知识,在讲插件之前必须得提起 AST
的概念。AST
全称 Abstract Syntax Tree
形象语法树,这棵树定义了代码的构造,通过操作这棵树的增删改查实现对代码的变动和优化,并最终在 Generate 步骤构建出转换后的代码字符串。
astexplorer 是一款十分好用的在线转换工具,能够帮忙咱们更直观的意识到 AST
节点。
function square(n) {return n * n;}
通过网站解析后,失去
{
"type": "Program",
"start": 0,
"end": 38,
"body": [
{
"type": "FunctionDeclaration",
"start": 0,
"end": 38,
"id": {
"type": "Identifier",
"start": 9,
"end": 15,
"name": "square"
},
"expression": false,
"generator": false,
"async": false,
"params": [
{
"type": "Identifier",
"start": 16,
"end": 17,
"name": "n"
}
],
"body": {
"type": "BlockStatement",
"start": 19,
"end": 38,
"body": [
{
"type": "ReturnStatement",
"start": 23,
"end": 36,
"argument": {
"type": "BinaryExpression",
"start": 30,
"end": 35,
"left": {
"type": "Identifier",
"start": 30,
"end": 31,
"name": "n"
},
"operator": "*",
"right": {
"type": "Identifier",
"start": 34,
"end": 35,
"name": "n"
}
}
}
]
}
}
],
"sourceType": "module"
}
这里不是本文的重点,大略相熟下数据结构就行,前面实例中用到了会再具体解说。
简介
visitor
转换阶段 @babel/traverse
会遍历拜访每一个 AST
节点传递给插件,插件依据须要抉择感兴趣的节点进行转换操作,这种设计模式称为 访问者模式 (visitor)。
这样做的益处是:
- 对立执行遍历操作
- 对立执行节点的转换方法
设想一下,Babel 有那么多插件,如果每个插件本人去遍历 AST,对不同的节点进行不同的操作,保护本人的状态。这样子不仅低效,它们的逻辑扩散在各处,会让整个零碎变得难以了解和调试。
来看一个最简略的插件构造:
export default function({types: t}) {
return {
visitor: {Identifier(path, state) {console.log(path, state);
},
}
};
};
它在每次进入一个标识符 Identifier
的时候会打印以后的 path
和 state
。
留神:
Identifier() {}
↑ 简写
Identifier: {enter() {}}
如果须要拜访到残缺的生命周期(蕴含来到事件),应用如下写法:
Identifier: {enter() {console.log('Entered!');
},
exit() {console.log('Exited!');
}
}
@babel/traverse
遍历并负责替换、移除和增加 AST
节点。
path
示意节点之间的关联关系,详见 path 源码:
// 数据
{"parent": {...}, // 父节点数据结构
"parentPath": null, // 父节点门路
"node": {...}, // 节点数据结构
"scope": null, // 作用域
... 等等
}
// 办法
{
"remove", // 移除以后节点
"replaceWith", // 替换以后节点
... 等等
}
state
用来拜访插件信息、配置参数,也能够用来存储插件处理过程中的自定义状态。
@babel/types
蕴含了结构、验证、变换 AST
节点的办法的工具库。
咱们以上述 square 办法为例,写一个把 n 重命名为 x 的访问者的疾速实现:
enter(path) {if (path.node.type === 'Identifier' && path.node.name === 'n') {path.node.name = 'x';}
}
联合 @babel/types
能够更简洁且语义化:
import * as t from '@babel/types';
enter(path) {if (t.isIdentifier(path.node, { name: 'n'})) {path.node.name = 'x';}
}
实例
babel-plugin-import
应用过 react
组件库 ant-design
或者 vue
组件库 vant
的小伙伴肯定都不会对按需引入 (import-on-demand) 这个概念生疏,具体概念文档可见 antd 按需加载、vant 疾速上手,都举荐应用 babel-plugin-import
这款插件反对主动按需。
这里须要留神的是,大部分构建工具反对对 ESM
产物基于 Tree Shaking
的按需加载,那么这个插件是不是曾经无用武之地了?
答案是否定的:
Tree Shaking
收到简单环境影响(如副作用)导致失败- 构建工具无
Tree Shaking
或 组件库无ESM
产物 css
得手动按需屡次引入
讲完了它的不可替代性,接下来咱们看看这个插件做了什么
// 在.babelrc 中增加配置
{
"plugins": [
["import", {
"libraryName": "vant",
"libraryDirectory": "es",
"style": true
}]
]
}
import {Button} from 'vant';
↓ ↓ ↓ ↓ ↓ ↓
import "vant/es/button/style";
import _Button from "vant/es/button";
如果去掉插件成果会怎么样呢?
import {Button} from 'vant';
↓ ↓ ↓ ↓ ↓ ↓
var _vant = require("vant");
var Button = _vant.Button;
能够显著看到会将整个组件库全副引入,重大影响了包大小。
铺垫了这么多,进入主题剖析源码吧。先晓得须要做什么,从树上收集到要害一些关键字 ImportDeclaration
、specifiers.local.name
、source.value
针对这些要害节点,开始做状态收集,源码如下:
ImportDeclaration(path, state) {const { node} = path;
// path maybe removed by prev instances.
if (!node) return;
const {value} = node.source;
const {libraryName} = this;
// @babel/types 工具库
const {types} = this;
// 外部保护的状态
const pluginState = this.getPluginState(state);
if (value === libraryName) {
node.specifiers.forEach(spec => {if (types.isImportSpecifier(spec)) {pluginState.specified[spec.local.name] = spec.imported.name;
} else {pluginState.libraryObjs[spec.local.name] = true;
}
});
pluginState.pathsToRemove.push(path);
}
}
剖析得出,做了以下几件事:
- 判断引入的包名是否与参数 libraryName 雷同
- 遍历
specifiers
关键字,判断是否ImportSpecifier
类型(大括号形式),别离存入不同的外部状态 - 将以后节点存入外部状态,最初对立删除
收集完状态后,寻找所有可能援用到 Import
的节点,对他们所有进行解决。因为须要判断的节点太多,这里不多做赘述,波及到的能够查看源码如下:
const methods = [
'ImportDeclaration',
'CallExpression', // 函数调用表达式 React.createElement(Button)
'MemberExpression', // 属性成员表达式 vant.Button
'Property', // 对象属性值 const obj = {btn: Button}
'VariableDeclarator', // 变量申明 const btn = Button
'ArrayExpression', // 数组表达式 [Button, Input]
'LogicalExpression', // 逻辑运算符表达式 Button || 1
'ConditionalExpression', // 条件运算符 true ? Button : Input
'IfStatement',
'ExpressionStatement', // 表达式语句 module.export = Button
'ReturnStatement',
'ExportDefaultDeclaration',
'BinaryExpression', // 二元表达式 Button | 1
'NewExpression',
'ClassDeclaration', // 类申明 class btn extends Button {}
'SwitchStatement',
'SwitchCase',
];
一些显著能看懂的办法名就不一一正文了,须要特地阐明的是非大括号形式的状态会在 MemberExpression
办法中将 vant.Button 转为 _Button,
import vant from 'vant'; // 对应 pluginState.libraryObjs
const Button = vant.Button;
↓ ↓ ↓ ↓ ↓ ↓
import "vant/es/button/style";
import _Button from "vant/es/button";
const Button = _Button;
这些办法最终都会调用 importMethod 函数,它承受 3 个参数:
- methodName 原组件名
- file 以后文件 path.hub.file
- pluginState 外部状态
importMethod(methodName, file, pluginState) {if (!pluginState.selectedMethods[methodName]) {const { style, libraryDirectory} = this;
const transformedMethodName = this.camel2UnderlineComponentName // eslint-disable-line
? transCamel(methodName, '_')
: this.camel2DashComponentName
? transCamel(methodName, '-')
: methodName;
const path = winPath(
this.customName
? this.customName(transformedMethodName, file)
: join(this.libraryName, libraryDirectory, transformedMethodName, this.fileName), // eslint-disable-line
);
pluginState.selectedMethods[methodName] = this.transformToDefaultImport // eslint-disable-line
? addDefault(file.path, path, { nameHint: methodName})
: addNamed(file.path, methodName, path);
if (this.customStyleName) {const stylePath = winPath(this.customStyleName(transformedMethodName, file));
addSideEffect(file.path, `${stylePath}`);
} else if (this.styleLibraryDirectory) {
const stylePath = winPath(join(this.libraryName, this.styleLibraryDirectory, transformedMethodName, this.fileName),
);
addSideEffect(file.path, `${stylePath}`);
} else if (style === true) {addSideEffect(file.path, `${path}/style`);
} else if (style === 'css') {addSideEffect(file.path, `${path}/style/css`);
} else if (typeof style === 'function') {const stylePath = style(path, file);
if (stylePath) {addSideEffect(file.path, stylePath);
}
}
}
return {...pluginState.selectedMethods[methodName] };
}
剖析得出,做了以下几件事:
- 通过 methodName 进行去重,确保 importMethod 函数不会被屡次调用
- 对组件名 methodName 进行转换
- 依据不同配置生成
import
语句和import
款式
这里还用到了 babel 官网的辅助函数包 @babel/helper-module-imports 办法 addDefault
、addNamed
、addSideEffect
,具体作用如下:
import {addDefault} from "@babel/helper-module-imports";
// If 'hintedName' exists in scope, the name will be '_hintedName2', '_hintedName3', ...
addDefault(path, 'source', { nameHint: "hintedName"})
↓ ↓ ↓ ↓ ↓ ↓
import _hintedName from "source"
import {addNamed} from "@babel/helper-module-imports";
// if the hintedName isn't set, the function will gennerate a uuid as hintedName itself such as'_named'addNamed(path,'named','source');
↓ ↓ ↓ ↓ ↓ ↓
import {named as _named} from "source"
import {addSideEffect} from "@babel/helper-module-imports";
addSideEffect(path, 'source');
↓ ↓ ↓ ↓ ↓ ↓
import "source"
最初,在 exit 来到事件中做好善后工作,删除掉旧的 import
导入。
ProgramExit(path, state) {this.getPluginState(state).pathsToRemove.forEach(p => !p.removed && p.remove());
}
总结一下整个 babel-import-plugin
的流程:
- 解析
import
引入的所有标识符并外部缓存 - 枚举所有可能应用到这些标识符的节点,去匹配缓存中的标识符
- 匹配胜利则用辅助函数生成新的节点
- 对立删除旧节点
附言
写这篇文章的初衷是因为本系列的兄弟篇:
编写 webpack 的 loader 和 plugin(附实例)
编写 markdown-it 的插件和规定
都曾经写完了(顺便安利一波😁),怎么能没有弱小的 babel
篇呢。通过上述例子也能够看出,每个 plugin 都要思考到各种简单状况生成的不同 AST
树,须要大量的常识储备,不同于之前的文章,自己没有在我的项目中实际过本人的 babel plugin
实例,心愿之后能有机会补上。
参考文档
babel 插件手册