Я решил написать эту статью, когда работал над своим компилятором Webflow/React.
Мне хотелось, чтобы можно было взять строку кода на JS и трансформировать ее так, чтобы глобальные объекты не изменили своих значений.
/* In */
foo = 'foo'
/* Out */
if (typeof window.foo === 'undefined') window.foo = 'foo'
Сначала я думал, что сумею это сделать с помощью регулярного выражения. Как же я ошибался.
Регулярного выражения попросту недостаточно, поскольку оно полностью игнорирует концепцию переменных области видимости и работает со строкой так, как если бы это был обычный текст. Чтобы определить глобальную переменную, мы должны спросить себя: объявлена ли эта переменная в текущей области или в одной из ее родительских областей?
Чтобы решить этот вопрос, нужно разбить код на узлы, где каждый узел будет представлять часть нашего кода, а все узлы связаны друг с другом реляционными (родственными) связями. Эта система из узлов в целом называется AST (АСД) - абстрактное синтаксическое дерево. Его можно использовать для простого поиска областей и переменных, а также других элементов, связанных с нашим кодом.
AST может выглядеть так:
function foo(x) {
if (x > 10) {
var a = 2;
return a * x;
}
return x + 10;
}
Очевидно, разбиение кода на узлы — это не прогулка по парку. К счастью, у нас есть инструмент под названием Babel, который может справиться с этой задачей.
Babel спасает
Babel — это проект, который первоначально был создан для преобразовывания новейшего синтаксиса es20XX в синтаксис es5 для лучшей совместимости с браузерами. Поскольку комитет EcmaScript продолжает обновлять стандарты языка EcmaScript, плагины предоставляют отличное и легко обслуживаемое решение для простого обновления поведения компилятора Babel.
Babel состоит из множества компонентов, которые работают вместе, чтобы воплотить в жизнь новейший синтаксис EcmaScript. В частности, поток преобразования кода работает со следующими компонентами и отношениями:
Синтаксический анализатор разбирает строку кода, превращая ее в структуру отображения данных под названием AST (абстрактное синтаксическое дерево) с помощью @babel/parser;
AST манипулируется предопределенными плагинами, которые используют @babel/traverse;
AST трансформируется обратно в код с помощью @babel/generator.
Теперь вы лучше понимаете Babel и можете сообразить, что происходит, когда вы создаете плагин. Кстати, а как это сделать?
Создание и использование плагина Babel
Прежде всего я хотел бы, чтобы мы разобрались с генерированием AST в Babel, поскольку этот этап важен для создания плагина. Плагин будет манипулировать AST, и поэтому мы должны понимать его строение. Если вы зайдете на astexplorer.net, то найдете замечательный компилятор, который преобразует код в AST. Давайте возьмем код foo = "foo"
в качестве примера. Сгенерированное AST должно выглядеть так:
Как видите, каждый узел в дереве представляет часть кода, и дерево является рекурсивным. В выражении присваивания foo = "foo"
используется оператор =
, операндом слева является идентификатор с именем foo
, а операндом справа - литерал со значением "foo"
. Таким образом, каждая часть кода может быть представлена как узел, состоящий из других узлов. Каждый узел имеет тип и дополнительные свойства в зависимости от типа.
Скажем, мы хотели бы изменить значение "foo"
на "bar"
. Гипотетически нам нужно будет взять соответствующий литеральный узел и изменить его значение с "foo"
на "bar"
. Давайте возьмем этот простой пример и превратим его в плагин.
Я на скорую руку подготовил проект-шаблон, который вы можете использовать для быстрого написания плагинов и проверки их путем преобразования. Проект можно скачать, клонировав этот репозиторий. Он содержит следующие файлы:
- in.js - включает входной код, который мы хотели бы преобразовать;
- out.js - включает вывод только что преобразованного кода;
- transform.js - принимает код в in.js, преобразует его и записывает новый код в out.js;
- plugin.js - плагин преобразования, который будет применяться на протяжении всего процесса.
Для реализации нашего плагина скопируйте следующее содержимое и вставьте его в файл in.js
:
foo = "foo"
А это содержимое – в файл transform.js
:
module.exports = () => {
return {
visitor: {
AssignmentExpression(path) {
if (
path.node.left.type === 'Identifier' &&
path.node.left.name === 'foo' &&
path.node.right.type === 'Literal' &&
path.node.right.value === 'foo'
) {
path.node.right.value = 'bar'
}
}
}
}
}
Чтобы начать преобразование, просто запустите $node transform.js
. Теперь откройте файл out.js
. Вы должны увидеть следующее:
foo = "foo"
Свойство visitor
— это то место, где должна выполняться фактическая манипуляция с AST. Оно проходит по дереву и запускает обработчики для каждого указанного типа узла. В нашем случае всякий раз, когда посетитель встречает узел типа AssignmentExpression
, оно будет заменять правый операнд на "bar"
в случае, если мы присваиваем значение "foo"
для foo
. Мы можем добавить обработчик манипуляции для любого типа узла, который мы хотим. Это может быть AssignmentExpression
, Identifier
, Literal
или даже Program
, который является корневым узлом AST.
Итак, вернемся к основной цели, ради которой мы собрались. Я сначала напомню вам о ней:
/* In */
foo = 'foo'
/* Out */
if (typeof window.foo === 'undefined') window.foo = 'foo'
Во-первых, мы возьмем все глобальные назначения и превратим их в выражения назначения членов окна, чтобы избежать путаницы и возможных недоразумений. Я хотел бы начать с изучения желаемого выхода AST:
Затем соответствующим образом запишем сам плагин:
module.exports = ({ types: t }) => {
return {
visitor: {
AssignmentExpression(path) {
if (
path.node.left.type === 'Identifier' &&
!path.scope.hasBinding(path.node.left.name)
) {
path.node.left = t.memberExpression(t.identifier('window'), t.identifier(path.node.left.name))
}
}
}
}
}
Сейчас я познакомлю вас с двумя новыми концепциями, которые я не упоминал ранее. Однако они используются в плагине выше:
Объект types
представляет собой служебную библиотеку вроде Lodash для узлов AST. Он содержит методы для построения, проверки и преобразования узлов AST. Он полезен для прояснения логики AST с помощью хорошо продуманных утилитарных методов. Все его методы должны начинаться так же, как типы узлов, и должны записываться CamelCase. Все типы определены в @babel/types. Более того, я рекомендую вам взглянуть на исходный код при сборке плагина, чтобы определить нужные сигнатуры создателей узлов, поскольку большинство из них не документированы;
Как и объект types
, объект scope
содержит утилиты, которые связаны с областью действия текущего узла. Он может проверять, определена ли переменная или нет, генерировать уникальные идентификаторы переменных или переименовывать их. В плагине выше мы использовали метод hasBinding()
, чтобы проверить, имеет ли идентификатор соответствующую объявленную переменную или нет, поднявшись по AST.
Теперь мы добавим отсутствующий кусочек паззла - превратим выражение присваивания в выражение условного присваивания. Итак, мы хотим превратить этот код:
window.foo = 'foo'
В этот код:
if (typeof window.foo === 'undefined') window.foo = 'foo'
Если вы изучите AST данного кода, то увидите, что мы имеем дело с 3 новыми типами узлов:
UnaryExpression — typeof window.foo
;
BinaryExpression — ... === 'undefined'
;
IfStatement — if (...)
.
Обратите внимание на то, что каждый узел делается из того, что стоит над ним. Так же мы и будем обновлять наш плагин. Мы сохраним старую логику, в которой превращаем глобальные переменные в члены окна. В дополнение к этому, мы добавим условность с помощью IfStatement
:
module.exports = ({ types: t }) => {
return {
visitor: {
AssignmentExpression(path) {
if (
path.node.left.type === 'Identifier' &&
!path.scope.hasBinding(path.node.left.name)
) {
path.node.left = t.memberExpression(t.identifier('window'), t.identifier(path.node.left.name))
}
if (
path.node.left.type == 'MemberExpression' &&
path.node.left.object.name == 'window'
) {
const typeofNode = t.unaryExpression('typeof', path.node.left)
const isNodeUndefined = t.binaryExpression('===', typeofNode, t.stringLiteral('undefined'))
const ifNodeUndefined = t.ifStatement(isNodeUndefined, t.expressionStatement(path.node))
path.replaceWith(ifNodeUndefined)
path.skip()
}
}
}
}
}
По сути, мы проверяем, имеем ли мы дело с выражением присваивания члена окна. Если да, то мы создаем условный оператор и заменяем его текущим узлом. Несколько заметок:
Не вдаваясь в подробности объяснения, я создал вложенный ExpressionStatement
внутри IfStatement
просто потому, что это ожидается от меня, согласно AST;
Я использовал метод replaceWith
, чтобы заменить текущий узел вновь созданным;
Обычно обработчик AssignmentExpression
должен вызываться снова, потому что технически я создал новый узел этого типа, когда мы вызвали метод replaceWith
. Так как я не хочу запускать другой обход для вновь создаваемых узлов, я вызвал метод skip
, иначе у меня была бы бесконечная рекурсия.
Итак, все, плагин должен быть завершен. Это не самый сложный плагин, но это, безусловно, хороший пример для начала. Он даст вам хорошую основу для дальнейших плагинов, которые вы создадите в будущем.
Напомню, что если вы по какой-либо причине забудете о том, как работает плагин, то стоит просмотреть эту статью. Работая над самим плагином, изучите желаемый результат AST на astexplorer.net, а для документации по API я рекомендую вам поработать с этим замечательным руководством.