通过写Babel插件理解抽象语法树(翻译)

原文:https://www.sitepoint.com/understanding-asts-building-babel-plugin/

每天很多JavaScript开发者使用了浏览器提供商还没有实现的语言版本。很多语言特性都只是草案,将来并不能保证会被写进标准。因为了有了Bable项目,才成为了可能。

众所周知,Babel可以把ES6代码转换为ES5代码,并且可以安全运行;同时,Bable允许开发者编写插件在编译时更改JavaScript程序的结构。

今天,我们将看下如何写Bable插件为JavaScript添加默认的不可变数据。这个教程的代码可以从 GitHub仓库下载。

语言概述

我们希望设计允许我们把普通的对象和数组字面量通过使用Mori转换为不可变的数据结构的插件。

我希望这样写代码:

var foo = { a: 1 };
var baz = foo.a = 2;
foo.a === 1;
baz.a === 2;

转成这样的代码:

var foo = mori.hashMap('a', 1);
var baz = mori.assoc(foo, 'a', 2);
mori.get(foo, 'a') === 1;
mori.get(baz, 'a') === 2;

我们借助MoriScript开始吧。

Babel概览

如果我们要深入理解Bable,将会有三个非常重要的工具辅助去理解主要流程。

1551075528506-b7216a3b-06d7-42ce-bf58-aa

解析

Babylon  是一个解析器,它可以将 JavaScript 字符串转换为对计算机来说更加友好的表现形式,称之为抽象语法树(AST)。

转换

 babel-traverse 允许你查找,分析和修改AST。

生成

最后,babel-generator 把转换后的AST再转成普通的代码。

什么是AST?

想继续本教程,需要先理解AST。让我们陷入深入理解他们以及为什么需要他们。

JavaScript 程序是由字符序列组成的,对于人类大脑来说是有视觉意义的。 这些对于我们是非常友好的,允许我们使用匹配字符([]{}()), 成对字符(''"") 和字符缩进,使得程序更加容易理解。

但是,对于计算机来说不是那么有帮助。对于计算机,每个字符在内存中仅仅是一个数字,它们不会问一些像“在这里声明了多少变量?”这样的高级问题。相反,我们要通过一种方式把我们的代码转换为程序和计算机可以理解的东西。

看下下面的代码:

var a = 3;
a + 5

当为程序生成AST,我们得到一个这样的结构:

1551075528220-48e97258-092d-4cff-bc32-a8

所有的AST都是以一个 Program 为根节点的树。 它包含了我们程序中的所有的顶级语句。在这个例子中,包含两部分:

声明一个变量并赋值。为标识符"a"赋值为数字字面量"3

1.(VariableDeclaration with one VariableDeclarator that assigns the Identifier "a" to the NumericLiteral "3".)

2.一个二元表达式语句。一个标识符"a",一个操作符"+" 和一个数字字面量"5"。

(An ExpressionStatement which is in turn is made up of a BinaryExpression, which is described as an Identifier "a", an operator "+" and another NumericLiteral "5")

虽然他们是由简单的块构成,但是对于AST来说通常会非常复杂,特别是一些特殊项目。我们不要自己去生成AST,我么可以通过 astexplorer.ne,允许我们在左边输入JavaScript,然后在右边输入AST。我们将使用这个工具理解和实验代码。

和Babel保持一致,确保使用的是“babylon6” 作为解析器。

当我们写Babel插件的时候,我们的工作就是在AST上insert/move/replace/delete一些node,然后生成一个新的AST,再生成代码。

安装

开始之前确保你安装了node 和 npm. 创建一个工程目录,创建package.json文件,安装如下的依赖:

mkdir moriscript && cd moriscript
npm init -y
npm install --save-dev babel-core

为插件创建一个文件,在里面导出一个默认函数。

// moriscript.js
module.exports = function(babel) {
  var t = babel.types;
  return {
    visitor: {
    }
  };
};

这个函数为visitor pattern暴露出一个接口,后面我们还会回来修改。

最后我们创建一个执行代码,去运行测试我们的插件。

// run.js
var fs = require('fs');
var babel = require('babel-core');
var moriscript = require('./moriscript');
// read the filename from the command line arguments
var fileName = process.argv[2];
// read the code from this file
fs.readFile(fileName, function(err, data) {
  if(err) throw err;
  // convert from a buffer to a string
  var src = data.toString();
  // use our plugin to transform the source
  var out = babel.transform(src, {
    plugins: [moriscript]
  });
  // print the generated code to screen
  console.log(out.code);
});

我们运行此代码,检查是否是我们期望生成的代码:比如, node run.js example.ms.

数组

MoriScript的首要目标是把对象和数组字面量转换成Mori对应的HashMaps 和 Vectors。我先解决数组的转换,因为比较简单。

var bar = [1, 2, 3];
// should become
var bar = mori.vector(1, 2, 3);

把上面的代码贴到 astexplorer ,选中数组字面量 [1, 2, 3去看对应的 AST 节点.

为了可读性,我们将省略的元数据字段,我们不需要担心这样又问。

{
  "type": "ArrayExpression",
  "elements": [
    {
      "type": "NumericLiteral",
      "value": 1
    },
    {
      "type": "NumericLiteral",
      "value": 2
    },
    {
      "type": "NumericLiteral",
      "value": 3
    }
  ]
}

现在我们按同样的方式处理 mori.vector(1, 2, 3).

{
  "type": "CallExpression",
  "callee": {
    "type": "MemberExpression",
    "object": {
      "type": "Identifier",
      "name": "mori"
    },
    "property": {
      "type": "Identifier",
      "name": "vector"
    }
  },
  "arguments": [
    {
      "type": "NumericLiteral",
      "value": 1
    },
    {
      "type": "NumericLiteral",
      "value": 2
    },
    {
      "type": "NumericLiteral",
      "value": 3
    }
  ]
}

如果我们通过视觉的方式表达,我们会更容易理解如何在这两棵树之间转换。

1551075528837-087f59e5-c776-4901-bf26-c6

现在我们可以很清楚地看到,我们需要更换顶级的表达式,但在两棵树之间我们需要共享数字字面量。

让我们在visator对象中增加一个 ArrayExpression 方法

module.exports = function(babel) {
  var t = babel.types;
  return {
    visitor: {
      ArrayExpression: function(path) {
      }
    }
  };
};

当Babel遍历AST的时候,会查找每一个节点,如果它发现了和插件中的visitor对象匹配的方法,它会把上下文传递到这个方法中,因此我们可以分析和操作它。

ArrayExpression: function(path) {
  path.replaceWith(
    t.callExpression(
      t.memberExpression(t.identifier('mori'), t.identifier('vector')),
      path.node.elements
    )
  );
}

我们可以找到使用babel-types包的所有表达式类型的文档. 在这个例子中,我们将要替 ArrayExpression  为 CallExpression, 这个替换通过 t.callExpression(callee, arguments)实现.

MemberExpression 通过 t.memberExpression(object, property)创建。

你可以在 astexplorer 实时查看效果,通过点击“transform”下拉选项选择“babelv6”.

对象

接下来让我们看下对象的转换。

var foo = { bar: 1 };
// should become
var foo = mori.hashMap('bar', 1);

这个对象字面量和ArrayExpression 有一个类似的简单结构。

{
  "type": "ObjectExpression",
  "properties": [
    {
      "type": "ObjectProperty",
      "key": {
        "type": "Identifier",
        "name": "bar"
      },
      "value": {
        "type": "NumericLiteral",
        "value": 1
      }
    }
  ]
}

这个相当简单。有一个属性数组(properties), 每一个属性有一个key和一个value。现在我们选中对应的Mori调用, mori.hashMap('bar', 1)看下效果:

{
  "type": "CallExpression",
  "callee": {
    "type": "MemberExpression",
    "object": {
      "type": "Identifier",
      "name": "mori"
    },
    "property": {
      "type": "Identifier",
      "name": "hashMap"
    }
  },
  "arguments": [
    {
      "type": "StringLiteral",
      "value": "bar"
    },
    {
      "type": "NumericLiteral",
      "value": 1
    }
  ]
}

我们再次看下AST的视觉表示:

1551075528403-f2f5bf4a-4705-4add-9a5b-52

和之前一样,由一个调用表达式( CallExpression )包裹一个成员表达式( MemberExpression),我们可以借鉴数组的代码,但是我们要做一些更复杂的事情,去获取属性值,并放进一个扁平化的数组内。

ObjectExpression: function(path) {
  var props = [];
  path.node.properties.forEach(function(prop) {
    props.push(
      t.stringLiteral(prop.key.name),
      prop.value
    );
  });
  path.replaceWith(
    t.callExpression(
      t.memberExpression(t.identifier('mori'), t.identifier('hashMap')),
      props
    )
  );
}

对于实现数组来说是非常简单的,另外我们还需要把 Identifier 转换为 StringLiteral ,防止出现下面的代码:

// before
var foo = { bar: 1 };
// after
var foo = mori.hashMap(bar, 1);

最后,我们创建一个帮助函数去生成Mori MemberExpressions 供后面使用。

function moriMethod(name) {
  return t.memberExpression(
    t.identifier('mori'),
    t.identifier(name)
  );
}
// now rewrite
t.memberExpression(t.identifier('mori'), t.identifier('methodName'));
// as
moriMethod('methodName');

现在我们可以创建一些测试的例子,并且运行他们去看下我们的插件是否能够正常运行:

mkdir test
echo -e "var foo = { a: 1 };\nvar baz = foo.a = 2;" > test/case.ms
node run.js test/case.ms

在终端上看下输出是否有问题:

var foo = mori.hashMap("a", 1);
var baz = foo.a = 2;

赋值

为了让新的Mori数据结构生效,我们要覆盖原生的创建新属性的语法。

foo.bar = 3;
// needs to become
mori.assoc(foo, 'bar', 3);

这次不提供AST代码,只提AST图和插件代码。如果想继续看的话可以 astexplorer运行一下。

1551075528266-43704e4f-d888-4225-a72a-4a

我们需要展开和转换每一个AssignmentExpression节点到CallExpression节点。

AssignmentExpression: function(path) {
  var lhs = path.node.left;
  var rhs = path.node.right;
  if(t.isMemberExpression(lhs)) {
    if(t.isIdentifier(lhs.property)) {
      lhs.property = t.stringLiteral(lhs.property.name);
    }
    path.replaceWith(
      t.callExpression(
        moriMethod('assoc'),
        [lhs.object, lhs.property, rhs]
      )
    );
  }
}

我们处理每个赋值表达式 AssignmentExpressions 都要检查左侧的表达式是否为成员表达式 MemberExpression (因为我们不想操作这样的表达式 var a = 3)。此时我们用新的调用表达式 CallExpression 替换成Mori’s assoc 方法。

和之前一样,我也需要处理在使用Identifier 地方替换为 StringLiteral.

Now create another test case and run the code to see whether it works:

现在创建另一个测试用例,并运行看下是如何工作的:

echo -e "foo.bar = 3;" >> test/case.ms
node run.js test/case.ms
$ mori.assoc(foo, "bar", 3);

成员

最后,我们去重写原生语法去访问对象的成员。

foo.bar;
// needs to become
mori.get(foo, 'bar');

这里是AST的可视化呈现。

1551075527786-957c3cb3-cf06-498f-a7ee-fb

我们可以直接是用MemberExpression 的属性,当属性部分是Identifier时,我们需要转换它。

MemberExpression: function(path) {
  if(t.isAssignmentExpression(path.parent)) return;
  if(t.isIdentifier(path.node.property)) {
    path.node.property = t.stringLiteral(path.node.property.name);
  }
  path.replaceWith(
    t.callExpression(
      moriMethod('get'),
      [path.node.object, path.node.property]
    )
  );
}

首先需要注意的最大的不同点在于,当父节点是AssignmentExpression(赋值表达式)时,要尽快结束函数执行。这是因为我们期望visitor的AssignmentExpression方法处理这种情况。

看着上述代码没啥问题,但是当实际运行的时候你回发现会有栈溢出的错误。这是因为当我们把MemberExpression(foo.bar) 替换为另外一种成员表达式mori.get,Babel会把替换后的新节点重新放回vistor方法的递归调用中。

为了解决这个问题,我们可以从moriMethod 返回的值添加一个标记,在MemberExpression方法内遇到这个标记就忽略掉。

function moriMethod(name) {
  var expr = t.memberExpression(
    t.identifier('mori'),
    t.identifier(name)
  );
  expr.isClean = true;
  return expr;
}

Once it’s been tagged, we can add another return clause to our function.

一旦打标,我就可以添加另外一个return语句退出函数的此次执行。

MemberExpression: function(path) {
  if(path.node.isClean) return;
  if(t.isAssignmentExpression(path.parent)) return;
  // ...
}

创建最后的测试用例,编译你的代码去检查是否能够工作:

echo -e "foo.bar" >> test/case.ms
node run.js test/case.ms
$ mori.get(foo, "bar");

一切都看起来正常,你已经获得了一个像JavaScript的语言,不同的是在不改变原有的预发结构的情况下有不可变的数据结构。

结语

这篇博客已代码为主,但是已经涵盖了如何设计和构建Bable插件去转换JavaScript文件的所有基本方式。你可以使用MoriScript去测试一把,你可以在GitHub上找到完整代码。

如果你很有兴趣并且向了解更多关于Babel插件的东西,可以检出非常优秀的 Babel Handbook 和babel-plugin-hello-world github 仓库去学习。或者读NPM上的 700+ Babel plugins源码。也有穿新插件的 Yeoman generator 脚手架。

希望这篇文章能激起你写Babel插件的热情。但是在你去实现下一个伟大的转换语言时,需要注意一些基本原则:Babel紧急是JavaScript转换JavaScript语言的编译器,意思就是我们不能用Babel插件去转换为像CoffeeScript这样的其他语言。我们只能转换JavaScript的超集,因为这样Babel的解析器 才能理解。

这里有一个新的想法需要你去完成,你可以把为运算符|转换为像F#,ELM和LiveScript的管道函数。

2 | double | square
// would become
square(double(2))

或者另一个箭头函数的例子:

const doubleAndSquare = x => x | double | square
// would become
const doubleAndSquare = x => square(double(x));
// then use babel-preset-es2015
var doubleAndSquare = function doubleAndSquare(x) {
  return square(double(x));
};

一旦你理解了这个规则,限制你的只是你的想象力。

是否有Babel插件需要分享?请写在评论中吧。

猜你喜欢

转载自yq.aliyun.com/articles/691614