編寫代碼來重寫你的代碼:jscodeshift

已發表: 2022-03-11

帶有 jscodeshift 的 Codemods

您有多少次使用跨目錄的查找和替換功能來更改 JavaScript 源文件? 如果你做得很好,你就會喜歡並使用正則表達式來捕獲組,因為如果你的代碼庫相當大,那麼付出努力是值得的。 不過,正則表達式有限制。 對於重要的更改,您需要一位了解上下文代碼並且願意承擔冗長、乏味且容易出錯的過程的開發人員。

這就是“codemods”的用武之地。

Codemods 是用於重寫其他腳本的腳本。 將它們視為可以讀寫代碼的查找和替換功能。 您可以使用它們來更新源代碼以適應團隊的編碼約定,在修改 API 時進行廣泛的更改,甚至在您的公共包進行重大更改時自動修復現有代碼。

jscodeshift 工具包非常適合使用 codemods。

將 codemods 視為可以讀取和寫入代碼的腳本查找和替換功能。
鳴叫

在本文中,我們將探索一個名為“jscodeshift”的 codemod 工具包,同時創建三個複雜度越來越高的 codemod。 到最後,您將廣泛了解 jscodeshift 的重要方面,並準備好開始編寫自己的 codemod。 我們將完成三個練習,涵蓋一些基本但很棒的 codemod 使用,您可以在我的 github 項目上查看這些練習的源代碼。

什麼是 jscodeshift?

jscodeshift 工具包允許您通過轉換抽取一堆源文件並將它們替換為另一端的內容。 在轉換內部,您將源解析為抽象語法樹 (AST),四處尋找以進行更改,然後從更改後的 AST 重新生成源。

jscodeshift 提供的接口是recastast-types包的包裝器。 recast處理從源到 AST 的轉換,而ast-types處理與 AST 節點的低級交互。

設置

首先,從 npm 全局安裝 jscodeshift。

 npm i -g jscodeshift

您可以使用一些運行器選項和一個自以為是的測試設置,這使得通過 Jest(一個開源 JavaScript 測試框架)運行一套測試非常容易,但為了簡單起見,我們現在將繞過它:

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

這將通過轉換some-transform.js運行input-file.js並在不更改文件的情況下打印結果。

不過,在開始之前,了解 jscodeshift API 處理的三種主要對像類型很重要:節點、節點路徑和集合。

節點

節點是 AST 的基本構建塊,通常被稱為“AST 節點”。 這些是您在使用 AST Explorer 探索代碼時看到的內容。 它們是簡單的對象,不提供任何方法。

節點路徑

節點路徑是由ast-types提供的圍繞 AST 節點的包裝器,作為遍歷抽象語法樹(AST,還記得嗎?)的一種方式。 孤立地,節點沒有關於其父級或範圍的任何信息,因此節點路徑負責處理。 您可以通過node屬性訪問包裝的節點,並且有多種方法可用於更改底層節點。 節點路徑通常被稱為“路徑”。

收藏品

集合是 jscodeshift API 在查詢 AST 時返回的零個或多個節點路徑組。 他們有各種有用的方法,我們將探索其中的一些。

集合包含節點路徑,節點路徑包含節點,節點是 AST 的組成部分。 請記住這一點,這樣就很容易理解 jscodeshift 查詢 API。

跟踪這些對象及其各自 API 功能之間的差異可能很困難,因此有一個名為 jscodeshift-helper 的漂亮工具可以記錄對像類型並提供其他關鍵信息。

了解節點、節點路徑和集合之間的區別很重要。

了解節點、節點路徑和集合之間的區別很重要。

練習 1:刪除對控制台的調用

為了讓我們的腳濕透,讓我們從刪除對我們代碼庫中所有控制台方法的調用開始。 雖然您可以使用查找和替換以及一些正則表達式來完成此操作,但對於多行語句、模板文字和更複雜的調用,它開始變得棘手,因此它是一個理想的開始示例。

首先,創建兩個文件remove-consoles.jsremove-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。 如果運行轉換的命令不正確,如果找不到輸入文件,您將看到“錯誤轉換文件...不存在”消息或“類型錯誤:路徑必須是字符串或緩衝區”。 如果您對某些東西進行了粗略的處理,那麼應該很容易發現具有非常描述性的轉換錯誤。

相關: Toptal 的快速實用 JavaScript 備忘單: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(); };

但是我們如何找到控制台並刪除它們呢? 除非您對 Mozilla Parser API 有一些特殊的了解,否則您可能需要一個工具來幫助理解 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,還會找到源中的每個 CallExpression,包括

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調用都是我們正在尋找的,我們需要一些上下文。 這就是 codemods 開始顯示其價值的地方。 讓我們先創建兩個文件deprecated.jsdeprecated.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(); };

為了讀取當前傳入的所有參數,我們在 CallExpressions 集合上使用replaceWith()方法來交換每個節點。 新節點將用一個新的單個參數(一個對象)替換 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 節點類型都定義在 ast-types github 項目的def文件夾中,主要在 core.js 所有 AST 節點類型都有構建器,但它們使用的是駝峰式版本的節點類型,而不是 pascal -案子。 (這沒有明確說明,但您可以在 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"));

因此,為 { foo: 'bar' } 構建 AST 節點的代碼如下所示:

 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, });

帶有 jscodeshift 的 Codemods 回顧

達到這一點花費了一些時間和精力,但是在面對大規模重構時,好處是巨大的。 將文件組分配給不同的進程並並行運行它們是 jscodeshift 擅長的,它允許您在幾秒鐘內跨龐大的代碼庫運行複雜的轉換。 隨著您對 codemod 的精通,您將開始重新利用現有腳本(例如 react-codemod github 存儲庫或為各種任務編寫自己的腳本,這將使您、您的團隊和您的包用戶更有效率.