引言
Babel 作为前端基建的核心一员,已经广泛应用到各大项目,成为前端世界不可或缺的一部分,这也意味着 Babel 正逐渐转变成我们必不可缺的技能。
很多同学一听到前端基建,就会有几分抵触,感觉距离日常业务开发会有一定距离,Babel 作为基建的一员,也不免披上"难"、"神秘"等诸如此类的面纱,起初的小包也是这么认为的,我一个小小入门前端应该没有了解 Babel 的必要吧。有必要,真的很有必要!!!
最近阅读,看到这样一段话,对小包感触还是挺深的,一起共勉:
阿德勒把这种企图设立种种借口来回避人生课题的情况,叫做"人生谎言"。你之所以不幸并不是因为过去或者环境,更不是因为能力不足,你只不过缺乏"勇气",可以说是缺乏"获得幸福的勇气"。
不能空口断难,要眼见为实,实践才是检验真理的唯一标准,我们一起去看一下发布并广泛应用的 Babel 插件们。打开 npm 官网,搜索 babel-plugin,你会发现大多数的 Babel 插件代码大多都较为简短,100-300 行左右 ,箭头函数转换babel-plugin-transform-es2015-arrow-functions最为简短,包含空行在内,34 行便可实现。
由此可见,Babel 并没有想象中那么高大上,只要咱们静下心来,慢慢体悟,也不过 paper tiger 一只罢了。
下面跟随小包一起来解开 Babel 的伪装,开启愉快的 Babel 开发之旅。
友情提示: 文章以箭头函数转换为例,囊括了小包思考的全流程,并将插件开发流程总结为顺口溜。篇幅较长,内容很多,希望大家能一起来动手尝试。
Babel 编译流程
Babel 本质是一个转换编译器,简单来说就是将一种编程语言转换成另一种编程语言。
这个转换过程也就是 Babel 的编译过程,可以分为三步:
- parse 阶段: 通过 parser 将编程语言的源码转换成抽象语法树(AST)
- transform 阶段: 遍历 AST,调用 transform 插件对 AST 节点进行增删改等操作
- generate: 把经过 transform 转换后的 AST 转换为目标代码,并生成 sourcemap
计算机是无法直接理解编程语言的源码的,所以首先需要将源码转换成计算机可以理解的数据格式,也就是 AST,借助 AST,计算机才能间接理解源码,而日常开发又不可能面向 AST,计算机操作完 AST 后,需要再转成编程语言,这也就是为何 Babel 编译过程会分为三步。
parse 阶段和 generate 阶段咱们这里不做深究,可以简单地理解为源码-->AST 和AST-->源码的一个互逆过程,至于具体内部如何实现,后续小包会在单独写文章进行详解。
transform 阶段关系到后续插件开发,这里咱们来详细唠唠。
AST 抽象语法树,为了更清晰的分析 transform 阶段的作用,这里咱们就 AST 当成现实中的一颗大树,一颗能确切洞悉每片树叶的树。
面对一颗现实中的树,我们把思维发散开,咱们可以做什么,小包来举几个例子:
- 树长得过分枝繁叶茂了,有时候就需要修剪一下多余的小树枝
- 挨着检查检查树叶,看看树有没有虫害等其他类似健康状况
- 给树嫁接上别的品种树
- ...
transform 阶段对 AST 的操作是类似的,通常会有三类操作
- 静态分析: 例如 linter、type checker、自动生成 api 文档等,这类操作不会对 AST 进行修改,仅借助 AST 提供的信息------可以类比于虫害健康分析等
- 特定用途代码转换: 例如函数插桩、删除 console、自动国际化等,这类操作在保持原 AST 结构的前提上,会做出部分增删改------可以对树就行修剪等操作
- 代码转译: 这是最常用的功能,主要将浏览器不兼容和不支持的语法进行转换,例如 ES 新特性、Typescript 等。
transform 阶段的三类操作本质上也就是 Babel 的主要用途,下面咱们借箭头函数转换插件了解其中一类用途。
插件开发分析
箭头函数转换插件完成的是代码转译的部分,将代码中使用的 ES6 箭头函数降级为 ES5 的普通函数,来保证低版本浏览器的兼容性,即完成下列案例中的效果。
js
// input: ES6 箭头函数
var func = (a) => a;
// output: ES5 普通函数
var func = function func(a) {
return a;
};
通过对 transform 阶段的分析,可以得知,Babel 操作围绕 AST 展开,前后 AST 变化即 transform 操作所在。
箭头函数转换插件开发就转换成了下面两个问题:
- 如何获取箭头函数转换前后的源码对应 AST
- 如何操作 AST
astexplorer 可以说是在线 AST 转换神器,支持目前主流所有解析器的 AST 转换,Babel 开发不可或缺的强大助手。
下面给出了箭头函数转换前后的 AST 对比,从红色框出的地方可以看出,此处 AST 节点由 ArrowFunctionExpression --> FunctionExpression
,建议同学们自己在 astexplorer输入需要转译的代码对比查看更详细的变动情况。
那么插件编写的关键就集中在如何操作 AST 上,这个完全不用担心,Babel 官方开发了一系列辅助编写插件的包,下面来一起初步了解一下:
@babel/parser
: 顾名思义,负责 parse 阶段的包,默认只能 parse js 代码,支持扩展,通过指定对应语法插件可实现 jsx、ts 等解析。@babel/traverse
: 提供 traverse 方法来负责 AST 的遍历,维护了整颗 AST 树的状态。@babel/generator
:负责 generate 阶段的包,用于将 AST 转换成新的代码。@babel/types
: 包含所有 AST 节点的类型以及检查 AST 类型的方法@babel/core
: Babel 的核心 api,包含了上述所提的所有功能,能完成从源码到目标代码的整个编译流程,本文插件开发就围绕@babel/core
来进行。
根据上面所学,来总结一下目前插件开发有效知识:
- 箭头函数转换前后,AST 树中节点变动为:
ArrowFunctionExpression --> FunctionExpression
@babel/traverse
提供 AST 节点遍历方法,@babel/types
提供 AST 节点创建以及类型检查方法@babel/core
集成了源码->目标代码的全流程
Babel 在 traverse 方法中不仅提供了 AST 的遍历逻辑,同时针对不同的 AST 节点还会触发不同的 visitor 函数来实现对 AST 节点的操作逻辑。这个思想源自于 23 种经典设计模式中的 visitor 模式,visitor 模式的思想是: 当被操作的对象结构比较稳定,而操作对象的逻辑经常变化的时候,通过分离逻辑和对象结构,使得他们能独立扩展。
在 Babel 中,具体表现大致这样的(以箭头函数转换为例)
基于 visitor 模式,Babel plugin 开发实现了节点和逻辑之间的解耦,Babel Plugin 通常有两种格式。
一种是对象,对象中包含 visitor、pre、post 等属性;另一种是函数,返回一个包含 visitor、pre 等属性的对象。函数格式可以接受三个参数,分别为 api--提供 babel 基础 api 能力、options 外界传入插件的参数、dirname 目录名。
js
export default plugin = {
pre() {}, // 遍历前触发的钩子函数
visitor: {},
post(file) {}, // 遍历后触发的钩子函数
};
export default function plugin(api, options, dirname){
return {
pre(){},
visitor: {},
post(){}
}
}
Babel Plugin 本质上就是带有 visitor 属性的对象,traverse 遍历时,根据 visitor 中编写的对应节点逻辑来实现对 AST 节点的增删改,来实现代码层级的工作。
学到这里,想必已经对 Babel Plugin 开发有了一定的概念,这里小包用顺口溜来总结一下通用的开发逻辑
- 对照前后抽象树,找出节点变动处: 通过 astexplorer 分别解析转换前后的源码,找出其中的 AST 节点变动
- 分析变动写逻辑,生成新的 AST: AST 变动处作为 visitor 属性节点,编写逻辑,逻辑编写完毕后,利用 generate 生成新的代码
Babel Plugin 开发起来还是挺有规律的,熟记对比前后抽象树,找出节点变化处;分析节点写逻辑,生成新的 AST口诀,轻松快乐 Babel 开发。
plugin-transform-arrow-functions 插件开发
下面带大家一起来尝试写一下 Babel Plugin,带上口诀,启程。
plugin-transform-arrow-functions
解决是一个代码转译插件,负责将 ES6 中的箭头函数转换为 ES5 中的普通函数,具体转换实例:
js
// input: ES6 箭头函数
var func = (a) => a;
// output: ES5 普通函数
var func = function func(a) {
return a;
};
首先搭建插件的基本结构,plugin-transform-arrow-functions
插件不需要引入参数,使用对象形式即可。
js
// plugin-transform-arrow-functions.js
const ArrowTransformFunctionPlugin = {
visitor: {},
};
module.exports = {
ArrowTransformFunctionPlugin,
};
插件开发后,还需要一个施展平台。
@babel/core
包集成 Babel 的各项基础 API,因此这里就不单独使用三步阶段的 API,直接使用 @babel/core 来完成。
小包阅读社区中的 Babel 文章发现,很多文章由于发文较早,对
@babel/core
的使用还停留在 transform 方法,而对于目前的 Babel 7 来说,transform 方法已经不再提倡,更改为 transformSync 和 transformAsync 方法。详情参见官网 @babel/core
js
// test-plugin.js
const core = require("@babel/core");
const {
ArrowTransformFunctionPlugin,
} = require("./plugin-transform-arrow-functions.js");
const sourceCode = `var func = (a) => a;`;
console.log("== 转换前 ==");
console.log(sourceCode);
const { code } = core.transformSync(sourceCode, {
plugins: [ArrowTransformFunctionPlugin],
});
console.log("== 转换后 ==");
console.log(code);
接下来就进入口诀阶段------对比前后抽象树,找出节点变化处 。利用 astexplorer 观察前后 AST 变化。
本文上面已经多次提到,箭头函数插件转换为: ArrowFunctionExpression
节点转换为 FunctionExpression
节点。
在正式编写 visitor 逻辑前,首先补充一些知识
- visitor 接受 path 和 state 参数,path 是 AST 节点树中的路径记录者,也就是说通过各节点的 path,搭建起 AST 树,而且 path.node 属性指向当前节点;state 则负责 AST 节点间数据传递。
- @babel/types 中提供了各种 AST 节点类型方法,需要节点创建或者删除直接查询文档即可。
那么对于当前插件,我们可以产生两种编写思路:
Case1: 构建新的 FunctionExpression 节点,替换原有节点
将 FunctionExpression
的结构抽离一下,具体如下
js
FunctionExpression
|--id
|--params
|--body-BlockStatement
|--body
|--ReturnStatement
|--argument: Identifier
id、params、Identifier(a)
部分参数都未发生改变,这部分通过 path.node
直接获取。
核心内容已经具备了,现在只需要将 FunctionExpression
框架给搭建起来,可以先用伪代码初步设计一下,大致是这样
js
new FunctionExpression(
id,
params,
new BlockStatement([new ReturnStatement(Identifier("a"))])
);
查阅文档,将伪代码具现
js
const t = require("@babel/types");
const ArrowTransformFunctionPlugin = {
visitor: {
ArrowFunctionExpression(path) {
const { node } = path;
const id = node.id;
const params = node.params;
const body = t.blockStatement([t.returnStatement(node.body)]);
const functionExpression = t.functionExpression(id, params, body);
path.replaceWith(functionExpression);
},
},
};
执行 test-plugin.js
,测试一下
通过 @babel/types
创建 AST 节点,需要创建每个子项,然后在进行组装,当随着 AST 节点增多或者变复杂,@babel/types
又有些繁琐,这时候就可以使用 @babel/template 批量创建 AST,简化创建逻辑。
@babel/template
使用有几个核心要点。
- template 接受的第一个参数为 code,即源代码
- 提供生成不同 AST 粒度的 API,这里使用 template.expression 返回创建 expression 的 AST
- code 源码中支持设置占位符,具体调用时传入占位符即可
js
const ArrowTransformFunctionPlugin = {
visitor: {
ArrowFunctionExpression(path) {
const { node } = path;
const id = node.id;
const params = node.params;
const body = node.body;
const functionExpression = template.expression(
"function %%FUNC_NAME%%(%%PARAMS%%){return %%RETURN_STATE%%}"
)({ FUNC_NAME: id, PARAMS: params, RETURN_STATE: body });
path.replaceWith(functionExpression);
},
},
};
如果没有发生命名冲突,其实 %%
也是可以省略的。
Case2: 复用原 AST 节点
Babel 开发中,提倡尽量复用原来的节点,这一定程度上可以提高插件性能。
babel-plugin-transform-es2015-arrow-functions
中是这样实现的。
js
node.type = "FunctionExpression";
path.ensureBlock();
不由有点吃惊,更改 node 类型就可以实现节点互换了吗?
Babel 编译流程的第三个阶段为 generate,根据 AST 生成新的代码。Babel 在 generate 阶段为每类 AST 节点都定义了对应的构建函数,当 node.type
由 ArrowFunctionExpression
转换为 FunctionExpression
,其构建函数相应改变。
下面展示了@babel/generator 中的部分源代码。
js
function FunctionExpression(node, parent) {
// 函数头
this._functionHead(node, parent);
this.space();
// 函数体
this.print(node.body, node);
}
function _functionHead(node, parent) {
// 是否为 async 寒大虎
if (node.async) {
this.word("async");
this._endsWithInnerRaw = false;
this.space();
}
// function 关键字
this.word("function");
// 是否为 generator 函数
if (node.generator) {
this._endsWithInnerRaw = false;
this.tokenChar(42);
}
this.space();
if (node.id) {
this.print(node.id, node);
}
// 参数
this._params(node, node.id, parent);
// TS 函数
if (node.type !== "TSDeclareFunction") {
this._predicate(node);
}
}
乍一看上来,FunctionExpression 实现已经非常完善了,函数头、参数、函数体都考虑到了,那为何又补充了 path.ensureBlock()
。
我们在对比转换前后 AST 时,箭头函数的 body 为 Identifier,而转换后函数为 BlockStatement,由于我们复用了原箭头函数节点,node.body 并不符合 FunctionExpression
,转换出来代码会有错误。
小包在阅读文章中发现,对于
node.type
部分朋友的使用其实是存在误区的,特别是同类型不同格式节点的转换,单纯修改node.type
是不够的,在箭头函数转换插件中这种使用频繁出现。至于为什么大家没有发现这个错误,原因也很简单,在某些格式下,转换前后 AST 的格式是相同的。例如这里咱们修改一下箭头函数。
插件的开发一定要考虑全面,简单的语法使用前一定要反复测试,斟酌。
箭头函数与普通函数间 body 差异主要在于当只有一行语句时可以省略花括号{}及 return ,path.ensureBlock
是如何弥补这一点的呢?
咱们直接来看源码
ensureBlock
函数小包猜测是为了确保构建块级作用域的。(这点不由得吐槽:traverse 包的文档真是一片空空,很多函数、属性都需要自己去查源码)
js
function ensureBlock() {
// 获取 body 内容
const body = this.get("body");
const bodyNode = body.node;
if (Array.isArray(body)) {
throw new Error("Can't convert array path to a block statement");
}
if (!bodyNode) {
throw new Error("Can't convert node without a body");
}
// 检测是否已经是块级作用域
if (body.isBlockStatement()) {
return bodyNode;
}
const statements = [];
let stringPath = "body";
let key;
let listKey;
// 检测是否为语句
if (body.isStatement()) {
listKey = "body";
key = 0;
statements.push(body.node);
} else {
stringPath += ".body.0";
// 检测是否为函数
// 如果是函数,只能是为箭头函数或者被复用的箭头函数
if (this.isFunction()) {
key = "argument";
// 添加 return 语句
statements.push(returnStatement(body.node));
} else {
// 若不是函数
// 例如for 循环或者 if 循环,后只跟一个语句,省略花括号的情形
key = "expression";
statements.push(expressionStatement(body.node));
}
}
// 块级作用域包裹
this.node.body = blockStatement(statements);
const parentPath = this.get(stringPath);
body.setup(
parentPath,
listKey ? parentPath.node[listKey] : parentPath.node,
listKey,
key
);
return this.node;
}
箭头函数的块级作用域大家看着应该不陌生,跟咱们编写插件的思路是相同的,最外层定义 blockStatement
,内部定义 returnStatement
。
js
const body = t.blockStatement([t.returnStatement(node.body)]);
对于其他情形,有可能有些没有概念,小包以 for 循环给大家举个例子:
虽然 ensureBlock
方法非常强大,但是它的适用范围毕竟是有限的,换个场景,AST 节点类型发生变化,可能就需要去找其他的方法,鉴于 Babel 文档的不负责任,这不是一个好选择。
咱们可以结合两种思路,通过 node.type
修改类型,来切换 generator 的构建方法,同时对于内部的一些细微变化,提前采用创建节点的方式来弥补,具体看代码。
js
node.type = "FunctionExpression";
// 手动修复node.body
if (!t.isBlockStatement(node.body)) {
node.body = t.blockStatement([t.returnStatement(node.body)]);
}
到这里,我们实现了一个初步的箭头函数转换插件,这里来总结一下插件开发的思路和一些经验:
- 围绕口诀: 对比前后抽象树,找出节点变化处;分析节点写逻辑,生成新的 AST。
- 尽量复用原 AST 节点
- 插件的编写要考虑尽可能全面
进一步完善插件
我们初学箭头函数时,有一个点会反复出现,反复被强调------箭头函数没有 this,其 this 需要沿作用域查询得到。
换句话说,箭头函数没有 this,但它内部还是可以使用 this,针对这个情形,我们应该如何完善我们的插件。
Step1: 分析获取转换前后 AST
这里我们使用 @babel/preset-env
来转换生成一下,至于 @babel/preset-env
是啥,且听后文细细讲来,这里把它初步的理解为多个 Babel 插件集合即可。
js
// sourceCode.js
const func = function (a) {
return (a) => {
console.log(this);
return a;
};
};
const core = require("@babel/core");
const babel_preset = require("@babel/preset-env");
const fs = require("fs");
const path = require("path");
const sourceCode = fs.readFileSync(
path.resolve(__dirname, "./sourceCode.js"),
"utf-8"
);
const { code } = core.transformSync(sourceCode, {
presets: [babel_preset],
});
console.log(code);
// code
var func = function func(a) {
var _this = this;
return function (a) {
console.log(_this);
return a;
};
};
先来测试一下,已经编写的插件的效果。
接下来的目标就在于如何处理 this。
Step2: 对比前后 AST
Step3: 找出节点变化处
具体变动如下:
- 在父级作用域中添加了
_this
变量声明及初始化,也就是添加VariableDeclaration
this -> _this
,由ThisExpression -> Identifier(_this)
Step4: 分析节点写逻辑
- 箭头函数 this 来源于其所在作用域,作用域可以分为函数作用域和全局作用域,因此首先沿箭头函数所在节点,向上查询,找到第一个非箭头函数的 this 或者全局 this,也就是
(path.isFunction() && !path.isArrowFunctionExpression()) || path.isProgram()
- 在找到的作用域中创建变量
_this -> this
- 从当前
ArrowFunctionExpression
节点开始遍历,寻找this
,进行替换
再写之前,还是先补充几个知识点
- path 上有 scope 属性,该属性存储了作用域的各种信息
- scope.bindings 存储了作用域中的变量信息
- scope.hasBinding(name) 查找当前作用域是否存在 name 变量
- scope.block 存储了生成作用域的 block 节点信息
- scope.push({id, init: AST}) 在当前作用域内添加一个 VariableDeclaration 变量
- scope.generateUidIdentifier(str) 生成作用域内唯一的标识符,返回 Identifier 节点
- scope.generateUid(name) 生成作用域内唯一的名字
js
/**
* 处理箭头函数中的 this
* @param nodePath 节点路径
*/
function hoistFunctionEnvironment(nodePath) {
// 获取 this 作用域
const thisContext = nodePath.findParent(
(path) =>
(path.isFunction() && !path.isArrowFunctionExpression()) ||
path.isProgram()
);
// 创建变量 _this
let thisBinding = "_this";
// 获取当前 AST 节点中使用 this 的节点
const thisPaths = getUseThisPaths(nodePath);
if (thisPaths.length) {
if (!thisContext.scope.hasBinding(thisBinding)) {
// 定义 var _this = this;
thisContext.scope.push({
id: t.identifier(thisBinding),
init: t.thisExpression(),
});
}
// 替换 this
thisPaths.forEach((thisPath) => {
thisPath.replaceWith(t.identifier(thisBinding));
});
}
}
/**
* 获取箭头函数中使用 this 的节点
* @param nodePath 节点路径
*/
function getUseThisPaths(nodePath) {
const thisPaths = [];
nodePath.traverse({
ThisExpression(path) {
thisPaths.push(path);
},
});
return thisPaths;
}
const ArrowTransformFunctionPlugin = {
visitor: {
ArrowFunctionExpression(path) {
const { node } = path;
hoistFunctionEnvironment(path);
node.type = "FunctionExpression";
// 手动修复node.body
if (!t.isBlockStatement(node.body)) {
node.body = t.blockStatement([t.returnStatement(node.body)]);
}
},
},
};
测试一下,看一下是否成功解决 this。
对于上面的案例来说,当前的插件代码的确是没有问题的,但插件开发,一定要切记,测试要全面。
如果这里把测试代码稍微一修改,改成下面这样
js
// == 转换前 ==
const func = function (a) {
console.log(this);
function aaa() {
console.log(this);
return () => {
console.log(this);
};
}
return (a) => {
console.log(this);
return a;
};
};
// == 转换后 ==
const func = function (a) {
var _this = this;
console.log(this);
function aaa() {
var _this = this;
console.log(this);
return function () {
console.log(_this);
};
}
return function (a) {
console.log(_this);
return a;
};
};
转换的结果从逻辑上是没有问题的,但语义上两个函数都用 _this
,这代码可读性不敢想。
js
path.scope.generateUidIdentifier("uid");
path.scope.generateUid("uid");
这个问题解决并不复杂,每次使用时将 thisBinding 的值设置为独一无二的即可,Babel 提供了 generateUidIdentifier 和 generateUid 来实现这个功能。
这里要注意一下 generateUidIdentifier 返回 Identifier 格式节点
{type: 'identifier', name: '_this'}
,如果使用该方法就不需要 t.identifier 重复创建标识。
js
function hoistFunctionEnvironment(nodePath) {
// 获取 this 作用域
const thisContext = nodePath.findParent(
(path) =>
(path.isFunction() && !path.isArrowFunctionExpression()) ||
path.isProgram()
);
// 借助 generateUid 创建 _thisX
let thisBinding = nodePath.scope.generateUid("_this");
// 获取当前 AST 节点中使用 this 的节点
const thisPaths = getUseThisPaths(nodePath);
if (thisPaths.length) {
// 创建变量 _this
if (!thisContext.scope.hasBinding(thisBinding)) {
// 定义 var _this = this;
thisContext.scope.push({
id: t.identifier(thisBinding),
init: t.thisExpression(),
});
}
// 替换 this
thisPaths.forEach((thisPath) => {
thisPath.replaceWith(t.identifier(thisBinding));
});
}
}
代码会和预想的一样成功吗?
js
== 转换后 ==
const func = function (a) {
console.log(this);
function aaa() {
console.log(this);
return function () {
console.log(_this);
};
}
return function (a) {
console.log(_this2);
return a;
};
};
初一看没错,再一看,_this
和 _this2
的声明去哪里了?仔细一缕逻辑,就可以定位到问题所在:thisContext.scope.hasBinding(thisBinding)。大大的问号?只定义了一个 uid,未定义 binding,为何 hasBinding 函数会返回 true 呐?
Babel 有些疑惑真的没法解,好吧,@babel/traverse 源码见
js
/**
* 检查作用域内是否存在 binding
* @param {*} name binding name 属性
* @param { } noGlobals noUids
*/
hasBinding(
name: string,
opts?: boolean | { noGlobals?: boolean; noUids?: boolean },
) {
if (!name) return false;
// 检查自身作用域
if (this.hasOwnBinding(name)) return true;
{
// TODO: Only accept the object form.
if (typeof opts === "boolean") opts = { noGlobals: opts };
}
// 检查父作用域(递归,直至检查到根作用域)
if (this.parentHasBinding(name, opts)) return true;
// 检查 Uid
if (!opts?.noUids && this.hasUid(name)) return true;
if (!opts?.noGlobals && Scope.globals.includes(name)) return true;
if (!opts?.noGlobals && Scope.contextVariables.includes(name)) return true;
return false;
}
问题找到了,hasBinding 函数竟然同步会判断 Uid,通过 noUids 可以关闭 Uid 判断。
js
thisContext.scope.hasBinding(thisBinding, { noUids: true });
下面再升级一下测试代码
js
// == 转换前 ==
const func = function (a) {
console.log(this);
function aaa() {
console.log(this);
}
return (a) => {
console.log(this);
function ccc() {
console.log(this);
return () => {
console.log(this);
};
}
return a;
};
};
// == 转换后 ==
const func = function (a) {
var _this = this;
console.log(this);
function aaa() {
console.log(this);
}
return function (a) {
console.log(_this);
function ccc() {
console.log(_this);
return function () {
console.log(_this);
};
}
return a;
};
};
可以发现 ccc 函数 this 已经被错误修改,连累内部的箭头函数也发生了错误。
原因出在 getUseThisPaths 函数上,每当找到一个箭头函数后,以其为起点开始遍历,找出 this 节点,因此当遇到第一个箭头函数时,会提取到下列 this 节点:自身 this,ccc 函数 this,ccc 函数内部箭头函数 this。
ccc 函数是普通函数,可以构建自身的作用域,自身有 this 属性,因此面对情形,不能一枚的处理,需要单独摘出去。
每次遍历,应该只提取箭头函数作用域的 this,其他情况下的 this 此轮遍历不予处理。
scope.path 属性提供生成作用域的节点对应的 path,因此当遍历到 ThisExpression,只需要判断其 scope.path 是否与 ArrowFunctionExpression 所生成作用域即可、
js
function getUseThisPaths(nodePath) {
const thisPaths = [];
nodePath.traverse({
ThisExpression(path) {
block = path.scope.path;
if (path.scope.path === nodePath) thisPaths.push(path);
},
});
return thisPaths;
}
测试一下,转换后的代码就没问题了。
js
// == 转换后 ==
const func = function (a) {
var _this = this;
console.log(this);
function aaa() {
console.log(this);
}
return function (a) {
console.log(_this);
function ccc() {
var _this2 = this;
console.log(this);
return function () {
console.log(_this2);
};
}
return a;
};
};
到这里,箭头函数转换插件基本上就竣工了,整体实现流程还是比较简单的,推荐大家都按照小包的思路,一起来动手试一试。Babel 开发真的很快乐,也并没有那么难。
插件开发总结
虽然箭头函数转换插件是一个比较简单的 Demo,但麻雀虽小,五脏俱全,完整的 Babel 插件也是类似的,这里再系统的总结一下 Babel 插件的开发流程。
- 需求分析:分析出经过 Babel 转换前后可能存在的代码变动。例如删除 console 语句,代码转换前后区别就在于 console 语句的存在与否。
- 口诀:
- 对比前后抽象树,找出节点变化处:分析出前后源码变化,就可以通过在线astexplorer分析出前后 AST 变化
- 分析节点写逻辑,生成新的 AST: 在尽可能复用原节点的前提,通过代码实现 AST 变化
- 测试阶段:多重案例,反复测试
Babel 插件开发的基本思路其实是非常简单的,本文的目的引领大家开启 Babel 插件开发的大门,很多的 API 这里小包并没有进行详解。主要有两个原因,其一是 Babel 文档不当人,很多 API 都没有提供具体使用方法,小包正在阅读其源码和 TS 类型推导,后天给它编写一篇文档;其二是,本文篇幅已经较长,后续更深入的内容再单独开篇进行讲解。
插件完整代码
后语
我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。
如果喜欢小包,可以在 掘金 关注我,同样也可以关注我的小小公众号------小包学前端。
一路加油,冲向未来!!!