Напишите код, чтобы переписать свой код: jscodeshift

Опубликовано: 2022-03-11

Кодмоды с jscodeshift

Сколько раз вы использовали функцию поиска и замены в каталоге, чтобы внести изменения в исходные файлы JavaScript? Если вы хорошо разбираетесь, вы использовали регулярные выражения с захваченными группами, потому что это стоит затраченных усилий, если ваша кодовая база значительна. Однако Regex имеет ограничения. Для нетривиальных изменений вам нужен разработчик, который понимает код в контексте, а также готов взять на себя долгий, утомительный и подверженный ошибкам процесс.

Вот тут-то и появляются «кодмоды».

Codemods — это скрипты, используемые для перезаписи других скриптов. Думайте о них как о функциях поиска и замены, которые могут читать и писать код. Вы можете использовать их для обновления исходного кода в соответствии с принятыми в команде соглашениями о написании кода, для внесения широкомасштабных изменений при изменении API или даже для автоматического исправления существующего кода, когда ваш общедоступный пакет вносит критическое изменение.

Набор инструментов jscodeshift отлично подходит для работы с кодмодами.

Думайте о codemods как о скриптовой функции поиска и замены, которая может читать и писать код.
Твитнуть

В этой статье мы собираемся изучить набор инструментов для кодмодов под названием «jscodeshift» при создании трех кодмодов возрастающей сложности. К концу вы получите широкое представление о важных аспектах jscodeshift и будете готовы начать писать свои собственные кодмоды. Мы рассмотрим три упражнения, которые охватывают некоторые базовые, но удивительные способы использования codemods, и вы можете просмотреть исходный код этих упражнений в моем проекте на github.

Что такое jscodeshift?

Инструментарий jscodeshift позволяет прокачивать кучу исходных файлов через преобразование и заменять их тем, что выходит на другом конце. Внутри преобразования вы анализируете исходный код в абстрактное синтаксическое дерево (AST), копаетесь, чтобы внести свои изменения, а затем регенерируете исходный код из измененного AST.

Интерфейс, который предоставляет jscodeshift, представляет собой оболочку для пакетов recast и ast-types . recast обрабатывает преобразование из источника в AST и обратно, в то время как ast-types обрабатывает низкоуровневое взаимодействие с узлами AST.

Настраивать

Для начала установите jscodeshift глобально из npm.

 npm i -g jscodeshift

Есть варианты запуска, которые вы можете использовать, и продуманная настройка теста, которая делает запуск набора тестов через Jest (фреймворк для тестирования JavaScript с открытым исходным кодом) действительно простым, но мы собираемся обойти это сейчас в пользу простоты:

jscodeshift -t some-transform.js input-file.js -d -p

Это запустит input-file.js через преобразование some-transform.js и распечатает результаты без изменения файла.

Прежде чем приступить к делу, важно понять три основных типа объектов, с которыми работает API jscodeshift: узлы, пути к узлам и коллекции.

Узлы

Узлы — это основные строительные блоки AST, часто называемые «узлами AST». Это то, что вы видите, изучая свой код с помощью AST Explorer. Это простые объекты, не предоставляющие никаких методов.

Node-пути

Node-paths — это обертки вокруг узла AST, предоставляемые ast-types как способ обхода абстрактного синтаксического дерева (AST, помните?). По отдельности узлы не имеют никакой информации о своих родителях или области действия, поэтому об этом позаботятся пути узлов. Вы можете получить доступ к обернутому узлу через свойство node , и есть несколько методов, доступных для изменения базового узла. узлы-пути часто называют просто «путями».

Коллекции

Коллекции — это группы из нуля или более путей к узлам, которые API jscodeshift возвращает при запросе AST. У них есть всевозможные полезные методы, некоторые из которых мы рассмотрим.

Коллекции содержат пути к узлам, пути к узлам содержат узлы, а узлы — это то, из чего состоит AST. Имейте это в виду, и вам будет легко понять API запросов jscodeshift.

Может быть сложно отслеживать различия между этими объектами и их соответствующими возможностями API, поэтому есть отличный инструмент под названием jscodeshift-helper, который регистрирует тип объекта и предоставляет другую ключевую информацию.

Очень важно знать разницу между узлами, путями к узлам и коллекциями.

Очень важно знать разницу между узлами, путями к узлам и коллекциями.

Упражнение 1. Удалить вызовы консоли

Чтобы намочить ноги, давайте начнем с удаления вызовов всех консольных методов в нашей кодовой базе. Хотя вы можете сделать это с помощью поиска и замены и небольшого регулярного выражения, это становится сложным с многострочными операторами, литералами шаблонов и более сложными вызовами, поэтому это идеальный пример для начала.

Сначала создайте два файла, remove-consoles.js и remove-consoles.input.js :

 //remove-consoles.js export default (fileInfo, api) => { };
 //remove-consoles.input.js export const sum = (a, b) => { console.log('calling sum with', arguments); return a + b; }; export const multiply = (a, b) => { console.warn('calling multiply with', arguments); return a * b; }; export const divide = (a, b) => { console.error(`calling divide with ${ arguments }`); return a / b; }; export const average = (a, b) => { console.log('calling average with ' + arguments); return divide(sum(a, b), 2); };

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

jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p

Если все настроено правильно, при запуске вы должны увидеть что-то вроде этого.

 Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 0 unmodified 1 skipped 0 ok Time elapsed: 0.514seconds

Хорошо, это было немного разочаровывающе, так как наше преобразование на самом деле еще ничего не делает, но, по крайней мере, мы знаем, что все это работает. Если он вообще не запускается, убедитесь, что вы установили jscodeshift глобально. Если команда для запуска преобразования неверна, вы увидите сообщение «ERROR Transform file … не существует» или «TypeError: путь должен быть строкой или буфером», если входной файл не может быть найден. Если вы что-то напутали, это должно быть легко заметить по очень описательным ошибкам преобразования.

Связанный: Быстрая и практичная шпаргалка по JavaScript от Toptal: ES6 и не только

Однако наша конечная цель после успешного преобразования — увидеть этот источник:

 export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); };

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

 remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };

Но как нам найти консоли и удалить их? Если у вас нет исключительных знаний об API Mozilla Parser, вам, вероятно, понадобится инструмент, помогающий понять, как выглядит AST. Для этого вы можете использовать AST Explorer. Вставьте в него содержимое remove-consoles.input.js , и вы увидите AST. Даже в самом простом коде много данных, поэтому помогает скрыть данные о местоположении и методы. Вы можете переключать видимость свойств в AST Explorer с помощью флажков над деревом.

Мы видим, что вызовы консольных методов называются CallExpressions , так как же мы найдем их в нашем преобразовании? Мы используем запросы jscodeshift, помня наше более раннее обсуждение различий между коллекциями, путями узлов и самими узлами:

 //remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };

Строка const root = j(fileInfo.source); возвращает набор из одного пути к узлу, который обертывает корневой узел AST. Мы можем использовать метод find коллекции для поиска узлов-потомков определенного типа, например:

 const callExpressions = root.find(j.CallExpression);

Это возвращает другую коллекцию путей к узлам, содержащую только узлы, которые являются CallExpressions. На первый взгляд кажется, что это то, что нам нужно, но это слишком широко. Мы можем в конечном итоге запустить сотни или тысячи файлов через наши преобразования, поэтому мы должны быть точными, чтобы быть уверенными, что все будет работать так, как задумано. Наивная find выше не просто нашла бы консольные CallExpressions, она нашла бы все CallExpressions в источнике, включая

 require('foo') bar() setTimeout(() => {}, 0)

Чтобы обеспечить большую специфичность, мы предоставляем второй аргумент .find : объект дополнительных параметров, каждый узел должен быть включен в результаты. Мы можем посмотреть в AST Explorer, чтобы увидеть, что наши вызовы console.* имеют форму:

 { "type": "CallExpression", "callee": { "type": "MemberExpression", "object": { "type": "Identifier", "name": "console" } } }

Зная это, мы можем уточнить наш запрос с помощью спецификатора, который будет возвращать только интересующий нас тип CallExpressions:

 const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, });

Теперь, когда у нас есть точная коллекция сайтов звонков, давайте удалим их из AST. Удобно, что тип объекта коллекции имеет метод remove , который делает именно это. Наш файл remove-consoles.js теперь будет выглядеть так:

 //remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source) const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ); callExpressions.remove(); return root.toSource(); };

Теперь, если мы запустим наше преобразование из командной строки с помощью jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p , мы должны увидеть:

 Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... export const sum = (a, b) => { return a + b; }; export const multiply = (a, b) => { return a * b; }; export const divide = (a, b) => { return a / b; }; export const average = (a, b) => { return divide(sum(a, b), 2); }; All done. Results: 0 errors 0 unmodified 0 skipped 1 ok Time elapsed: 0.604seconds

Это выглядит хорошо. Теперь, когда наше преобразование изменяет базовый AST, использование .toSource() генерирует строку, отличную от исходной. Опция -p в нашей команде отображает результат, а внизу отображается количество диспозиций для каждого обработанного файла. Удаление параметра -d из нашей команды заменит содержимое remove-consoles.input.js выходными данными преобразования.

Наше первое упражнение завершено… почти. Код выглядит причудливо и, вероятно, очень оскорбителен для любых функциональных пуристов, и поэтому, чтобы улучшить поток кода преобразования, jscodeshift сделал большинство вещей цепляемыми. Это позволяет нам переписать наше преобразование следующим образом:

 // remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; return j(fileInfo.source) .find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, } ) .remove() .toSource(); };

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

Упражнение 2. Замена вызовов импортированных методов

Для этого сценария у нас есть модуль «геометрия» с методом «circleArea», от которого мы отказались в пользу «getCircleArea». Мы могли бы легко найти и заменить их на /geometry\.circleArea/g , но что, если пользователь импортировал модуль и присвоил ему другое имя? Например:

 import g from 'geometry'; const area = g.circleArea(radius);

Откуда нам знать, что нужно заменить g.circleArea вместо geometry.circleArea ? Мы, конечно, не можем предполагать, что все вызовы circleArea являются теми, которые мы ищем, нам нужен некоторый контекст. Именно здесь кодмоды начинают показывать свою ценность. Начнем с создания двух файлов: deprecated.js и deprecated.input.js .

 //deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
 deprecated.input.js import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.circleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));

Теперь запустите эту команду, чтобы запустить codemod.

jscodeshift -t ./deprecated.js ./deprecated.input.js -d -p

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

 Processing 1 files... Spawning 1 workers... Running in dry mode, no files will be written! Sending 1 files to free worker... All done. Results: 0 errors 1 unmodified 0 skipped 0 ok Time elapsed: 0.892seconds

Нам нужно знать, как был импортирован наш модуль geometry . Давайте посмотрим на AST Explorer и выясним, что мы ищем. Наш импорт принимает такую ​​форму.

 { "type": "ImportDeclaration", "specifiers": [ { "type": "ImportDefaultSpecifier", "local": { "type": "Identifier", "name": "g" } } ], "source": { "type": "Literal", "value": "geometry" } }

Мы можем указать тип объекта, чтобы найти набор узлов, например:

 const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, });

Это дает нам ImportDeclaration, используемый для импорта «геометрии». Оттуда покопайтесь, чтобы найти локальное имя, используемое для хранения импортированного модуля. Так как мы делаем это впервые, давайте отметим важный и запутанный момент при первом запуске.

Примечание. Важно знать, что root.find() возвращает набор путей к узлам. Оттуда метод .get(n) возвращает путь к узлу с индексом n в этой коллекции, и для получения фактического узла мы используем .node . Узел — это то, что мы видим в AST Explorer. Помните, что путь к узлу — это в основном информация об объеме и отношениях узла, а не о самом узле.

 // find the Identifiers const identifierCollection = importDeclaration.find(j.Identifier); // get the first NodePath from the Collection const nodePath = identifierCollection.get(0); // get the Node in the NodePath and grab its "name" const localName = nodePath.node.name;

Это позволяет нам динамически определять, как был импортирован наш модуль geometry . Затем мы находим места, где он используется, и меняем их. Взглянув на AST Explorer, мы видим, что нам нужно найти MemberExpressions, которые выглядят следующим образом:

 { "type": "MemberExpression", "object": { "name": "geometry" }, "property": { "name": "circleArea" } }

Помните, однако, что наш модуль мог быть импортирован с другим именем, поэтому мы должны учитывать это, сделав вместо этого наш запрос таким:

 j.MemberExpression, { object: { name: localName, }, property: { name: "circleArea", }, })

Теперь, когда у нас есть запрос, мы можем получить коллекцию всех сайтов вызовов для нашего старого метода, а затем использовать метод replaceWith() коллекции для их замены. Метод replaceWith() выполняет итерацию по коллекции, передавая путь к каждому узлу функции обратного вызова. Затем узел AST заменяется любым узлом, который вы возвращаете из обратного вызова.

Codemods позволяют вам записывать «интеллектуальные» соображения для рефакторинга.

Опять же, для того, чтобы это имело смысл, необходимо понимать разницу между коллекциями, путями к узлам и узлами.

Как только мы закончим замену, мы создадим исходный код, как обычно. Вот наше готовое преобразование:

 //deprecated.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "geometry" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, }); // get the local name for the imported module const localName = // find the Identifiers importDeclaration.find(j.Identifier) // get the first NodePath from the Collection .get(0) // get the Node in the NodePath and grab its "name" .node.name; return root.find(j.MemberExpression, { object: { name: localName, }, property: { name: 'circleArea', }, }) .replaceWith(nodePath => { // get the underlying Node const { node } = nodePath; // change to our new prop node.property.name = 'getCircleArea'; // replaceWith should return a Node, not a NodePath return node; }) .toSource(); };

Когда мы запускаем исходный код через преобразование, мы видим, что вызов устаревшего метода в модуле geometry был изменен, но остальное осталось без изменений, например:

 import g from 'geometry'; import otherModule from 'otherModule'; const radius = 20; const area = g.getCircleArea(radius); console.log(area === Math.pow(g.getPi(), 2) * radius); console.log(area === otherModule.circleArea(radius));

Упражнение 3. Изменение сигнатуры метода

В предыдущих упражнениях мы рассмотрели запросы коллекций для определенных типов узлов, удаление узлов и изменение узлов, но как насчет создания совершенно новых узлов? Это то, что мы будем решать в этом упражнении.

В этом сценарии у нас есть сигнатура метода, которая вышла из-под контроля с отдельными аргументами по мере роста программного обеспечения, и поэтому было решено, что вместо этого будет лучше принять объект, содержащий эти аргументы.

Вместо car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);

мы хотели бы увидеть

 const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, });

Давайте начнем с создания преобразования и входного файла для тестирования:

 //signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
 //signature-change.input.js import car from 'car'; const suv = car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true); const truck = car.factory('silver', 'Toyota', 'Tacoma', 2006, 100000, true, true);

Наша команда для запуска преобразования будет jscodeshift -t signature-change.js signature-change.input.js -d -p так:

  • Найдите локальное имя для импортированного модуля
  • Найдите все сайты вызова метода .factory
  • Прочитать все переданные аргументы
  • Замените этот вызов одним аргументом, который содержит объект с исходными значениями.

Используя AST Explorer и процесс, который мы использовали в предыдущих упражнениях, первые два шага просты:

 //signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .toSource(); };

Для чтения всех аргументов, которые в настоящее время передаются, мы используем метод replaceWith() в нашей коллекции CallExpressions, чтобы поменять местами каждый из узлов. Новые узлы заменят node.arguments новым единственным аргументом, объектом.

Легко меняйте аргументы метода с помощью jscodeshift!

Измените сигнатуры методов с помощью replacewith() и замените целые узлы.

Давайте попробуем с простым объектом, чтобы убедиться, что мы знаем, как это работает, прежде чем использовать правильные значения:

 .replaceWith(nodePath => { const { node } = nodePath; node.arguments = [{ foo: 'bar' }]; return node; })

Когда мы запустим это ( jscodeshift -t signature-change.js signature-change.input.js -d -p ), преобразование взорвется:

 ERR signature-change.input.js Transformation error Error: {foo: bar} does not match type Printable

Оказывается, мы не можем просто втиснуть простые объекты в наши узлы AST. Вместо этого нам нужно использовать строителей для создания правильных узлов.

По теме: наймите 3% лучших внештатных разработчиков Javascript.

Создатели узлов

Строители позволяют нам правильно создавать новые узлы; они предоставляются ast-types и отображаются через jscodeshift. Они жестко проверяют, правильно ли создаются различные типы узлов, что может расстраивать, когда вы работаете на броске, но, в конечном счете, это хорошо. Чтобы понять, как использовать конструкторы, нужно помнить две вещи:

Все доступные типы узлов AST определены в папке def проекта ast-types github, в основном в core.js. Существуют сборщики для всех типов узлов AST, но они используют версию типа узла в верблюжьем корпусе, а не паскаль. -кейс. (Это не указано явно, но вы можете видеть, что это так в исходном коде ast-types.

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

 def("ObjectExpression") .bases("Expression") .build("properties") .field("properties", [def("Property")]); def("Property") .bases("Node") .build("kind", "key", "value") .field("kind", or("init", "get", "set")) .field("key", or(def("Literal"), def("Identifier"))) .field("value", def("Expression"));

Таким образом, код для создания узла AST для { foo: 'bar' } будет выглядеть так:

 j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]);

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

 .replaceWith(nodePath => { const { node } = nodePath; const object = j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]); node.arguments = [object]; return node; })

Выполнение этого дает нам результат:

 import car from 'car'; const suv = car.factory({ foo: "bar" }); const truck = car.factory({ foo: "bar" });

Теперь, когда мы знаем, как создать правильный узел AST, легко перебрать старые аргументы и вместо этого сгенерировать новый объект для использования. Вот как теперь выглядит наш файл signature-change.js :

 //signature-change.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); // find declaration for "car" import const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'car', }, }); // get the local name for the imported module const localName = importDeclaration.find(j.Identifier) .get(0) .node.name; // current order of arguments const argKeys = [ 'color', 'make', 'model', 'year', 'miles', 'bedliner', 'alarm', ]; // find where `.factory` is being called return root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { name: localName, }, property: { name: 'factory', }, } }) .replaceWith(nodePath => { const { node } = nodePath; // use a builder to create the ObjectExpression const argumentsAsObject = j.objectExpression( // map the arguments to an Array of Property Nodes node.arguments.map((arg, i) => j.property( 'init', j.identifier(argKeys[i]), j.literal(arg.value) ) ) ); // replace the arguments with our new ObjectExpression node.arguments = [argumentsAsObject]; return node; }) // specify print options for recast .toSource({ quote: 'single', trailingComma: true }); };

Запустите преобразование ( jscodeshift -t signature-change.js signature-change.input.js -d -p ), и мы увидим, что подписи были обновлены, как и ожидалось:

 import car from 'car'; const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, }); const truck = car.factory({ color: 'silver', make: 'Toyota', model: 'Tacoma', year: 2006, miles: 100000, bedliner: true, alarm: true, });

Codemods с jscodeshift Резюме

Чтобы добраться до этой точки, потребовалось немного времени и усилий, но преимущества огромны, когда вы сталкиваетесь с массовым рефакторингом. Распределение групп файлов по разным процессам и их параллельный запуск — это то, в чем jscodeshift преуспевает, позволяя выполнять сложные преобразования в огромной кодовой базе за считанные секунды. По мере того, как вы станете более опытными в codemods, вы начнете перепрофилировать существующие скрипты (например, репозиторий react-codemod github или писать свои собственные для всех видов задач, и это сделает вас, вашу команду и ваших пользователей пакетов более эффективными). .