Пишем плагин для Babel

в 16:30, , рубрики: babel, compiler, javascript, transpilation

Модульность прочно обосновалась в мире javascript. Однако, при всех плюсах, писать в каждом файле одни и те же импорты — утомляет. А что, если убрать подключение часто используемых модулей в сборщик, а в коде использовать их как глобальные переменные? Выглядит, как задача для babel-плагина. Что же, давайте вместе напишем такой плагин, попутно разбираясь, как работает babel.

Пишем плагин для Babel - 1


Начнём со “скелета”. Плагин представляет собой функцию, которая возвращает объект с посетителями (visitors). Аргументом в неё передаётся объект с модулями из babel-core. В дальнейшем нам понадобится модуль babel-types.

export default function({types: t}) {
    return {
        visitor: {}
    };
}

Посетитель — это метод объекта visitor, имя которого соответствует типу узла абстрактного синтаксического дерева (АСД), например, FunctionDeclaration или StringLiteral (полный список), в который передаётся путь (path) к узлу. Нас интересуют узлы типа Identifier.

export default function({types: t}) {
    return {
        visitor: {
            Identifier(path, {opts: options}) {

            }
        }
    };
}

Также, у посетителя есть доступ к настройкам плагина в свойстве .opts второго аргумента. Через них мы будем передавать имена переменных и пути к модулям, для которых будет создаваться импорт. Это будет выглядеть так:

.babelrc

{
    plugins: [[
        "babel-plugin-auto-import",
        { declarations: [{name: "React", path: "react"}] }
    ]]
}

Обход АСД. Пути. Узлы

Babel принимает на вход некоторый код (в виде строки), который разбивается на токены, из которых строится АСД. Затем плагины изменяют АСД, и из него генерируется новый код, который и подаётся на выход. Для манипуляций с АСД, плагины используют пути. Также через пути можно проверить, какой тип узла представляет этот путь. Для этого есть методы формата .["is" + тип узла](). Например, path.isIdentifier(). Путь может искать среди дочерних путей, используя метод .find(callback), и среди родительских путей, используя метод .findParent(callback). В свойстве .parentPath хранится ссылка на родительский путь.

Приступим к написанию самого плагина. И в первую очередь нужно отфильтровать идентификаторы. Тип Identifier широко используется в различных типах узлов. Нам нужны только некоторые из них. Предположим, что у нас есть такой код:

React.Component

АСД для этого кода выглядит так:

{
    type: "MemberExpression",
    object: {
        type: "Identifier",
        name: "React"
    },
    property: {
        type: "Identifier",
        name: "Component"
    },
    computed: false
}

Узел — это объект со свойством .type и некоторыми другими, специфическими для каждого типа, свойствами. Рассмотрим корневой узел — MemberExpression. У него есть три свойства. Object — это выражение слева от точки. В данном случае — это идентификатор. Свойство computed указывает, будет ли справа идентификатор или некоторое выражение, например x["a" + b]. Property — собственно, то, что справа от точки.

Если сейчас запустить наш плагин-каркас, то метод Identifier будет вызван два раза: для идентификаторов React и Component соответственно. Плагин должен обработать идентификатор React, но пропустить идентификатор Component. Для этого путь идентификатора должен получить родительский путь и, если это узел типа MemberExpression, проверить, является ли идентификатор свойством .object у MemberExpression. Вынесем проверку в отдельную функцию:

export default function({types: t}) {
    return {
        visitor: {
            Identifier(path, {opts: options}) {
                if (!isCorrectIdentifier(path))
                    return;
            }
        }
    };

    function isCorrectIdentifier(path) {
        let {parentPath} = path;

        if (parentPath.isMemberExpression() && parentPath.get("object") == path)
            return true;
    }
}

В финальной версии таких проверок будет много — для каждого случая своя проверка. Но все они работают по одному и тому же принципу.

Полный список

function isCorrectIdentifier(path) {
    let {parentPath} = path;

    if (parentPath.isArrayExpression())
        return true;
    else
    if (parentPath.isArrowFunctionExpression())
        return true;
    else
    if (parentPath.isAssignmentExpression() && parentPath.get("right") == path)
        return true;
    else
    if (parentPath.isAwaitExpression())
        return true;
    else
    if (parentPath.isBinaryExpression())
        return true;
    else
    if (parentPath.bindExpression && parentPath.bindExpression())
        return true;
    else
    if (parentPath.isCallExpression())
        return true;
    else
    if (parentPath.isClassDeclaration() && parentPath.get("superClass") == path)
        return true;
    else
    if (parentPath.isClassExpression() && parentPath.get("superClass") == path)
        return true;
    else
    if (parentPath.isConditionalExpression())
        return true;
    else
    if (parentPath.isDecorator())
        return true;
    else
    if (parentPath.isDoWhileStatement())
        return true;
    else
    if (parentPath.isExpressionStatement())
        return true;
    else
    if (parentPath.isExportDefaultDeclaration())
        return true;
    else
    if (parentPath.isForInStatement())
        return true;
    else
    if (parentPath.isForStatement())
        return true;
    else
    if (parentPath.isIfStatement())
        return true;
    else
    if (parentPath.isLogicalExpression())
        return true;
    else
    if (parentPath.isMemberExpression() && parentPath.get("object") == path)
        return true;
    else
    if (parentPath.isNewExpression())
        return true;
    else
    if (parentPath.isObjectProperty() && parentPath.get("value") == path)
        return !parentPath.node.shorthand;
    else
    if (parentPath.isReturnStatement())
        return true;
    else
    if (parentPath.isSpreadElement())
        return true;
    else
    if (parentPath.isSwitchStatement())
        return true;
    else
    if (parentPath.isTaggedTemplateExpression())
        return true;
    else
    if (parentPath.isThrowStatement())
        return true;
    else
    if (parentPath.isUnaryExpression())
        return true;
    else
    if (parentPath.isVariableDeclarator() && parentPath.get("init") == path)
        return true;

    return false;
}

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

Следующим шагом необходимо проверить, объявлен ли наш идентификатор как локальная переменная или является глобальной. Для это в путях есть одно полезное свойство — scope. С его помощью мы переберем все области видимости, начиная с текущей. Переменные текущей области видимости находятся в свойстве .bindings. Ссылка на родительскую область видимости — в свойстве .parent. Осталось рекурсивно пройтись по всем переменным всех областей видимости и проверить, встречается ли там наш идентификатор.

export default function({types: t}) {
    return {
        visitor: {
            Identifier(path, {opts: options}) {
                if (!isCorrectIdentifier(path))
                    return;

                let {node: identifier, scope} = path;

                if (isDefined(identifier, scope))
                    return;
            }
        }
    };

    // ...

    function isDefined(identifier, {bindings, parent}) {
        let variables = Object.keys(bindings);

        if (variables.some(has, identifier))
            return true;

        return parent ? isDefined(identifier, parent) : false;
    }

    function has(identifier) {
        let {name} = this;

        return identifier == name;
    }
}

Отлично! Теперь мы уверены, что с идентификатором можно работать. Возьмём из options объявления “глобальных” переменных и обработаем их:

let {declarations} = options;

declarations.some(declaration => {
    if (declaration.name == identifier.name) {
        let program = path.findParent(path => path.isProgram());

        insertImport(program, declaration);

        return true;
    }
});

Модификация АСД

И вот мы дошли до изменения АСД. Но прежде чем начинать вставлять новые импорты, получим все существующие. Для этого мы используем метод .reduce, чтобы получить массив с путями типа ImportDeclaration:

function insertImport(program, { name, path }) {
    let programBody = program.get("body");

    let currentImportDeclarations =
        programBody.reduce(currentPath => {
            if (currentPath.isImportDeclaration())
                list.push(currentPath);

            return list;
        }, []);
}

Теперь проверим, не подключен ли уже наш идентификатор:

let importDidAppend =
    currentImportDeclarations.some(({node: importDeclaration}) => {
        if (importDeclaration.source.value == path) {
            return importDeclaration.specifiers.some(specifier => specifier.local.name == name);
        }
    });

Если модуль не подключен — создадим новый узел импорта и вставим его в программу.

Для создания узлов используется модуль babel-types. Ссылка на него есть в переменной t. Для каждого из узлов есть свой метод. Нам нужно создать importDeclaration. Смотрим документацию и видим, что для создания импорта требуются спецификаторы (т.е. имена импортируемых переменных) и путь к модулю.

Сначала создадим спецификатор. Наш плагин подключает модули как экспортируемые по умолчанию (export default ...). Затем создадим узел с путём к модулю. Это простая строка типа StringLiteral.

let specifier = t.importDefaultSpecifier(t.identifier(name));
let pathToModule = t.stringLiteral(path);

Что ж, у нас есть всё, чтобы создать импорт:

let importDeclaration = t.importDeclaration([specifier], pathToModule);

Осталось вставить узел в АСД. Для этого нам понадобится путь. Путь можно заменить узлом, используя метод .replaceWith(node), или массивом узлов, используя метод .replaceWithMultiple([...nodes]). Можно удалить методом .remove(). Для вставки используются методы .insertBefore(node) и .insertAfter(node), чтобы вставить узел перед или после пути соответственно.

В нашем случае, импорт нужно вставить в так называемый контейнер. У узла program есть свойство .body, в котором находится массив выражений, представляющих программу. Для вставки узлов в такие массивы-”контейнеры”, у путей есть специальные методы pushContainer и unshiftContainer. Воспользуемся последним:

program.unshiftContainer("body", importNode);

Плагин готов. Мы познакомились с основными API Babel, рассмотрели принципы устройства и работы плагинов. Сделанный нами плагин — упрощенная версия, которая работает некорректно. Но с полученными знаниями можно легко прочитать полный код плагина. Надеюсь статья была интересной, а полученный опыт — полезным. Все спасибо!

Автор: PavelDymkov

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js