likes
comments
collection
share

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

作者站长头像
站长
· 阅读数 18

引言

Babel 作为前端基建的核心一员,已经广泛应用到各大项目,成为前端世界不可或缺的一部分,这也意味着 Babel 正逐渐转变成我们必不可缺的技能。

很多同学一听到前端基建,就会有几分抵触,感觉距离日常业务开发会有一定距离,Babel 作为基建的一员,也不免披上"难"、"神秘"等诸如此类的面纱,起初的小包也是这么认为的,我一个小小入门前端应该没有了解 Babel 的必要吧。有必要,真的很有必要!!!

最近阅读,看到这样一段话,对小包感触还是挺深的,一起共勉:

阿德勒把这种企图设立种种借口来回避人生课题的情况,叫做“人生谎言”。你之所以不幸并不是因为过去或者环境,更不是因为能力不足,你只不过缺乏"勇气",可以说是缺乏"获得幸福的勇气"。

不能空口断难,要眼见为实,实践才是检验真理的唯一标准,我们一起去看一下发布并广泛应用的 Babel 插件们。打开 npm 官网,搜索 babel-plugin,你会发现大多数的 Babel 插件代码大多都较为简短,100-300 行左右,箭头函数转换babel-plugin-transform-es2015-arrow-functions最为简短,包含空行在内,34 行便可实现。

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

由此可见,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 阶段咱们这里不做深究,可以简单地理解为源码-->ASTAST-->源码的一个互逆过程,至于具体内部如何实现,后续小包会在单独写文章进行详解。

transform 阶段关系到后续插件开发,这里咱们来详细唠唠。

AST 抽象语法树,为了更清晰的分析 transform 阶段的作用,这里咱们就 AST 当成现实中的一颗大树,一颗能确切洞悉每片树叶的树。

面对一颗现实中的树,我们把思维发散开,咱们可以做什么,小包来举几个例子:

  1. 树长得过分枝繁叶茂了,有时候就需要修剪一下多余的小树枝
  2. 挨着检查检查树叶,看看树有没有虫害等其他类似健康状况
  3. 给树嫁接上别的品种树
  4. ...

transform 阶段对 AST 的操作是类似的,通常会有三类操作

  1. 静态分析: 例如 linter、type checker、自动生成 api 文档等,这类操作不会对 AST 进行修改,仅借助 AST 提供的信息——可以类比于虫害健康分析等
  2. 特定用途代码转换: 例如函数插桩、删除 console、自动国际化等,这类操作在保持原 AST 结构的前提上,会做出部分增删改——可以对树就行修剪等操作
  3. 代码转译: 这是最常用的功能,主要将浏览器不兼容和不支持的语法进行转换,例如 ES 新特性、Typescript 等。

transform 阶段的三类操作本质上也就是 Babel 的主要用途,下面咱们借箭头函数转换插件了解其中一类用途。

插件开发分析

箭头函数转换插件完成的是代码转译的部分,将代码中使用的 ES6 箭头函数降级为 ES5 的普通函数,来保证低版本浏览器的兼容性,即完成下列案例中的效果。

// 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输入需要转译的代码对比查看更详细的变动情况。

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

那么插件编写的关键就集中在如何操作 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 中,具体表现大致这样的(以箭头函数转换为例)

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

基于 visitor 模式,Babel plugin 开发实现了节点和逻辑之间的解耦,Babel Plugin 通常有两种格式。

一种是对象,对象中包含 visitor、pre、post 等属性;另一种是函数,返回一个包含 visitor、pre 等属性的对象。函数格式可以接受三个参数,分别为 api--提供 babel 基础 api 能力、options 外界传入插件的参数、dirname 目录名。

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 中的普通函数,具体转换实例:

// input: ES6 箭头函数
var func = (a) => a;

// output: ES5 普通函数
var func = function func(a) {
  return a;
};

首先搭建插件的基本结构,plugin-transform-arrow-functions 插件不需要引入参数,使用对象形式即可。

// 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 方法已经不再提倡,更改为 transformSynctransformAsync 方法。详情参见官网 @babel/core

// 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 变化。

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

本文上面已经多次提到,箭头函数插件转换为: ArrowFunctionExpression 节点转换为 FunctionExpression节点。

在正式编写 visitor 逻辑前,首先补充一些知识

  1. visitor 接受 path 和 state 参数,path 是 AST 节点树中的路径记录者,也就是说通过各节点的 path,搭建起 AST 树,而且 path.node 属性指向当前节点;state 则负责 AST 节点间数据传递。
  2. @babel/types 中提供了各种 AST 节点类型方法,需要节点创建或者删除直接查询文档即可。

那么对于当前插件,我们可以产生两种编写思路:

Case1: 构建新的 FunctionExpression 节点,替换原有节点

FunctionExpression 的结构抽离一下,具体如下

FunctionExpression
|--id
|--params
|--body-BlockStatement
   |--body
      |--ReturnStatement
         |--argument: Identifier

id、params、Identifier(a) 部分参数都未发生改变,这部分通过 path.node 直接获取。

核心内容已经具备了,现在只需要将 FunctionExpression 框架给搭建起来,可以先用伪代码初步设计一下,大致是这样

new FunctionExpression(
  id,
  params,
  new BlockStatement([new ReturnStatement(Identifier("a"))])
);

查阅文档,将伪代码具现

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开发的"面纱"

通过 @babel/types 创建 AST 节点,需要创建每个子项,然后在进行组装,当随着 AST 节点增多或者变复杂,@babel/types 又有些繁琐,这时候就可以使用 @babel/template 批量创建 AST,简化创建逻辑。

@babel/template 使用有几个核心要点。

  1. template 接受的第一个参数为 code,即源代码
  2. 提供生成不同 AST 粒度的 API,这里使用 template.expression 返回创建 expression 的 AST
  3. code 源码中支持设置占位符,具体调用时传入占位符即可
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 中是这样实现的。

node.type = "FunctionExpression";
path.ensureBlock();

不由有点吃惊,更改 node 类型就可以实现节点互换了吗?

Babel 编译流程的第三个阶段为 generate,根据 AST 生成新的代码。Babel 在 generate 阶段为每类 AST 节点都定义了对应的构建函数,当 node.typeArrowFunctionExpression 转换为 FunctionExpression,其构建函数相应改变。

下面展示了@babel/generator 中的部分源代码。

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,转换出来代码会有错误。

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

小包在阅读文章中发现,对于 node.type 部分朋友的使用其实是存在误区的,特别是同类型不同格式节点的转换,单纯修改 node.type 是不够的,在箭头函数转换插件中这种使用频繁出现。至于为什么大家没有发现这个错误,原因也很简单,在某些格式下,转换前后 AST 的格式是相同的。例如这里咱们修改一下箭头函数。

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

插件的开发一定要考虑全面,简单的语法使用前一定要反复测试,斟酌。

箭头函数与普通函数间 body 差异主要在于当只有一行语句时可以省略花括号{}及 returnpath.ensureBlock 是如何弥补这一点的呢?

咱们直接来看源码

ensureBlock 函数小包猜测是为了确保构建块级作用域的。(这点不由得吐槽:traverse 包的文档真是一片空空,很多函数、属性都需要自己去查源码)

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

const body = t.blockStatement([t.returnStatement(node.body)]);

对于其他情形,有可能有些没有概念,小包以 for 循环给大家举个例子:

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

虽然 ensureBlock 方法非常强大,但是它的适用范围毕竟是有限的,换个场景,AST 节点类型发生变化,可能就需要去找其他的方法,鉴于 Babel 文档的不负责任,这不是一个好选择。

咱们可以结合两种思路,通过 node.type 修改类型,来切换 generator 的构建方法,同时对于内部的一些细微变化,提前采用创建节点的方式来弥补,具体看代码。

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 插件集合即可。

// 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;
  };
};

先来测试一下,已经编写的插件的效果。

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

接下来的目标就在于如何处理 this。

Step2: 对比前后 AST

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

Step3: 找出节点变化处

具体变动如下:

  • 在父级作用域中添加了 _this 变量声明及初始化,也就是添加 VariableDeclaration
  • this -> _this,由 ThisExpression -> Identifier(_this)

Step4: 分析节点写逻辑

  1. 箭头函数 this 来源于其所在作用域,作用域可以分为函数作用域和全局作用域,因此首先沿箭头函数所在节点,向上查询,找到第一个非箭头函数的 this 或者全局 this,也就是 (path.isFunction() && !path.isArrowFunctionExpression()) || path.isProgram()
  2. 在找到的作用域中创建变量 _this -> this
  3. 从当前 ArrowFunctionExpression 节点开始遍历,寻找 this,进行替换

再写之前,还是先补充几个知识点

  1. path 上有 scope 属性,该属性存储了作用域的各种信息

  • scope.bindings 存储了作用域中的变量信息
  • scope.hasBinding(name) 查找当前作用域是否存在 name 变量
  • scope.block 存储了生成作用域的 block 节点信息
  • scope.push({id, init: AST}) 在当前作用域内添加一个 VariableDeclaration 变量
  • scope.generateUidIdentifier(str) 生成作用域内唯一的标识符,返回 Identifier 节点
  • scope.generateUid(name) 生成作用域内唯一的名字
/**
 * 处理箭头函数中的 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。

从箭头函数转换插件开始,带你揭开Babel开发的"面纱"

对于上面的案例来说,当前的插件代码的确是没有问题的,但插件开发,一定要切记,测试要全面。

如果这里把测试代码稍微一修改,改成下面这样

// == 转换前 ==
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,这代码可读性不敢想。

path.scope.generateUidIdentifier("uid");
path.scope.generateUid("uid");

这个问题解决并不复杂,每次使用时将 thisBinding 的值设置为独一无二的即可,Babel 提供了 generateUidIdentifier 和 generateUid 来实现这个功能。

这里要注意一下 generateUidIdentifier 返回 Identifier 格式节点{type: 'identifier', name: '_this'},如果使用该方法就不需要 t.identifier 重复创建标识。

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));
    });
  }
}

代码会和预想的一样成功吗?

== 转换后 ==
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 源码

/**
 * 检查作用域内是否存在 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 判断。

thisContext.scope.hasBinding(thisBinding, { noUids: true });

下面再升级一下测试代码

// == 转换前 ==
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 所生成作用域即可、

function getUseThisPaths(nodePath) {
  const thisPaths = [];
  nodePath.traverse({
    ThisExpression(path) {
      block = path.scope.path;
      if (path.scope.path === nodePath) thisPaths.push(path);
    },
  });
  return thisPaths;
}

测试一下,转换后的代码就没问题了。

// == 转换后 ==
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 类型推导,后天给它编写一篇文档;其二是,本文篇幅已经较长,后续更深入的内容再单独开篇进行讲解。

插件完整代码

后语

我是 战场小包 ,一个快速成长中的小前端,希望可以和大家一起进步。

一路加油,冲向未来!!!