Scrieți codul pentru a vă rescrie codul: jscodeshift
Publicat: 2022-03-11Codemods cu jscodeshift
De câte ori ați folosit funcționalitatea de căutare și înlocuire într-un director pentru a modifica fișierele sursă JavaScript? Dacă ești bun, ai devenit fantezist și ai folosit expresii regulate cu grupuri de captură, pentru că merită efortul dacă baza ta de cod este mare. Regex are limite, totuși. Pentru modificări non-triviale, aveți nevoie de un dezvoltator care să înțeleagă codul în context și care este, de asemenea, dispus să preia procesul lung, obositor și predispus la erori.
Aici intervin „codemodurile”.
Codemod-urile sunt scripturi folosite pentru a rescrie alte scripturi. Gândiți-vă la ele ca la o funcționalitate de găsire și înlocuire care poate citi și scrie cod. Puteți să le utilizați pentru a actualiza codul sursă pentru a se potrivi convențiilor de codare ale unei echipe, pentru a face modificări pe scară largă atunci când un API este modificat sau chiar pentru a remedia automat codul existent atunci când pachetul dvs. public face o schimbare de ultimă oră.
În acest articol, vom explora un set de instrumente pentru codemod-uri numit „jscodeshift” în timp ce creăm trei codemod-uri de complexitate crescândă. Până la sfârșit, veți avea o expunere largă la aspectele importante ale jscodeshift și veți fi gata să începeți să scrieți propriile module de codare. Vom trece prin trei exerciții care acoperă câteva utilizări de bază, dar minunate, ale codemod-urilor și puteți vizualiza codul sursă pentru aceste exerciții în proiectul meu github.
Ce este jscodeshift?
Setul de instrumente jscodeshift vă permite să pompați o grămadă de fișiere sursă printr-o transformare și să le înlocuiți cu ceea ce iese la celălalt capăt. În interiorul transformării, analizați sursa într-un arbore de sintaxă abstractă (AST), aruncați o privire pentru a face modificările, apoi regenerați sursa din AST modificat.
Interfața pe care o oferă jscodeshift este un înveliș în jurul pachetelor de tip recast
și ast-types
. recast
se ocupă de conversia de la sursă la AST și înapoi, în timp ce ast-types
se ocupă de interacțiunea de nivel scăzut cu nodurile AST.
Înființat
Pentru a începe, instalați jscodeshift la nivel global din npm.
npm i -g jscodeshift
Există opțiuni de rulare pe care le puteți utiliza și o configurare de testare cu opinie care face rularea unei suită de teste prin Jest (un cadru de testare JavaScript open source) cu adevărat ușoară, dar vom ocoli asta pentru moment în favoarea simplității:
jscodeshift -t some-transform.js input-file.js -d -p
Aceasta va rula input-file.js
prin transformarea some-transform.js
și va tipări rezultatele fără a modifica fișierul.
Înainte de a intra, totuși, este important să înțelegeți trei tipuri principale de obiecte cu care se ocupă API-ul jscodeshift: noduri, căi de noduri și colecții.
Noduri
Nodurile sunt blocurile de bază ale AST, adesea denumite „noduri AST”. Acestea sunt ceea ce vedeți când explorați codul cu AST Explorer. Sunt obiecte simple și nu oferă nicio metodă.
Căi-noduri
Căile de nod sunt învelișuri în jurul unui nod AST furnizate de ast-types
ca o modalitate de a traversa arborele de sintaxă abstractă (AST, vă amintiți?). Izolat, nodurile nu au nicio informație despre părintele sau domeniul lor, așa că căile nodurilor au grijă de asta. Puteți accesa nodul înfășurat prin proprietatea node
și există mai multe metode disponibile pentru a schimba nodul subiacent. căile nodurilor sunt adesea denumite doar „căi”.
Colecții
Colecțiile sunt grupuri de zero sau mai multe căi de noduri pe care API-ul jscodeshift le returnează atunci când interogați AST. Au tot felul de metode utile, dintre care unele le vom explora.
Colecțiile conțin căi-noduri, căile-noduri conțin noduri, iar nodurile sunt din ce este făcut AST. Țineți cont de asta și va fi ușor de înțeles API-ul de interogare jscodeshift.
Poate fi dificil să urmăriți diferențele dintre aceste obiecte și capacitățile lor API respective, așa că există un instrument ingenios numit jscodeshift-helper care înregistrează tipul de obiect și oferă alte informații cheie.
Exercițiul 1: Eliminați apelurile către consolă
Pentru a ne uda picioarele, să începem cu eliminarea apelurilor către toate metodele de consolă din baza noastră de cod. Deși puteți face acest lucru cu găsirea și înlocuirea și puțină expresie regex, începe să devină dificil cu declarații cu mai multe linii, literale șablon și apeluri mai complexe, așa că este un exemplu ideal pentru a începe.
Mai întâi, creați două fișiere, remove-consoles.js
și 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); };
Iată comanda pe care o vom folosi în terminal pentru a o împinge prin jscodeshift:
jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
Dacă totul este configurat corect, atunci când îl rulați, ar trebui să vedeți ceva de genul acesta.
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
OK, a fost puțin anticlimatic, deoarece transformarea noastră nu face nimic încă, dar cel puțin știm că totul funcționează. Dacă nu rulează deloc, asigurați-vă că ați instalat jscodeshift la nivel global. Dacă comanda pentru a rula transformarea este incorectă, veți vedea fie un mesaj „EROARE Transformare … nu există” sau „TypeError: calea trebuie să fie un șir sau Buffer” dacă fișierul de intrare nu poate fi găsit. Dacă ai înțeles ceva, ar trebui să fie ușor de identificat cu erorile de transformare foarte descriptive.
Totuși, obiectivul nostru final, după o transformare de succes, este să vedem această sursă:
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); };
Pentru a ajunge acolo, trebuie să convertim sursa într-un AST, să găsim consolele, să le eliminam și apoi să convertim AST modificat înapoi în sursă. Primii și ultimii pași sunt simpli, este doar:
remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
Dar cum găsim consolele și le eliminăm? Dacă nu aveți cunoștințe excepționale despre API-ul Mozilla Parser, probabil că veți avea nevoie de un instrument care să vă ajute să înțelegeți cum arată AST. Pentru asta puteți folosi AST Explorer. Lipiți conținutul remove-consoles.input.js
în el și veți vedea AST. Există o mulțime de date chiar și în cel mai simplu cod, așa că ajută la ascunderea datelor și a metodelor de locație. Puteți comuta vizibilitatea proprietăților în AST Explorer cu casetele de selectare de deasupra arborelui.
Putem vedea că apelurile către metodele de consolă sunt denumite CallExpressions
, deci cum le găsim în transformarea noastră? Folosim interogările jscodeshift, amintindu-ne discuția noastră anterioară despre diferențele dintre colecții, căile nodurilor și nodurile în sine:
//remove-consoles.js export default (fileInfo, api) => { const j = api.jscodeshift; const root = j(fileInfo.source); return root.toSource(); };
Linia const root = j(fileInfo.source);
returnează o colecție de o cale de nod, care include nodul AST rădăcină. Putem folosi metoda de căutare a colecției pentru a find
noduri descendente de un anumit tip, astfel:
const callExpressions = root.find(j.CallExpression);
Aceasta returnează o altă colecție de căi de noduri care conțin doar nodurile care sunt CallExpressions. La prima vedere, asta pare ceea ce ne dorim, dar este prea larg. S-ar putea să ajungem să rulăm sute sau mii de fișiere prin transformările noastre, așa că trebuie să fim precisi pentru a avea încredere că va funcționa conform intenției. find
naivă de mai sus nu ar găsi doar consola CallExpressions, ci ar găsi fiecare CallExpression din sursă, inclusiv
require('foo') bar() setTimeout(() => {}, 0)
Pentru a forța o mai mare specificitate, oferim un al doilea argument pentru .find
: Un obiect cu parametri suplimentari, fiecare nod trebuie inclus în rezultate. Ne putem uita la AST Explorer pentru a vedea că apelurile noastre din consola.* au forma:
{ "type": "CallExpression", "callee": { "type": "MemberExpression", "object": { "type": "Identifier", "name": "console" } } }
Cu aceste cunoștințe, știm să ne rafinăm interogarea cu un specificator care va returna doar tipul de CallExpressions care ne interesează:
const callExpressions = root.find(j.CallExpression, { callee: { type: 'MemberExpression', object: { type: 'Identifier', name: 'console' }, }, });
Acum că avem o colecție exactă a site-urilor de apeluri, să le eliminăm din AST. În mod convenabil, tipul de obiect de colecție are o metodă de remove
care va face exact asta. Fișierul nostru remove-consoles.js
va arăta acum astfel:
//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(); };
Acum, dacă rulăm transformarea noastră din linia de comandă folosind jscodeshift -t remove-consoles.js remove-consoles.input.js -d -p
, ar trebui să vedem:
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
Arata bine. Acum că transformarea noastră modifică AST-ul de bază, folosind .toSource()
generează un șir diferit de cel original. Opțiunea -p din comanda noastră afișează rezultatul, iar în partea de jos este afișat un număr de dispoziții pentru fiecare fișier procesat. Eliminarea opțiunii -d din comanda noastră ar înlocui conținutul remove-consoles.input.js cu ieșirea din transformare.
Primul nostru exercițiu este complet... aproape. Codul are un aspect bizar și, probabil, foarte ofensator pentru orice purist funcțional de acolo și, astfel, pentru a îmbunătăți fluxul de cod de transformare, jscodeshift a făcut ca majoritatea lucrurilor să fie înlănțuite. Acest lucru ne permite să ne rescriem transformarea astfel:
// 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(); };
Mult mai bine. Pentru a recapitula exercițiul 1, am împachetat sursa, am solicitat o colecție de căi de noduri, am schimbat AST și apoi am regenerat acea sursă. Ne-am udat picioarele cu un exemplu destul de simplu și am atins cele mai importante aspecte. Acum, hai să facem ceva mai interesant.
Exercițiul 2: Înlocuirea apelurilor de metodă importate
Pentru acest scenariu, avem un modul „geometrie” cu o metodă numită „circleArea” pe care am renunțat-o în favoarea „getCircleArea”. Le-am putea găsi și înlocui cu ușurință cu /geometry\.circleArea/g
, dar dacă utilizatorul a importat modulul și i-a atribuit un alt nume? De exemplu:
import g from 'geometry'; const area = g.circleArea(radius);
Cum am ști să înlocuim g.circleArea
în loc de geometry.circleArea
? Cu siguranță nu putem presupune că toate apelurile circleArea
sunt cele pe care le căutăm, avem nevoie de un anumit context. Aici codemodurile încep să-și arate valoarea. Să începem prin a crea două fișiere, deprecated.js
și 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));
Acum rulați această comandă pentru a rula codemod.

jscodeshift -t ./deprecated.js ./deprecated.input.js -d -p
Ar trebui să vedeți o ieșire care indică transformarea rulată, dar încă nu s-a schimbat nimic.
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
Trebuie să știm cum a fost importat modulul nostru de geometry
. Să ne uităm la AST Explorer și să ne dăm seama ce căutăm. Importul nostru ia această formă.
{ "type": "ImportDeclaration", "specifiers": [ { "type": "ImportDefaultSpecifier", "local": { "type": "Identifier", "name": "g" } } ], "source": { "type": "Literal", "value": "geometry" } }
Putem specifica un tip de obiect pentru a găsi o colecție de noduri ca aceasta:
const importDeclaration = root.find(j.ImportDeclaration, { source: { type: 'Literal', value: 'geometry', }, });
Acest lucru ne aduce Declarația de import folosită pentru a importa „geometrie”. De acolo, săpați pentru a găsi numele local folosit pentru a deține modulul importat. Deoarece este prima dată când o facem, să subliniem un punct important și confuz atunci când începem.
Notă: Este important să știți că root.find()
returnează o colecție de căi de noduri. De acolo, metoda .get(n)
returnează calea nodului la indexul n
din acea colecție, iar pentru a obține nodul real, folosim .node
. Nodul este practic ceea ce vedem în AST Explorer. Amintiți-vă, calea nodului este în mare parte informații despre domeniul și relațiile nodului, nu nodul în sine.
// 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;
Acest lucru ne permite să aflăm în mod dinamic cum a fost importat modulul nostru de geometry
. În continuare, găsim locurile în care este folosit și le schimbăm. Privind la AST Explorer, putem vedea că trebuie să găsim MemberExpressions care arată astfel:
{ "type": "MemberExpression", "object": { "name": "geometry" }, "property": { "name": "circleArea" } }
Amintiți-vă, totuși, că modulul nostru poate fi importat cu un alt nume, așa că trebuie să luăm în considerare acest lucru făcând interogarea noastră să arate astfel:
j.MemberExpression, { object: { name: localName, }, property: { name: "circleArea", }, })
Acum că avem o interogare, putem obține o colecție a tuturor site-urilor de apel la vechea noastră metodă și apoi folosim metoda replaceWith()
a colecției pentru a le schimba. Metoda replaceWith()
iterează prin colecție, trecând fiecare cale de nod unei funcții de apel invers. Nodul AST este apoi înlocuit cu orice nod pe care îl returnați de la apel invers.
Odată ce am terminat cu înlocuirea, generăm sursa ca de obicei. Iată transformarea noastră terminată:
//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(); };
Când rulăm sursa prin transformare, vedem că apelul la metoda depreciată din modulul de geometry
a fost schimbat, dar restul a fost lăsat nealterat, așa:
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));
Exercițiul 3: Schimbarea unei semnături de metodă
În exercițiile anterioare am abordat interogarea colecțiilor pentru anumite tipuri de noduri, eliminarea nodurilor și modificarea nodurilor, dar cum rămâne cu crearea de noduri cu totul noi? Aceasta este ceea ce vom aborda în acest exercițiu.
În acest scenariu, avem o semnătură de metodă care a scăpat de sub control cu argumentele individuale pe măsură ce software-ul a crescut și, astfel, s-a decis că ar fi mai bine să acceptăm un obiect care conține acele argumente.
În loc de car.factory('white', 'Kia', 'Sorento', 2010, 50000, null, true);
am vrea sa vedem
const suv = car.factory({ color: 'white', make: 'Kia', model: 'Sorento', year: 2010, miles: 50000, bedliner: null, alarm: true, });
Să începem prin a face transformarea și un fișier de intrare pentru a testa:
//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);
Comanda noastră pentru a rula transformarea va fi jscodeshift -t signature-change.js signature-change.input.js -d -p
și pașii de care avem nevoie pentru a efectua această transformare sunt:
- Găsiți numele local pentru modulul importat
- Găsiți toate site-urile de apeluri către metoda .factory
- Citiți toate argumentele transmise
- Înlocuiți acel apel cu un singur argument care conține un obiect cu valorile originale
Folosind AST Explorer și procesul pe care l-am folosit în exercițiile anterioare, primii doi pași sunt simpli:
//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(); };
Pentru a citi toate argumentele transmise în prezent, folosim metoda replaceWith()
din colecția noastră de CallExpressions pentru a schimba fiecare dintre noduri. Noile noduri vor înlocui node.arguments cu un nou argument unic, un obiect.
Să încercăm cu un obiect simplu pentru a ne asigura că știm cum funcționează înainte de a folosi valorile adecvate:
.replaceWith(nodePath => { const { node } = nodePath; node.arguments = [{ foo: 'bar' }]; return node; })
Când rulăm acest lucru ( jscodeshift -t signature-change.js signature-change.input.js -d -p
), transformarea va exploda cu:
ERR signature-change.input.js Transformation error Error: {foo: bar} does not match type Printable
Se pare că nu putem bloca obiecte simple în nodurile noastre AST. În schimb, trebuie să folosim constructori pentru a crea noduri adecvate.
Constructori de noduri
Constructorii ne permit să creăm noi noduri în mod corespunzător; acestea sunt furnizate de ast-types
și apar la suprafață prin jscodeshift. Ei verifică cu rigiditate dacă diferitele tipuri de noduri sunt create corect, ceea ce poate fi frustrant atunci când piratați, dar în cele din urmă, acesta este un lucru bun. Pentru a înțelege cum să folosiți constructorii, există două lucruri pe care ar trebui să le aveți în vedere:
Toate tipurile de noduri AST disponibile sunt definite în folderul def
al proiectului github ast-types, mai ales în core.js. Există constructori pentru toate tipurile de noduri AST, dar folosesc versiunea tip nod cu carcasă de cămilă, nu pascal. -caz. (Acest lucru nu este specificat în mod explicit, dar puteți vedea că este cazul în sursa ast-types
Dacă folosim AST Explorer cu un exemplu despre ceea ce ne dorim să fie rezultatul, putem pune totul împreună destul de ușor. În cazul nostru, dorim ca noul argument să fie un ObjectExpression cu o grămadă de proprietăți. Privind definițiile tipului menționate mai sus, putem vedea ce presupune aceasta:
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"));
Deci, codul pentru a construi un nod AST pentru { foo: 'bar' } ar arăta astfel:
j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]);
Luați acel cod și conectați-l la transformarea noastră astfel:
.replaceWith(nodePath => { const { node } = nodePath; const object = j.objectExpression([ j.property( 'init', j.identifier('foo'), j.literal('bar') ) ]); node.arguments = [object]; return node; })
Rulând acest lucru ne obține rezultatul:
import car from 'car'; const suv = car.factory({ foo: "bar" }); const truck = car.factory({ foo: "bar" });
Acum că știm cum să creăm un nod AST adecvat, este ușor să parcurgem vechile argumente și să generăm un nou obiect de utilizat. Iată cum arată acum fișierul nostru 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 }); };
Rulați transformarea ( jscodeshift -t signature-change.js signature-change.input.js -d -p
) și vom vedea că semnăturile au fost actualizate conform așteptărilor:
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 cu jscodeshift Recap
A fost nevoie de puțin timp și efort pentru a ajunge la acest punct, dar beneficiile sunt uriașe atunci când ne confruntăm cu refactorizarea în masă. Distribuirea grupurilor de fișiere către diferite procese și rularea lor în paralel este ceva la care excelează jscodeshift, permițându-vă să executați transformări complexe într-o bază de cod uriașă în câteva secunde. Pe măsură ce deveniți mai pricepuți cu codemod-urile, veți începe să reutilizați scripturile existente (cum ar fi depozitul github react-codemod sau să vă scrieți al dvs. pentru tot felul de sarcini, iar acest lucru vă va face pe dvs., echipa și utilizatorii pachetelor dvs. mai eficienți .