Хабрахабр

[Перевод] Создание собственных синтаксических конструкций для JavaScript с использованием Babel. Часть 2

Сегодня мы публикуем вторую часть перевода материала о расширении синтаксиса JavaScript с использованием Babel.

→ Головокружительная первая часть

Как работает парсинг

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

Спецификация грамматики выглядит примерно так:

...
ExponentiationExpression -> UnaryExpression UpdateExpression ** ExponentiationExpression
MultiplicativeExpression -> ExponentiationExpression MultiplicativeExpression ("*" or "/" or "%") ExponentiationExpression
AdditiveExpression -> MultiplicativeExpression AdditiveExpression + MultiplicativeExpression AdditiveExpression - MultiplicativeExpression
...

Она описывает приоритет выполнения выражений или операторов. Например, выражение AdditiveExpression может представлять одна из следующих конструкций:

  • Выражение MultiplicativeExpression.
  • Выражение AdditiveExpression, за которым следует токен оператора «+», за которым следует выражение MultiplicativeExpression.
  • Выражение AdditiveExpression, за которым следует токен «-», за которым следует выражение MultiplicativeExpression.

В результате, если у нас имеется выражение 1 + 2 * 3, то оно будет выглядеть так:

(AdditiveExpression "+" 1 (MultiplicativeExpression "*" 2 3))

А вот таким оно не будет:

(MultiplicativeExpression "*" (AdditiveExpression "+" 1 2) 3)

Программа, с использованием этих правил, преобразуется в код, выдаваемый парсером:

class Parser , 'BinaryExpression' ); } else { // вернуть MultiplicativeExpression return left; } }
}

Обратите внимание на то, что здесь приведён чрезвычайно упрощённый вариант того, что на самом деле присутствует в Babel. Но я надеюсь, что этот фрагмент кода позволяет проиллюстрировать сущность происходящего.

Он переходит от конструкций с самым низким приоритетом к конструкциям с самым высоким приоритетом. Как видите, парсер, по своей природе, рекурсивен. Этот рекурсивный процесс называют синтаксическим анализом методом рекурсивного спуска (Recursive Descent Parsing). Например — parseAdditiveExpression вызывает parseMultiplicativeExpression, а эта конструкция вызывает parseExponentiationExpression и так далее.

Функции this.eat, this.match, this.next

Возможно, вы заметили, что в ранее приведённых примерах использовались некоторые вспомогательные функции, такие, как this.eat, this.match, this.next и другие. Это — внутренние функции парсера Babel. Подобные функции, правда, не уникальны для Babel, они обычно присутствуют и в других парсерах.

  • Функция this.match возвращает логическое значение, указывающее на то, соответствует ли текущий токен заданному условию.
  • Функция this.next осуществляет перемещение по списку токенов вперёд, к следующему токену.
  • Функция this.eat возвращает то же, что возвращает функция this.match, при этом, если this.match возвращает true, то this.eat выполняет, перед возвратом true, вызов this.next.
  • Функция this.lookahead позволяет получить следующий токен без перемещения вперёд, что помогает принять решение по текущему узлу.

Если вы снова взглянете на изменённый нами код парсера, то обнаружите, что читать его стало гораздо легче:

packages/babel-parser/src/parser/statement.js export default class StatementParser extends ExpressionParser { parseStatementContent(/* ...*/) { // ... // NOTE: мы вызываем match для проверки текущего токена if (this.match(tt._function)) { this.next(); // NOTE: у объявления функции приоритет выше, чем у обычного выражения this.parseFunction(); } } // ... parseFunction(/* ... */) { // NOTE: мы вызываем eat для проверки существования необязательного токена node.generator = this.eat(tt.star); node.curry = this.eat(tt.atat); node.id = this.parseFunctionId(); }
}

Я знаю о том, что не особенно углублялся в объяснения особенностей работы парсеров. Поэтому вот и вот — пара полезных ресурсов на эту тему. Я почерпнул их них много хорошего и могу порекомендовать их вам.

Возможно, вам интересно узнать о том, как я смог визуализировать созданный мной синтаксис в Babel AST Explorer, когда показывал новый атрибут «curry», появившийся в AST.

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

В панели Babel AST Explorer можно увидеть кнопку для загрузки собственного парсера. Если перейти по пути packages/babel-parser/lib, то можно найти скомпилированную версию парсера и карту кода. Загрузив packages/babel-parser/lib/index.js можно визуализировать AST, сгенерированное с помощью собственного парсера.

Визуализация AST

Наш плагин для Babel

Теперь, когда завершена работа над парсером — давайте напишем плагин для Babel.

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

Плагин Babel может предоставлять возможности парсера. Правда, опасаться тут нечего. Соответствующую документацию можно найти на сайте Babel.

babel-plugin-transformation-curry-function.js import customParser from './custom-parser'; export default function ourBabelPlugin() { return { parserOverride(code, opts) { return customParser.parse(code, opts); }, };
}

Так как мы создали форк парсера Babel, это значит, что все существующие возможности парсера, а также встроенные плагины, продолжат работать совершенно нормально.

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

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

Так как @babel/generator ничего не знает о новом атрибуте curry, он его просто игнорирует. Происходит это из-за того, что после парсинга и трансформации кода Babel использует @babel/generator для генерирования кода из трансформированного AST.

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

Для того чтобы сделать так, чтобы функция поддерживала бы каррирование, её можно обернуть в функцию высшего порядка currying:

function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]);
}

Если вас интересуют особенности реализации механизма каррирования функций в JS — взгляните на этот материал.

В результате мы, преобразуя функцию, поддерживающую каррирование, можем поступить так:

// из этого
function @@ foo(a, b, c) { return a + b + c;
} // преобразуем в это
const foo = currying(function foo(a, b, c) { return a + b + c;
})

Пока не будем обращать на механизм поднятия функций в JavaScript, который позволяет вызвать функцию foo до её определения.

Вот как выглядит код трансформации:

babel-plugin-transformation-curry-function.js export default function ourBabelPlugin() { return { // ...
<i> visitor: { FunctionDeclaration(path) { if (path.get('curry').node) { // const foo = curry(function () { ... }); path.node.curry = false; path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(t.identifier('currying'), [ t.toExpression(path.node), ]) ), ]) ); } }, },</i> };
}

Вам гораздо легче будет в нём разобраться в том случае, если вы читали этот материал о трансформациях в Babel.

Здесь можно воспользоваться одним из двух подходов. Теперь перед нами возникает вопрос о том, как предоставить этому механизму доступ к функции currying.

▍Подход №1: можно предположить, что функция currying объявлена в глобальной области видимости

Если это так, то дело уже сделано.

Оно очень похоже на сообщение «regeneratorRuntime is not defined». Если же при выполнении скомпилированного кода оказывается, что функция currying не определена, то мы столкнёмся с сообщением об ошибке, выглядящем как «currying is not defined».

Поэтому, если кто-то будет пользоваться вашим плагином babel-plugin-transformation-curry-function, то вам, возможно, придётся сообщить ему о том, что ему, для обеспечения нормальной работы этого плагина, нужно установить полифилл currying.

▍Подход №2: можно воспользоваться babel/helpers

Можно добавить новую вспомогательную функцию в @babel/helpers. Эта разработка вряд ли будет объединена с официальным репозиторием @babel/helpers. В результате вам придётся найти способ показать @babel/core место расположения вашего кода @babel/helpers:

package.json { "resolutions": { "@babel/helpers": "7.6.0--your-custom-forked-version", }

Я сам это не пробовал, но полагаю, что этот механизм будет работать. Если вы это попробуете и столкнётесь с проблемами — я с удовольствием с вами это обсужу.

Добавить новую вспомогательную функцию в @babel/helpers очень просто.

Сначала надо перейти в файл packages/babel-helpers/src/helpers.js и добавить туда новую запись:

helpers.currying = helper("7.6.0")` export default function currying(fn) { const numParamsRequired = fn.length; function curryFactory(params) { return function (...args) { const newParams = params.concat(args); if (newParams.length >= numParamsRequired) { return fn(...newParams); } return curryFactory(newParams); } } return curryFactory([]); }
`;

При описании вспомогательной функции указывается необходимая версия @babel/core. Некоторые сложности здесь может вызывать экспорт по умолчанию (export default) функции currying.

Для использования вспомогательной функции достаточно просто вызвать this.addHelper():

// ...
path.replaceWith( t.variableDeclaration('const', [ t.variableDeclarator( t.identifier(path.get('id.name').node), t.callExpression(this.addHelper("currying"), [ t.toExpression(path.node), ]) ), ])
);

Команда this.addHelper, при необходимости, внедрит вспомогательную функцию в верхнюю часть файла, и вернёт Identifier, указывающий на внедрённую функцию.

Примечания

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

В результате я решил написать об этом материал и это попробовать. Однако уже некоторое время меня занимала идея о добавлении в язык новых синтаксических конструкций. Невероятно приятно видеть то, что всё это работает именно так, как ожидалось.

Это даёт возможность, реализуя некие сложные конструкции, писать меньше кода, или писать более простой код, чем раньше. Возможность управления синтаксисом используемого тобой языка — это мощный источник вдохновения. Это напоминает то, как async/await решает проблемы ада коллбэков и длинных цепочек промисов. Механизмы преобразования простого кода в сложные конструкции автоматизируются и переносятся на этап компиляции.

Итоги

Здесь мы поговорили о том, как модифицировать возможности парсера Babel, мы написали собственный плагин трансформации кода, кратко поговорили о @babel/generator и о создании вспомогательных функций с помощью @babel/helpers. Сведения, касающиеся трансформации кода, здесь даны лишь схематично. Подробнее о них можно почитать здесь.

Если вам эта тема интересна — то вот, вот и вот — ресурсы, которые вам пригодятся. В процессе работы мы коснулись некоторых особенностей работы парсеров.

Вот страница репозитория TC39, на которой можно найти сведения о текущих предложениях. Выполненная нами последовательность действий очень похожа на часть того процесса, который выполняется при поступлении в TC39 предложения новой возможности JavaScript. При предложении новой возможности JavaScript, тот, кто её предлагает, обычно пишет полифиллы или, делая форк Babel, готовит демонстрацию, доказывающую работоспособность предложения. Здесь можно найти более подробные сведения о порядке работы с подобными предложениями. Сложно определить предметную область новшества, спланировать и продумать варианты его использования и пограничные случаи; сложно собрать мнения и предложения членов сообщества JavaScript-программистов. Как вы могли убедиться, создание форка парсера или написание полифилла — это не самая сложная часть процесса предложения новых возможностей JS. Поэтому я хотел бы выразить признательность всем тем, кто находит в себе силы предлагать TC39 новые возможности JavaScript, развивая таким образом этот язык.

Вот страница на GitHub, которая позволит вам увидеть общую картину того, чем мы здесь занимались.

Уважаемые читатели! Возникало ли у вас когда-нибудь желание расширить синтаксис JavaScript?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть