Работа с AST внутри плагина

Во время обработки исходного кода Parcel может не только анализировать файлы как текст, но и представлять их в виде AST (Abstract Syntax Tree, абстрактного синтаксического дерева). Такой подход позволяет выполнять сложные преобразования кода, сохраняя корректность синтаксиса и структуру программы.

AST представляет исходный код как набор узлов, связанных между собой иерархическими отношениями. Например, инструкция:

const sum = a + b;

может быть представлена в виде дерева:

VariableDeclaration
└── VariableDeclarator
    ├── Identifier (sum)
    └── BinaryEx * pression (+)
        ├── Identifier (a)
        └── Identifier (b)

Плагины Parcel получают возможность:

  • анализировать структуру программы;
  • находить определённые конструкции;
  • изменять узлы дерева;
  • добавлять новый код;
  • удалять существующие элементы;
  • генерировать обновлённый исходный код.

AST в системе трансформации Parcel

Работа с AST обычно выполняется внутри Transformer-плагинов.

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

  1. Parcel загружает ресурс.
  2. Парсер преобразует код в AST.
  3. Transformer получает доступ к дереву.
  4. Выполняются необходимые изменения.
  5. AST преобразуется обратно в код.
  6. Результат передаётся следующему этапу сборки.

Упрощённая схема:

Source Code
      ↓
    Parser
      ↓
      AST
      ↓
 Transformer
      ↓
 Modified AST
      ↓
 Code Generator
      ↓
 Final Source

Получение AST внутри Transformer

Базовая структура трансформера выглядит так:

import {Transformer} from '@parcel/plugin';

export default new Transformer({
  async transform({asset}) {
    return [asset];
  }
});

Для работы с AST необходимо получить дерево из объекта Asset.

Обычно используется метод:

const ast = await asset.getAST();

Полный пример:

import {Transformer} from '@parcel/plugin';

export default new Transformer({
  async transform({asset}) {
    const ast = await asset.getAST();

    console.log(ast);

    return [asset];
  }
});

Если парсер для данного типа файлов уже был выполнен, Parcel вернёт готовое дерево.


Структура объекта AST

Возвращаемый объект содержит метаданные и корневой узел дерева.

Пример:

{
  type: 'babel',
  version: '7.0.0',
  program: {
    type: 'Program',
    body: [...]
  }
}

Основные поля:

Поле Назначение
type Тип AST
version Версия формата
program Корневой узел дерева

Корневой узел чаще всего имеет тип:

Program

Он содержит массив инструкций:

ast.program.body

Например:

Program
├── ImportDeclaration
├── VariableDeclaration
├── FunctionDeclaration
└── ExportDefaultDeclaration

Использование Babel AST

Для JavaScript Parcel обычно использует структуру AST, совместимую с Babel.

Поэтому доступны все привычные типы узлов:

Identifier
Literal
StringLiteral
NumericLiteral
BinaryExpression
CallExpression
MemberExpression
FunctionDeclaration
ArrowFunctionExpression
ImportDeclaration
ExportNamedDeclaration

Пример узла:

{
  type: 'Identifier',
  name: 'user'
}

Обход дерева

После получения AST необходимо пройти по его узлам.

Для этого обычно используется пакет Babel Traverse.

Установка:

npm install @babel/traverse

Импорт:

import traverse from '@babel/traverse';

Пример обхода:

traverse(ast.program, {
  Identifier(path) {
    console.log(path.node.name);
  }
});

Для каждого идентификатора будет выполнен обработчик.


Объект Path

Во время обхода используется объект Path.

Он содержит информацию о текущем узле и его положении внутри дерева.

Пример:

Identifier(path) {
  console.log(path.node);
}

Получение текущего узла:

path.node

Получение родителя:

path.parent

Проверка типа:

path.isIdentifier()

Замена:

path.replaceWith(...)

Удаление:

path.remove()

Поиск вызовов функций

Предположим, необходимо найти все вызовы функции:

log();

AST-обход:

traverse(ast.program, {
  CallEx * pression(path) {
    const callee = path.node.callee;

    if (
      callee.type === 'Identifier' &&
      callee.name === 'log'
    ) {
      console.log('Найден вызов');
    }
  }
});

Исходный код:

log();
foo();
log();

Результат:

Найден вызов
Найден вызов

Изменение имён переменных

AST позволяет безопасно переименовывать идентификаторы.

Исходный код:

const user = 'Alex';
console.log(user);

Трансформация:

traverse(ast.program, {
  Identifier(path) {
    if (path.node.name === 'user') {
      path.node.name = 'currentUser';
    }
  }
});

После генерации:

const currentUser = 'Alex';
console.log(currentUser);

Создание новых узлов

Для создания элементов дерева удобно использовать пакет Babel Types.

Установка:

npm install @babel/types

Импорт:

import * as t from '@babel/types';

Создание строкового литерала:

const node = t.stringLiteral('hello');

Создание идентификатора:

const id = t.identifier('username');

Создание вызова функции:

const call = t.callEx * pression(
  t.identifier('log'),
  [t.stringLiteral('start')]
);

Полученный AST-узел полностью совместим с Babel и Parcel.


Добавление новых инструкций

Допустим, необходимо автоматически вставить логирование.

Создание узла:

const statement = t.expressionStatement(
  t.callEx * pression(
    t.memberEx * pression(
      t.identifier('console'),
      t.identifier('log')
    ),
    [t.stringLiteral('Application started')]
  )
);

Добавление в начало программы:

ast.program.body.unshift(statement);

Результат:

console.log('Application started');

const app = createApp();

Удаление узлов

Исходный код:

debug();
run();

Поиск вызова:

traverse(ast.program, {
  CallEx * pression(path) {
    if (
      path.node.callee.type === 'Identifier' &&
      path.node.callee.name === 'debug'
    ) {
      path.remove();
    }
  }
});

Результат:

run();

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


Замена узлов

Исходный код:

oldFunction();

Замена:

traverse(ast.program, {
  CallEx * pression(path) {
    if (
      path.node.callee.name === 'oldFunction'
    ) {
      path.replaceWith(
        t.callEx * pression(
          t.identifier('newFunction'),
          []
        )
      );
    }
  }
});

Результат:

newFunction();

Работа с импортами

AST позволяет анализировать импорты.

Исходный код:

import React from 'react';
import lodash from 'lodash';

Поиск:

traverse(ast.program, {
  ImportDeclaration(path) {
    console.log(path.node.source.value);
  }
});

Результат:

react
lodash

Добавление импортов

Создание нового импорта:

const importNode = t.importDeclaration(
  [
    t.importDefaultSpecifier(
      t.identifier('React')
    )
  ],
  t.stringLiteral('react')
);

Вставка:

ast.program.body.unshift(importNode);

Результат:

import React from 'react';

...

Работа с экспортами

Поиск экспортируемых сущностей:

traverse(ast.program, {
  ExportNamedDeclaration(path) {
    console.log(path.node);
  }
});

Пример:

export const version = '1.0';

Будет найден узел:

ExportNamedDeclaration

Добавление пользовательской логики

Часто AST используется для автоматизации повторяющихся операций:

  • внедрение метрик;
  • добавление логирования;
  • генерация кода;
  • удаление отладочных вызовов;
  • автоматическая регистрация компонентов;
  • оптимизация импортов;
  • внедрение feature-флагов.

Пример удаления всех debug-вызовов:

traverse(ast.program, {
  CallEx * pression(path) {
    const callee = path.node.callee;

    if (
      callee.type === 'Identifier' &&
      callee.name === 'debug'
    ) {
      path.remove();
    }
  }
});

Сохранение изменённого AST

После модификации дерево необходимо вернуть обратно в Asset.

Для этого используется:

asset.setAST(ast);

Пример:

export default new Transformer({
  async transform({asset}) {
    const ast = await asset.getAST();

    // модификация дерева

    asset.setAST(ast);

    return [asset];
  }
});

Parcel автоматически выполнит последующую генерацию кода.


Генерация AST вручную

Иногда исходное дерево отсутствует и необходимо сформировать его самостоятельно.

Пример:

const ast = {
  type: 'babel',
  version: '7.0.0',
  program: {
    type: 'Program',
    body: []
  }
};

После этого AST можно наполнять узлами:

ast.program.body.push(
  t.variableDeclaration('const', [
    t.variableDeclarator(
      t.identifier('version'),
      t.stringLiteral('1.0.0')
    )
  ])
);

Производительность при работе с AST

Обработка дерева может стать узким местом сборки, особенно в крупных проектах.

Практические рекомендации:

  • ограничивать количество проходов по AST;
  • объединять несколько трансформаций в один обход;
  • избегать глубоких рекурсивных проверок без необходимости;
  • выполнять ранний выход из обработчиков;
  • не создавать лишние узлы;
  • кэшировать результаты анализа.

Неэффективный вариант:

traverse(ast.program, {...});
traverse(ast.program, {...});
traverse(ast.program, {...});

Более эффективный подход:

traverse(ast.program, {
  Identifier() {},
  CallEx * pression() {},
  ImportDeclaration() {}
});

Полноценный пример Transformer с AST

import {Transformer} from '@parcel/plugin';
import traverse from '@babel/traverse';
import * as t from '@babel/types';

export default new Transformer({
  async transform({asset}) {
    const ast = await asset.getAST();

    traverse(ast.program, {
      CallEx * pression(path) {
        const callee = path.node.callee;

        if (
          callee.type === 'Identifier' &&
          callee.name === 'debug'
        ) {
          path.remove();
        }
      }
    });

    ast.program.body.unshift(
      t.expressionStatement(
        t.callEx * pression(
          t.memberEx * pression(
            t.identifier('console'),
            t.identifier('log')
          ),
          [t.stringLiteral('Build processed')]
        )
      )
    );

    asset.setAST(ast);

    return [asset];
  }
});

Такой плагин выполняет две задачи одновременно:

  1. Удаляет все вызовы debug().
  2. Добавляет в начало файла сообщение о выполненной обработке.

Подобный подход демонстрирует основной принцип работы с AST внутри Parcel: получение дерева, обход узлов, внесение изменений и сохранение результата для последующих этапов сборки.