前言

在之前的一遍文章中,我简单地介绍了Babel的一些概念,大家可以从我发表的文章记录中可以找到;但是大家有没有想过自己也能开发Babel插件呢,Babel官方提供很多插件进行新语法的解析,同时它也允许我们自己开发插件,去解析和转换代码。
或许大家在实际开发中也遇到过一下问题:

  • 我们想要对a.b.c中的c进行操作,但是我们可能会担心a.b中的a或b是不存在的,这个时候我们难免会做一下操作:
if (a && a.b && a.b.c) {
  // 对c进行操作
}
  • 当这个对象的层次很深的时候,例如a.b.c.d,那么,这个判断将会更加的冗长,我们不得不写一段很长的判断,非常地不美观。
  • 不过自从optional chaining出现后,我们终于可以优雅地解决这个问题:
if (a.b?.c) {
  // 对c进行操作
}
这里需要配置@babel/plugin-proposal-optional-chaining

但是假设没有这个插件的时候,如果想要达到这种optional chaining的效果,你有没有想过怎么实现呢?
下文将自己实现一个简版optional chaining,达到在if语句中实现optional chaining

预期效果

由于在后面直接添加?是属于不合法的,在编译生成AST的过程中,会提示Unexpected token
因此,为了能间接实现optional chaining,我们需要实现一种特殊的标识$$`,在需要达到可选链效果的值后添加`$$

  • 转换前
const a = { b: { c: '123' } };
if (a.b$$.c) {
  // 其他操作
}
  • 转换后
const a = { b: { c: '123' } };
if (a.b && a.b.c) {
  // 其他操作
}

预备知识

对Babel的AST解析过程有一定的了解,如果不了解的话,可以来这里学习一下

插件的声明

package.json声明你的入口文件

// package.json
{
  "name": "@jg/babel-plugin-tiny-optional-chaining",
  "main": "src/index.js",
  ...
}

入口文件导出方法

// src/index.js
export default function(babel) {
  return {
    name: 'babel-plugin-tiny--optional-chaining',
    visitor: {
    }
  };
}

插件需要返回visitor,而内部将以babel作为入参,后面我们需要用到babel对象的types帮助创建新的节点。

处理AST节点

visitor通过访问器模式达到处理AST语法树上各种类型的节点的目的,我们只需要在visitor中声明需要处理的节点的类型,即可在里面进行进一步的分析:

visitor: {
  MemberExpression: {
    enter(path) {
      // 处理节点
    },
    exit() {
    }
  }
}

节点的类型和属性繁多,如果需要更形象的分析节点,可以通过AST explorer来帮助我们。

分析节点

if (a.b$$.c) {}

我们需要解析的表达式a.b$$.c是一个MemberExpression节点,而且它必须是包裹在if语句中,从AST explorer中可以看出,它们的层次结构是这样的:
image

所以我们需要判断该MemberExpression是否在IfStatement中:

MemberExpression: {
  enter(path) {
    if (types.isIfStatement(path.parent)) {
      // 其他操作
    }
  }
}

进一步分析发现,转换后的AST结构是这样的:
image
MemberExpression会被LogicalExpression包裹,而且LogicalExpression会存在两个MemberExpression,分别为leftright节点,这两个节点不是新增的,可以从转换前的MemberExpression的子节点中拿到它们的引用,所以在我们构造新的LogicalExpression节点时,可以复用MemberExpression节点。
不过在新增LogicalExpression节点前,我们首先要将所有的$$符号去掉:

function transformOptionValue(value) {
  return value.replace(/\$\$$/, '');
}

... 其他代码

let currentNode = path.node;
const members = [];
const options = [];

while(types.isMemberExpression(currentNode)) {
  if (types.isLiteral(currentNode.property)) {
    const literalNode = currentNode.property;
    if (literalNode.value.endsWith('$$')) {
      const value = transformOptionValue(literalNode.value);
      literalNode.value = value;
      literalNode.raw = value;
      options.push(currentNode);
    }
  } else if (types.isIdentifier(currentNode.property)) {
    const identifierNode = currentNode.property;
    if (identifierNode.name.endsWith('$$')) {
      const name = transformOptionValue(identifierNode.name);
      identifierNode.name = name;
      options.push(currentNode);
    }
  }

  members.push(currentNode);

  currentNode = currentNode.object;
}

这里membersoptions两个数组是用来保存MemberExpression中的所有Member表达式以及被声明为optional chaining的节点。
在我们的例子中:

if (a.b$$.c) {}
  • members数组有:a.b.ca.b以及a,但是上面的代码并没有处理aa是需要单独处理的,相关的代码这里不贴出来了,大家可以看文末的源码地址。
  • options数组有:b

构造节点

现在我们已经知道了整个optional chaining表达式中有多少membersoptions,现在就通过遍历options数组递归构造LogicalExpression

if (options.length > 0) {
  const root = recursiveLogicalExpression(types, options, members);

  path.replaceWith(root);
}

// 递归构造新节点
function recursiveLogicalExpression(types, options, members) {
  const node = members.pop();
  if (members.length >= 1) {
    if (options.indexOf(node) > -1) {
      return types.logicalExpression('&&', node, recursiveLogicalExpression(types, options, members));
    } else {
      return recursiveLogicalExpression(types, options, members);
    }
  } else {
    return node;
  }
}

这里刚好member数组中节点的顺序和LogicalExpressionMemberExpression的顺序是反序的,所以需要从数组最后一位开始取节点。

到这里,核心功能基本已经完成了。

测试

这里我们运行了三个单元测试,基本都通过测试:

// Before:
const test = a.b.c.d;
// After:
const test = a.b.c.d;
// Before:
if (a$$.b$$.c.d$$.e) {}
// After:
if (a && a.b && a.b.c.d && a.b.c.d.e) {}
// Before:
if (a.b.c.d.e) {}
// After:
if (a.b.c.d.e) {}

总结

到这里,插件基本已经完成,其实还有很多可以优化的地方,这里先抛砖引玉了;下一步的话,可以着手尝试一些非if语句下的解析等等地扩展,欢迎大家讨论哈。
最后附上源码


Jackie
122 声望4 粉丝