
Обзор
Для начала давайте взглянем на то, чего мы добьёмся, добравшись до конца этого материала:
// конструкция '@@' оснащает функцию `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?



