Эта статья была рецензирована Тимом Севериеном . Спасибо всем рецензентам 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 начинаются с узла Program
В этом случае у нас есть только два:
-
VariableDeclaration
VariableDeclarator
Identifier
a
NumericLiteral
3
-
ExpressionStatement
BinaryExpression
Identifier
a
+
NumericLiteral
5
Несмотря на то, что они состоят из простых строительных блоков, размер AST означает, что они часто довольно сложны, особенно для нетривиальных программ. Вместо того, чтобы пытаться выяснить AST самостоятельно, мы можем использовать astexplorer.net , который позволяет нам вводить JavaScript слева, а затем выводить исследуемое представление AST справа. Мы будем использовать этот инструмент исключительно для понимания и экспериментов с кодом по мере продолжения.
Чтобы соответствовать Babel, убедитесь, что вы выбрали «babylon6» в качестве парсера.
При написании плагина Babel наша задача — взять AST, а затем вставить / переместить / заменить / удалить некоторые узлы, чтобы создать новый 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: {
}
};
};
Эта функция предоставляет интерфейс для шаблона посетителя , к которому мы еще вернемся.
Наконец, мы создадим раннер, который мы сможем использовать для тестирования нашего плагина.
// 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. В этом случае мы собираемся заменить 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
}
}
]
}
Это довольно просто. Существует множество свойств, каждое из которых имеет ключ и значение. Теперь давайте 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.
Как и раньше, у нас есть 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);
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 .
Нам нужно будет извлечь и перевести узлы с каждой стороны 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
Затем мы заменяем его новым assoc
Как и раньше, мы также должны обрабатывать случаи, когда используется Identifier
StringLiteral
Теперь создайте еще один контрольный пример и запустите код, чтобы увидеть, работает ли он:
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.
Мы можем почти напрямую использовать свойства 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
Это потому, что мы хотим, чтобы наш метод посетителя AssignmentExpression
Это выглядит хорошо, но если вы запустите этот код, вы фактически окажетесь с ошибкой переполнения стека. Это потому, что когда мы заменяем данное MemberExpression
foo.bar
mori.get
Затем Babel обходит этот новый узел и рекурсивно передает его обратно в наш метод посетителя.
Хм.
Чтобы обойти это, мы можем пометить возвращаемые значения из moriMethod
MemberExpression
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, которым хотите поделиться? Дай мне знать в комментариях.