Как стать автором
Обновить
2649.27
RUVDS.com
VDS/VPS-хостинг. Скидка 15% по коду HABR15

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

Время на прочтение 8 мин
Количество просмотров 5.4K
Автор оригинала: Tan Li Hau
Сегодня мы публикуем вторую часть перевода материала о расширении синтаксиса 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 {
  // ...
  parseAdditiveExpression() {
    const left = this.parseMultiplicativeExpression();
    // если текущий токен - это `+` или `-`
    if (this.match(tt.plus) || this.match(tt.minus)) {
      const operator = this.state.type;
      // перейти к следующему токену
      this.nextToken();
      const right = this.parseMultiplicativeExpression();

      // создать узел
      this.finishNode(
        {
          operator,
          left,
          right,
        },
        'BinaryExpression'
      );
    } else {
      // вернуть MultiplicativeExpression
      return left;
    }
  }
}

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

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

Функции 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 собственный парсер.

Если перейти по пути packages/babel-parser/lib, то можно найти скомпилированную версию парсера и карту кода. В панели Babel AST Explorer можно увидеть кнопку для загрузки собственного парсера. Загрузив 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 использует @babel/generator для генерирования кода из трансформированного AST. Так как @babel/generator ничего не знает о новом атрибуте curry, он его просто игнорирует.

Если когда-нибудь функции, поддерживающие каррирование, войдут в стандарт 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 объявлена в глобальной области видимости


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

Если же при выполнении скомпилированного кода оказывается, что функция currying не определена, то мы столкнёмся с сообщением об ошибке, выглядящем как «currying is not defined». Оно очень похоже на сообщение «regeneratorRuntime 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 предложения новой возможности JavaScript. Вот страница репозитория TC39, на которой можно найти сведения о текущих предложениях. Здесь можно найти более подробные сведения о порядке работы с подобными предложениями. При предложении новой возможности JavaScript, тот, кто её предлагает, обычно пишет полифиллы или, делая форк Babel, готовит демонстрацию, доказывающую работоспособность предложения. Как вы могли убедиться, создание форка парсера или написание полифилла — это не самая сложная часть процесса предложения новых возможностей JS. Сложно определить предметную область новшества, спланировать и продумать варианты его использования и пограничные случаи; сложно собрать мнения и предложения членов сообщества JavaScript-программистов. Поэтому я хотел бы выразить признательность всем тем, кто находит в себе силы предлагать TC39 новые возможности JavaScript, развивая таким образом этот язык.

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

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


Теги:
Хабы:
+38
Комментарии 0
Комментарии Комментировать

Публикации

Информация

Сайт
ruvds.com
Дата регистрации
Дата основания
Численность
11–30 человек
Местоположение
Россия
Представитель
ruvds