Модульность прочно обосновалась в мире javascript. Однако, при всех плюсах, писать в каждом файле одни и те же импорты — утомляет. А что, если убрать подключение часто используемых модулей в сборщик, а в коде использовать их как глобальные переменные? Выглядит, как задача для babel-плагина. Что же, давайте вместе напишем такой плагин, попутно разбираясь, как работает babel.
Начнём со “скелета”. Плагин представляет собой функцию, которая возвращает объект с посетителями (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