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

引言

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 阶段咱们这里不做深究,可以简单地理解为源码-->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 的普通函数,来保证低版本浏览器的兼容性,即完成下列案例中的效果。

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 方法已经不再提倡,更改为 transformSynctransformAsync 方法。详情参见官网 @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 逻辑前,首先补充一些知识

  1. visitor 接受 path 和 state 参数,path 是 AST 节点树中的路径记录者,也就是说通过各节点的 path,搭建起 AST 树,而且 path.node 属性指向当前节点;state 则负责 AST 节点间数据传递。
  2. @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 使用有几个核心要点。

  1. template 接受的第一个参数为 code,即源代码
  2. 提供生成不同 AST 粒度的 API,这里使用 template.expression 返回创建 expression 的 AST
  3. 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.typeArrowFunctionExpression 转换为 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 差异主要在于当只有一行语句时可以省略花括号{}及 returnpath.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: 分析节点写逻辑

  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) 生成作用域内唯一的名字
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 类型推导,后天给它编写一篇文档;其二是,本文篇幅已经较长,后续更深入的内容再单独开篇进行讲解。

插件完整代码

后语

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

如果喜欢小包,可以在 掘金 关注我,同样也可以关注我的小小公众号------小包学前端

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

相关推荐
灵犀学长5 分钟前
解锁HTML5页面生命周期API:前端开发的新视角
前端·html·html5
江号软件分享14 分钟前
轻松解决Office版本冲突问题:卸载是关键
前端
致博软件F2BPM21 分钟前
Element Plus和Ant Design Vue深度对比分析与选型指南
前端·javascript·vue.js
慧一居士1 小时前
flex 布局完整功能介绍和示例演示
前端
DoraBigHead1 小时前
小哆啦解题记——两数失踪事件
前端·算法·面试
一斤代码7 小时前
vue3 下载图片(标签内容可转图)
前端·javascript·vue
中微子7 小时前
React Router 源码深度剖析解决面试中的深层次问题
前端·react.js
光影少年7 小时前
从前端转go开发的学习路线
前端·学习·golang
中微子7 小时前
React Router 面试指南:从基础到实战
前端·react.js·前端框架
3Katrina7 小时前
深入理解 useLayoutEffect:解决 UI "闪烁"问题的利器
前端·javascript·面试