Статьи

Понимание AST путем создания собственного плагина Babel

Эта статья была рецензирована Тимом Севериеном . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!

Каждый день тысячи разработчиков JavaScript используют версии языка, которые производители браузеров еще даже не внедрили. Многие из них используют языковые функции, которые являются не чем иным как предложениями, без гарантии, что они когда-либо войдут в спецификацию. Все это стало возможным благодаря проекту Babel .

Babel наиболее известен тем, что он способен преобразовывать код ES6 в код ES5, который мы можем безопасно выполнять сегодня, однако он также позволяет разработчикам создавать плагины, которые преобразуют структуру программ JavaScript во время компиляции.

Сегодня мы рассмотрим, как мы можем написать плагин Babel для добавления неизменяемых данных по умолчанию в 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 !

Вавилон Обзор

Если мы посмотрим на поверхность Вавилона, мы найдем три важных инструмента, которые обрабатывают большую часть процесса.

Вавилонский процесс

Анализировать

Babylon — это синтаксический анализатор, и он понимает, как взять строку кода JavaScript и превратить ее в удобное для компьютера представление, называемое абстрактным синтаксическим деревом (AST).

преобразование

Модуль babel-traverse позволяет исследовать, анализировать и, возможно, изменять AST.

генерировать

Наконец, модуль babel-генератора используется для превращения преобразованного AST в обычный код.

Что такое АСТ?

Крайне важно, чтобы мы поняли цель AST, прежде чем продолжить этот урок. Итак, давайте погрузимся, чтобы увидеть, что они и зачем они нам нужны.

Программы на JavaScript обычно состоят из последовательности символов, каждая из которых имеет некоторое визуальное значение для нашего человеческого мозга. Это очень хорошо работает для нас, поскольку позволяет нам использовать совпадающие символы ( []{}()''""

Тем не менее, это не очень полезно для компьютеров. Для них каждый из этих символов — просто числовое значение в памяти, и они не могут использовать их, чтобы задавать вопросы высокого уровня, такие как «Сколько переменных в этом объявлении?». Вместо этого нам нужно пойти на компромисс и найти способ превратить наш код в нечто, что мы можем программировать, и компьютеры могут понять .

Посмотрите на следующий код.

 var a = 3;
a + 5

Когда мы генерируем AST для этой программы, мы получаем структуру, которая выглядит следующим образом:

Пример AST

Все AST начинаются с узла Program В этом случае у нас есть только два:

  1. VariableDeclarationVariableDeclaratorIdentifieraNumericLiteral3
  2. ExpressionStatementBinaryExpressionIdentifiera+NumericLiteral5

Несмотря на то, что они состоят из простых строительных блоков, размер AST означает, что они часто довольно сложны, особенно для нетривиальных программ. Вместо того, чтобы пытаться выяснить AST самостоятельно, мы можем использовать astexplorer.net , который позволяет нам вводить JavaScript слева, а затем выводить исследуемое представление AST справа. Мы будем использовать этот инструмент исключительно для понимания и экспериментов с кодом по мере продолжения.

Чтобы соответствовать Babel, убедитесь, что вы выбрали «babylon6» в качестве парсера.

При написании плагина Babel наша задача — взять AST, а затем вставить / переместить / заменить / удалить некоторые узлы, чтобы создать новый AST, который можно использовать для генерации кода.

Настроить

Убедитесь, что у вас установлен nodenpm Затем создайте папку для проекта, создайте файл 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: {

    }
  };
};

Эта функция предоставляет интерфейс для шаблона посетителя , к которому мы еще вернемся.

Наконец, мы создадим раннер, который мы сможем использовать для тестирования нашего плагина.

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

Мы можем вызвать этот скрипт с именем примера файла MoriScript, чтобы проверить, генерирует ли он ожидаемый нами JavaScript. Например, node run.js example.ms

Массивы

Первой и главной целью MoriScript является преобразование литералов Object и Array в их аналоги Mori: HashMaps и Vectors. Сначала мы рассмотрим массивы, так как они немного проще.

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

Вставьте код сверху в astexplorer и выделите литерал массива [1, 2, 3]

Для удобства чтения мы опустим поля метаданных, о которых нам не нужно беспокоиться.

 {
  "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
    }
  ]
}

Если мы выразим это визуально, мы получим лучшее представление о том, что нужно изменить между двумя деревьями.

Массив АСТ

Теперь мы можем ясно видеть, что нам нужно заменить выражение верхнего уровня, но мы сможем разделить числовые литералы между двумя деревьями.

Давайте начнем с добавления метода ArrayExpression

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

      }
    }
  };
};

Когда Бабель пересекает AST, он просматривает каждый узел и, если он находит соответствующий метод в объекте посетителя нашего плагина, он передает контекст в метод, чтобы мы могли анализировать или манипулировать им.

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

Мы можем найти документацию для каждого типа выражения с пакетом babel-types. В этом случае мы собираемся заменить ArrayExpressionCallExpressiont.callExpression(callee, arguments) То, что мы собираемся вызвать, это MemberExpressiont.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
      }
    }
  ]
}

Это довольно просто. Существует множество свойств, каждое из которых имеет ключ и значение. Теперь давайте 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
}
]
}

 CallExpression

Опять же, давайте также посмотрим на визуальное представление этих AST.

Объект АСТ

Как и раньше, у нас есть MemberExpressionObjectExpression: 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);

 MemberExpressions

Наконец, мы создадим вспомогательную функцию для создания Mori 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;

Вы должны увидеть следующий вывод на терминал:

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

присваивание

Чтобы наши новые структуры данных Mori были эффективными, нам также придется переопределить собственный синтаксис для назначения им новых свойств.

 AssignmentExpression

Вместо того, чтобы продолжать включать упрощенный AST, мы пока просто поработаем с диаграммами и кодом плагина, но не стесняйтесь продолжать эти примеры через astexplorer .

Назначение АСТ

Нам нужно будет извлечь и перевести узлы с каждой стороны CallExpressionAssignmentExpression: 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

Наш обработчик для MemberExpressionvar a = 3CallExpression Затем мы заменяем его новым assoc

Как и раньше, мы также должны обрабатывать случаи, когда используется IdentifierStringLiteral

Теперь создайте еще один контрольный пример и запустите код, чтобы увидеть, работает ли он:

 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.

Член АСТ

Мы можем почти напрямую использовать свойства MemberExpressionIdentifier

 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 Это потому, что мы хотим, чтобы наш метод посетителя AssignmentExpression

Это выглядит хорошо, но если вы запустите этот код, вы фактически окажетесь с ошибкой переполнения стека. Это потому, что когда мы заменяем данное MemberExpressionfoo.barmori.get Затем Babel обходит этот новый узел и рекурсивно передает его обратно в наш метод посетителя.

Хм.

Чтобы обойти это, мы можем пометить возвращаемые значения из moriMethodMemberExpression

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

  expr.isClean = true;
  return expr;
}

Как только он будет помечен, мы можем добавить еще одно предложение возврата в нашу функцию.

 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, но вместо этого имеет неизменные структуры данных по умолчанию, без ущерба для исходного выразительного синтаксиса.

Вывод

Это был довольно сложный пост, но мы рассмотрели все основы проектирования и создания плагина Babel, который можно использовать для преобразования файлов JavaScript полезным способом. Вы можете поиграть с MoriScript в REPL здесь и найти полный исходный код на GitHub .

Если вам интересно идти дальше и вы хотите узнать больше о плагинах Babel, ознакомьтесь с фантастическим руководством Babel и обратитесь к репозиторию babel-plugin-hello-world на GitHub. Или просто прочитайте исходный код любого из 700+ плагинов Babel уже на npm . Есть также генератор Yeoman для создания новых плагинов.

Надеюсь, эта статья вдохновила вас на создание плагина Babel! Но прежде чем вы приступите к реализации следующего замечательного языка для переноса, есть несколько основных правил, о которых нужно знать. Babel — это компилятор JavaScript-JavaScript. Это означает, что мы не можем реализовать такой язык, как CoffeeScript, как плагин Babel. Мы можем преобразовать только небольшой расширенный набор 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, которым хотите поделиться? Дай мне знать в комментариях.