Сегодня мы публикуем первую часть перевода материала, который посвящён созданию собственных синтаксических конструкций для 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?
Автор: ru_vds