image


Сегодня я поделюсь с вами пошаговым руководством как написать свой babel плагин. Вы сможете использовать эти знания для автоматизации правок, рефакторинга или кодогенерации.


Что такое babel?


Babel это JavaScript компилятор, который в основном используют для конвертации кода на ECMAScript 2015+ в обратно совместимый с текущими и более старыми браузерами или окружениями. Babel использует систему плагинов для преобразования кода, поэтому кто угодно может написать свой плагин.


Перед тем как мы начнем писать плагин, нужно узнать что такое AST(абстрактное синтаксическое дерево).


Что такое AST?


Я не уверен что могу объяснить это лучше, чем уже сделано в отличных материалах из Сети:



Подводя итог, AST это древовидная структура вашего кода. В случае с JavaScript, AST JavaScript следует спецификации estree.


AST представляет собой структуру и значения вашего кода. Таким образом AST позволяет компиляторам вроде babel, понимать код и делать в нем конкретные осмысленные преобразования.


Теперь, когда вы знаете что такое AST, давайте напишем плагин для преобразования кода используя эти знания.


Как использовать babel для преобразования кода?


Пример ниже, это общий шаблон использования babel для трансформации кода:


import { parse } from '@babel/parser';
import traverse from '@babel/traverse';
import generate from '@babel/generator';

const code = 'const n = 1';

// парсим код в ast
const ast = parse(code);

// изменяем ast
traverse(ast, {
  enter(path) {
    // в этом примере мы меняем все переменные `n` на `x`
    if (path.isIdentifier({ name: 'n' })) {
      path.node.name = 'x';
    }
  },
});

// генерируем код обратно из ast
const output = generate(ast, code);
console.log(output.code); // 'const x = 1;'

Вам нужно установить @babel/core для запуска данного примера. @babel/parser, @babel/traverse, @babel/generator все это зависимости @babel/core поэтому его установки будет достаточно.


Основная идея состоит в том чтобы распарсить ваш код в AST, изменить AST и после сгенерировать из него код.


Код -> AST -> измененное AST -> измененный код

Однако, мы можем использовать другое babel API записав пример выше следующим образом:


import babel from '@babel/core';

const code = 'const n = 1';

const output = babel.transformSync(code, {
  plugins: [
    // наш первый babel плагин
    function myCustomPlugin() {
      return {
        visitor: {
          Identifier(path) {
            // в этом примере мы меняем все переменные `n` на `x`
            if (path.isIdentifier({ name: 'n' })) {
              path.node.name = 'x';
            }
          },
        },
      };
    },
  ],
});

console.log(output.code); // 'const x = 1;'

Это наш первый babel плагин который заменяет все переменные с именем n на x, разве не круто?


Выделите функцию myCustomPlugin в отдельный файл и экспортируйте её. Опубликуйте npm пакет и сможете гордо сказать что сделали babel плагин!


К этому моменту вы должно быть думаете: "Да, мы написали babel плагин, но я все еще не представляю как это работает...". Не волнуйтесь, сейчас мы погрузимся глубже в то как написать плагин самостоятельно.


1. Представьте что и во что вы хотите превратить


В следующем примере я хочу разыграть своего коллегу созданием плагина который будет:


  • переворачивать названия переменных и функций
  • разделять строки на отдельные символы

До:


function greet(name) {
  return 'Hello ' + name;
}

console.log(greet('tanhauhau')); // Hello tanhauhau

После:


function teerg(eman) {
      return 'H' + 'e' + 'l' + 'l' + 'o' + ' ' + eman;
    }

console.log(teerg('t' + 'a' + 'n' + 'h' + 'a' + 'u' + 'h' + 'a' + 'u')); // Hello tanhauhau

Мы сохранили console.log, поэтому несмотря на то что код сложно читать, он все еще нормально работает. (Я ведь не хочу сломать код на продакшене!)


2. Знайте на что нацелиться в AST


Воспользуемся babel AST explorer, наведите курсором на разные части кода и наблюдайте как они представлены в AST:


https://lihautan.com/static/0cf3ccd0d3c0a243103feadfdf5c1dd9/bf7ce/targeting.png


Если это первый раз когда вы видите AST, поэкспериментируйте с ним чуть дольше, чтобы понять как связаны части дерева и код.


Итак, теперь мы знаем что нам нужна цель:


  • Identifier для переменных и названий функций
  • StringLiteral для строк.

3. Представьте как выглядит преобразованный AST


Вернемся снова в babel AST explorer, но в этот раз с кодом который мы хотим сгенерировать:


https://lihautan.com/static/fa937b7e3d8c6f72f076f4e86d9b60e1/30a34/output.png


Поиграйте с этим кодом и подумайте как мы можем превратить предыдущее AST дерево в текущее.


Например мы видим что 'H' + 'e' + 'l' + 'l' + 'o' + ' ' + eman сформирован вложенными бинарными выражениями(BinaryExpression) со строковыми литералами(StringLiteral)


4. Пишем код


Взглянем на наш код снова:


function myCustomPlugin() {
  return {
    visitor: {
      Identifier(path) {
        // ...
      },
    },
  };
}

Преобразование кода использует паттерн посетителя так же известный как visitor.


Во время обхода дерева, babel с помощью поиска в глубину обойдет каждый узел AST дерева. Мы можем указать колбэк в visitor, таким образом каждый раз когда будет найден новый узел, babel вызовет колбэк с таким узлом.


В объекте visitor можно уточнить название какого узла будет срабатывать колбэк:


function myCustomPlugin() {
  return {
    visitor: {
      Identifier(path) {
        console.log('идентификатор');
      },
      StringLiteral(path) {
        console.log('строковый литерал');
      },
    },
  };
}

Запустив код выше, вы увидите вывод идентификатор и строковый литерал всякий раз когда babel будет их находить:


идентификатор
идентификатор
строковый литерал
идентификатор
идентификатор
идентификатор
идентификатор
строковый литерал

Прежде чем продолжить, давай посмотрим на параметр Identifer(path) {}. Он называется path вместо node, в чем между ними разница?


В babel, path это абстракция над узлом, которая обеспечивает связь между ними, предоставляя доступ к такой информации как родительский узел, области видимости, контексту и т.д. Кроме этого path предоставляет методы, такие как replaceWith, insertBefore, remove и прочие, которые могут повлиять на исходный AST узел.


Вы можете узнать больше деталей о path в руководстве по babel от Jamie Kyle


Продолжим писать плагин.


Меняем порядок букв имен переменных


Как мы видели в babel AST explorer, название идентификатора хранится в свойстве name, исходя из этого, обратный порядок букв в названиях переменных реализуется так:


Identifier(path) {
  path.node.name = path.node.name
    .split('')
    .reverse()
    .join('');
}

После запуска мы увидим:


function teerg(eman) {
  return 'Hello ' + eman;
}

elosnoc.gol(teerg('tanhauhau')); // Hello tanhauhau

Мы почти добились цели исключая тот факт что функция console.log так же подверглась изменениям. Как мы можем это предотвратить?


Снова взглянем на AST:


https://lihautan.com/static/487252cc6b3641879b1a606c5f7e6263/ae92f/member-expression.png


console.log является частью MemberExpression, c object"console" и property"log"


Поэтому мы будем проверять, если наш текущий идентификатор содержится внутри MemberExpression, то не будем менять порядок букв:


if (
  !(
    path.parentPath.isMemberExpression() &&
    path.parentPath
      .get('object')
      .isIdentifier({ name: 'console' }) &&
    path.parentPath.get('property').isIdentifier({ name: 'log' })
  )
) {
 path.node.name = path.node.name
   .split('')
   .reverse()
   .join('');
}
}

Да, теперь это работает правильно!


function teerg(eman) {
  return 'Hello ' + eman;
}

console.log(teerg('tanhauhau')); // Hello tanhauhau

Итак, почему мы проверяем что Identifier родителя не является console.log MemberExpression? Почему бы просто не сделать проверку что Identifier.name === 'console' || Identifier.name === 'log'?


Мы можем так сделать, однако тогда переменные названные console or log не будут изменены:


const log = 1;

Как узнать методы isMemberExpression и isIdentifier? Все типы узлов описанные в @babel/types имеют функцию валидатор типа isXxxx, например функция anyTypeAnnotation будет иметь валидатор isAnyTypeAnnotation. Если вы хотите получить полный список функций валидаторов, можете ознакомиться с исходным кодом


Трансформируем строки


Следующим шагом является создание вложенного BinaryExpression из строкового литерала.


Чтобы создать узел в AST, можно использовать утилиту из [@babel/types](https://babeljs.io/docs/en/babel-types):


StringLiteral(path) {
  const newNode = path.node.value
    .split('')
    .map(c => babel.types.stringLiteral(c))
    .reduce((prev, curr) => {
      return babel.types.binaryExpression('+', prev, curr);
    });
  path.replaceWith(newNode);
}

Мы разделили содержание StringLiteral, которое находится в path.node.value, cделали каждый символ отдельным StringLiteral и объединили с помощью BinaryExpression. И наконец заменили StringLiteral новым узлом.


…И на этом все! За исключением того факта что запустив, мы столкнемся с переполнением стека:


RangeError: Maximum call stack size exceeded


Почему?


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


Итак, как можно сказать babel что после того как мы заменили StringLiteral на newNode, он может остановится и больше не искать новые узлы?


Мы можем использовать path.skip() для того чтобы пропустить обход дочерних элементов:


StringLiteral(path) {
  const newNode = path.node.value
    .split('')
    .map(c => babel.types.stringLiteral(c))
    .reduce((prev, curr) => {
      return babel.types.binaryExpression('+', prev, curr);
    });
  path.replaceWith(newNode);
  path.skip();
}

…Теперь это работает без переполнения стека!


Итог


Вот такой у нас получился плагин для babel:


const babel = require('@babel/core');
const code = `
function greet(name) {
  return 'Hello ' + name;
}
console.log(greet('tanhauhau')); // Hello tanhauhau
`;
const output = babel.transformSync(code, {
  plugins: [
    function myCustomPlugin() {
      return {
        visitor: {
          StringLiteral(path) {
            const concat = path.node.value
              .split('')
              .map(c => babel.types.stringLiteral(c))
              .reduce((prev, curr) => {
                return babel.types.binaryExpression('+', prev, curr);
              });
            path.replaceWith(concat);
            path.skip();
          },
          Identifier(path) {
            if (
              !(
                path.parentPath.isMemberExpression() &&
                path.parentPath
                  .get('object')
                  .isIdentifier({ name: 'console' }) &&
                path.parentPath.get('property').isIdentifier({ name: 'log' })
              )
            ) {
              path.node.name = path.node.name
                .split('')
                .reverse()
                .join('');
            }
          },
        },
      };
    },
  ],
});
console.log(output.code);

Краткое описание шагов, которые мы сделали:


  1. Представили что и во что будем превращать
  2. Нашли на что будем нацелены в AST
  3. Узнали как будет выглядеть измененное AST
  4. Написали код

Дополнительная информация


Если вы хотите узнать больше, репозиторий babel на GitHub лучшее место чтобы найти примеры различных преобразований с помощью babel.


Взгляните на https://github.com/babel/babel, и найдите директории babel-plugin-transform-* или babel-plugin-proposal-*, все они все являются плагинами-преобразователями, среди них можно найти как babel трансформирует nullish coalescing operator, optional chaining и многое другое.


Ссылки