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

Автор оригинала: Tan Li Hau
  • Перевод
Сегодня мы публикуем первую часть перевода материала, который посвящён созданию собственных синтаксических конструкций для JavaScript с использованием Babel.



Обзор


Для начала давайте взглянем на то, чего мы добьёмся, добравшись до конца этого материала:

// конструкция '@@' оснащает функцию `foo` возможностями каррирования
function @@ foo(a, b, c) {
  return a + b + c;
}
console.log(foo(1, 2)(3)); // 6

Мы собираемся реализовать синтаксическую конструкцию @@, которая позволяет каррировать функции. Этот синтаксис похож на тот, что используется для создания функций-генераторов, но в нашем случае вместо знака * между ключевым словом function и именем функции размещается последовательность символов @@. В результате при объявлении функций можно использовать конструкцию вида function @@ name(arg1, arg2).

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

foo(1, 2, 3); // 6

const bar = foo(1, 2); // (n) => 1 + 2 + n
bar(3); // 6

Я выбрал именно последовательность символов @@ потому, что в именах переменных нельзя использовать символ @. Это значит, что синтаксически корректной окажется и конструкция вида function@@foo(){}. Кроме того, «оператор» @ применяется для функций-декораторов, а мне хотелось использовать что-то совершенно новое. В результате я и выбрал конструкцию @@.

Для того чтобы добиться поставленной цели, нам нужно выполнить следующие действия:

  • Создать форк парсера Babel.
  • Создать собственный плагин Babel для трансформации кода.

Выглядит как нечто невозможное?
На самом деле, ничего страшного тут нет, мы вместе всё подробно разберём. Я надеюсь, что вы, когда это дочитаете, будете мастерски владеть тонкостями Babel.

Создание форка Babel


Зайдите в репозиторий Babel на GitHub и нажмите на кнопку Fork, которая находится в левой верхней части страницы.


Создание форка Babel (изображение в полном размере)

И, кстати, если только что вы впервые создали форк популярного опенсорсного проекта — примите поздравления!

Теперь клонируйте форк Babel на свой компьютер и подготовьте его к работе.

$ git clone https://github.com/tanhauhau/babel.git

# set up
$ cd babel
$ make bootstrap
$ make build

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

Babel использует монорепозиторий. Все пакеты (например — @babel/core, @babel/parser, @babel/plugin-transform-react-jsx и так далее) расположены в папке packages/. Выглядит это так:

- doc
- packages
  - babel-core
  - babel-parser
  - babel-plugin-transform-react-jsx
  - ...
- Gulpfile.js
- Makefile
- ...

Отмечу, что в Babel для автоматизации задач используется Makefile. При сборке проекта, выполняемой командой make build, в качестве менеджера задач используется Gulp.

Краткий курс по преобразованию кода в AST


Если вы не знакомы с такими понятиями, как «парсер» и «абстрактное синтаксическое дерево» (Abstract Syntax Tree, AST), то, прежде чем продолжать чтение, я настоятельно рекомендую вам взглянуть на этот материал.

Если очень кратко рассказать о том, что происходит при парсинге (синтаксическом анализе) кода, то получится следующее:

  • Код, представленный в виде строки (тип string), выглядит как длинный список символов: f, u, n, c, t, i, o, n, , @, @, f, ...
  • В самом начале Babel выполняет токенизацию кода. На этом шаге Babel просматривает код и создаёт токены. Например — нечто вроде function, @@, foo, (, a, ...
  • Затем токены пропускают через парсер для их синтаксического анализа. Здесь Babel, на основе спецификации языка JavaScript, создаёт абстрактное синтаксическое дерево.

Вот отличный ресурс для тех, кто хочет больше узнать о компиляторах.

Если вы думаете, что «компилятор» — это что-то очень сложное и непонятное, то знайте, что на самом деле всё не так уж и таинственно. Компиляция — это просто парсинг кода и создание на его основе нового кода, который мы назовём XXX. XXX-код может быть представлен машинным кодом (пожалуй, именно машинный код — это то, что первым всплывает в сознании большинства из нас при мысли о компиляторе). Это может быть JavaScript-код, совместимый с устаревшими браузерами. Собственно, одной из основных функций Babel является компиляция современного JS-кода в код, понятный устаревшим браузерам.

Разработка собственного парсера для Babel


Мы собираемся работать в папке packages/babel-parser/:

- src/
  - tokenizer/
  - parser/
  - plugins/
    - jsx/
    - typescript/
    - flow/
    - ...
- test/

Мы уже говорили о токенизации и о парсинге. Найти код, реализующий эти процессы, можно в папках с соответствующими именами. В папке plugins/ содержатся плагины (подключаемые модули), которые расширяют возможности базового парсера и добавляют в систему поддержку дополнительных синтаксисов. Именно так, например, реализована поддержка jsx и flow.

Давайте решим нашу задачу, воспользовавшись техникой разработки через тестирование (Test-driven development, TDD). По-моему, легче всего сначала написать тест, а потом, постепенно работая над системой, сделать так, чтобы этот тест выполнялся бы без ошибок. Такой подход особенно хорош при работе в незнакомой кодовой базе. TDD упрощает понимание того, в какие места кода нужно внести изменения для реализации задуманного функционала.

packages/babel-parser/test/curry-function.js

import { parse } from '../lib';

function getParser(code) {
  return () => parse(code, { sourceType: 'module' });
}

describe('curry function syntax', function() {
  it('should parse', function() {
    expect(getParser(`function @@ foo() {}`)()).toMatchSnapshot();
  });
});

Запуск теста для babel-parser можно выполнить так: TEST_ONLY=babel-parser TEST_GREP="curry function" make test-only. Это позволит увидеть ошибки:

SyntaxError: Unexpected token (1:9)

at Parser.raise (packages/babel-parser/src/parser/location.js:39:63)
at Parser.raise [as unexpected] (packages/babel-parser/src/parser/util.js:133:16)
at Parser.unexpected [as parseIdentifierName] (packages/babel-parser/src/parser/expression.js:2090:18)
at Parser.parseIdentifierName [as parseIdentifier] (packages/babel-parser/src/parser/expression.js:2052:23)
at Parser.parseIdentifier (packages/babel-parser/src/parser/statement.js:1096:52)

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

BABEL_ENV=test node_modules/.bin/jest -u packages/babel-parser/test/curry-function.js

Наш парсер обнаружил 2 токена @, вроде бы совершенно невинных, там, где их быть не должно.

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

Просмотр стека вызовов приводит нас к packages/babel-parser/src/parser/expression.js, где выбрасывается исключение this.unexpected().

Добавим в этот файл пару команд логирования:

packages/babel-parser/src/parser/expression.js

parseIdentifierName(pos: number, liberal?: boolean): string {
  if (this.match(tt.name)) {
    // ...
  } else {
    console.log(this.state.type); // текущий токен
    console.log(this.lookahead().type); // следующий токен
    throw this.unexpected();
  }
}

Как видно, оба токена — это @:

TokenType {
  label: '@',
  // ...
}

Как я узнал о том, что конструкции this.state.type и this.lookahead().type дадут мне текущий и следующий токены?
Об этом я расскажу в разделе данного материала, посвящённом функциям this.eat, this.match и this.next.

Прежде чем продолжать — давайте подведём краткие итоги:

  • Мы написали тест для babel-parser.
  • Мы запустили тест с помощью make test-only.
  • Мы воспользовались режимом мониторинга кода с помощью make watch.
  • Мы узнали о состоянии парсера и вывели в консоль сведения о типе текущего токена (this.state.type).

А сейчас мы сделаем так, чтобы 2 символа @ воспринимались бы не как отдельные токены, а как новый токен @@, тот, который мы решили использовать для каррирования функций.

Новый токен: «@@»


Для начала заглянем туда, где определяются типы токенов. Речь идёт о файле packages/babel-parser/src/tokenizer/types.js.

Тут можно найти список токенов. Добавим сюда и определение нового токена atat:

packages/babel-parser/src/tokenizer/types.js

export const types: { [name: string]: TokenType } = {
  // ...
  at: new TokenType('@'),
  atat: new TokenType('@@'),
};

Теперь давайте поищем то место кода, где, в процессе токенизации, создаются токены. Поиск последовательности символов tt.at в babel-parser/src/tokenizer приводит нас к файлу: packages/babel-parser/src/tokenizer/index.js. В babel-parser типы токенов импортируются как tt.

Теперь, в том случае, если после текущего символа @ идёт ещё один @, создадим новый токен tt.atat вместо токена tt.at:

packages/babel-parser/src/tokenizer/index.js

getTokenFromCode(code: number): void {
  switch (code) {
    // ...

    case charCodes.atSign:
      // если следующий символ - это `@`
      if (this.input.charCodeAt(this.state.pos + 1) === charCodes.atSign) {
        // создадим `tt.atat` вместо `tt.at`
        this.finishOp(tt.atat, 2);
      } else {
        this.finishOp(tt.at, 1);
      }
      return;
    // ...

  }
}

Если снова запустить тест — то можно заметить, что сведения о текущем и следующем токенах изменились:

// текущий токен
TokenType {
  label: '@@',
  // ...
}

// следующий токен
TokenType {
  label: 'name',
  // ...
}

Это уже выглядит довольно-таки неплохо. Продолжим работу.

Новый парсер


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


AST для функции-генератора (изображение в полном размере)

Как видите, на то, что это — функция-генератор, указывает атрибут generator: true сущности FunctionDeclaration.

Мы можем применить аналогичный подход для описания функции, поддерживающей каррирование. А именно, мы можем добавить к FunctionDeclaration атрибут curry: true.


AST для функции, поддерживающей каррирование (изображение в полном размере)

Собственно говоря, теперь у нас есть план. Займёмся его реализацией.

Если поискать в коде по слову FunctionDeclaration — можно выйти на функцию parseFunction, которая объявлена в packages/babel-parser/src/parser/statement.js. Здесь можно найти строку, в которой устанавливается атрибут generator. Добавим в код ещё одну строку:

packages/babel-parser/src/parser/statement.js

export default class StatementParser extends ExpressionParser {
  // ...
  parseFunction<T: N.NormalFunction>(
    node: T,
    statement?: number = FUNC_NO_FLAGS,
    isAsync?: boolean = false
  ): T {
    // ...
    node.generator = this.eat(tt.star);
    node.curry = this.eat(tt.atat);
  }
}

Если мы снова запустим тест, то нас будет ждать приятная неожиданность. Код успешно проходит тестирование!

PASS  packages/babel-parser/test/curry-function.js
  curry function syntax
    ✓ should parse (12ms)

И это всё? Что мы такого сделали, чтобы тест чудесным образом оказался пройденным?

Для того чтобы это выяснить — давайте поговорим о том, как работает парсинг. В процессе этого разговора, надеюсь, вы поймёте то, как подействовала на Babel строчка node.curry = this.eat(tt.atat);.

Продолжение следует…

Уважаемые читатели! Используете ли вы Babel?


RUVDS.com
1 510,64
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

Комментарии 21

    0
    Форматирование исходного кода подкачало. Уберите теги, пожалуйста.
      0
      Спасибо, поправили
      –3
      1. Непонятно зачем помечать функции-которые-можно-каррировать вместо того чтобы каррировать ВСЕ функции
      2. А редактировать получившееся чудо теперь в чём? Откуда IDE узнают как распарсить @@?
        +4

        ИМХО, для расширения функционала правильнее было бы реализовать новый плагин, нежели менять сам babel. Плагин можно сопровождать и распространять отдельно, что гораздо легче и реальнее, чем замержить такое в оригинальный репозиторий.

          +1

          К сожалению (или к счастью?) babel не позволяет написать плагин, что расширит возможности парсера.

            0

            А как же такие пресеты, как @babel/preset-typescript или @babel/plugin-proposal-decorators?

              +1

              Этот синтаксис уже реализован в парсере и просто включается данными плагинами. Ради интереса зайдите и посмотрите код официальных "синтаксических плагинов" (например, первый попавшийся, ну или упомянутый вами).

                +1

                Спасибо за информацию, жаль только такую разочаровывающую =)

          –4
          Зачем это делать? Для маленького проекта — это оверхед. Для больших проектов — не выгодно… Если отсутствует какойто синтаксис — лучше реализовывать его на постоянной основе, предложив внестив спецификацию языка, а не придумывать костыли… Костыли — это не есть хорошо
            +2
            Когда кто-то предлагает изменению в спецификацию языка, кому-то, часто самому предложившему, нужно это реализовывать в плагине к Babel. Также это может быть востребовано в различных фреймворках для реализации DSL, например в шаблонизаторах.
              –3
              Смысл имплементировать нативные вещи в бабел? Это ересь — я не думаю что прежде чем попасть в пропозал — какието спред операторы или промисы писались под транспайлеры…
              А фреймворк привязывать к баблу — вообще бессмысленно…
                +5
                Смысл имплементировать нативные вещи в бабел?

                У Babel'я есть плагины. Можно плагином.
                А фреймворк привязывать к баблу — вообще бессмысленно…

                Ну React как-то смог привязаться.
                  +1
                  Ну плагины это другой разговор — я про данный метод говорю. Да и реакт тоже плагинами реализован
                  +2

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


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

                    –1
                    Ну так плагины это совсем другое разве нет? Тут всё както странно сделано. Возможно меня не так поняли — я имел ввиду не то что чтото реализовывать в бабел не имеет смысла — а делать это вот таким странным способом.
                      +3

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

                        –1
                        26 Apr 2015… Вот babeljs.io/docs/en/plugins там есть Syntax Plugins и Transform Plugins итд
                          0

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


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

                            –1
                            Синтаксические плагины только для парсера — для своей интерпретации нужно использовать Transform plugins
              0

              Была идея разработать свой ЯП, синтаксис которого можно дополнять плагинами настолько, что можно было бы вводить конструкции типа json, xml и т.п. прямо в код. Идея была, но до реализации руки никак не доходят. Скорее всего такое сделать можно, но по моему это максимум Proof Of Concept.

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

            Самое читаемое