コードを書き直してコードを書き直す:jscodeshift

公開: 2022-03-11

jscodeshiftを使用したCodemod

ディレクトリ全体で検索と置換機能を使用してJavaScriptソースファイルに変更を加えたことは何回ありますか? コードベースがかなり大きい場合は努力する価値があるので、あなたが上手であれば、キャプチャグループで正規表現を使いこなすことができます。 ただし、正規表現には制限があります。 重要な変更については、コンテキスト内のコードを理解し、長くて退屈でエラーが発生しやすいプロセスを進んで引き受ける開発者が必要です。

これが「codemods」の出番です。

Codemodは、他のスクリプトを書き直すために使用されるスクリプトです。 それらは、コードの読み取りと書き込みが可能な検索および置換機能と考えてください。 これらを使用して、チームのコーディング規則に合わせてソースコードを更新したり、APIが変更されたときに広範囲に変更を加えたり、パブリックパッケージが重大な変更を加えたときに既存のコードを自動修正したりすることができます。

jscodeshiftツールキットは、codemodを操作するのに最適です。

codemodは、コードの読み取りと書き込みが可能なスクリプト化された検索および置換機能と考えてください。
つぶやき

この記事では、複雑さが増す3つのコードモッドを作成しながら、「jscodeshift」と呼ばれるコードモッドのツールキットについて説明します。 最終的には、jscodeshiftの重要な側面に幅広く触れることができ、独自のcodemodの作成を開始する準備が整います。 codemodsの基本的で素晴らしい使用法をカバーする、3つの演習を行います。これらの演習のソースコードは、私のgithubプロジェクトで確認できます。

jscodeshiftとは何ですか?

jscodeshiftツールキットを使用すると、変換を介して大量のソースファイルをポンプし、それらをもう一方の端から出力されるものに置き換えることができます。 変換内で、ソースを抽象構文ツリー(AST)に解析し、変更を加えてから、変更されたASTからソースを再生成します。

jscodeshiftが提供するインターフェースは、 recastおよびast-typesパッケージのラッパーです。 recastはソースからASTへの変換とその逆の変換を処理し、 ast-typesはASTノードとの低レベルの相互作用を処理します。

設定

開始するには、npmからグローバルにjscodeshiftをインストールします。

 npm i -g jscodeshift

使用できるランナーオプションと、Jest(オープンソースのJavaScriptテストフレームワーク)を介した一連のテストの実行を非常に簡単にする意見のあるテストセットアップがありますが、ここでは単純化のためにそれをバイパスします。

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

これにより、 input-file.jsが変換some-transform.jsを介して実行され、ファイルを変更せずに結果が出力されます。

ただし、ジャンプする前に、jscodeshift APIが処理する3つの主要なオブジェクトタイプ(ノード、ノードパス、およびコレクション)を理解することが重要です。

ノード

ノードはASTの基本的な構成要素であり、「ASTノード」と呼ばれることもあります。 これらは、ASTエクスプローラーでコードを探索するときに表示されるものです。 これらは単純なオブジェクトであり、メソッドを提供しません。

ノードパス

ノードパスは、抽象構文木(AST、覚えていますか?)をトラバースする方法としてast-typesによって提供されるASTノードのラッパーです。 単独では、ノードはその親またはスコープに関する情報を持っていないため、ノードパスがそれを処理します。 nodeプロパティを介してラップされたノードにアクセスでき、基になるノードを変更するために使用できるいくつかの方法があります。 ノードパスは、単に「パス」と呼ばれることがよくあります。

コレクション

コレクションは、ASTにクエリを実行したときにjscodeshiftAPIが返す0個以上のノードパスのグループです。 それらにはあらゆる種類の有用な方法があり、そのうちのいくつかを探求します。

コレクションにはノードパスが含まれ、ノードパスにはノードが含まれ、ノードはASTの構成要素です。 そのことを覚えておいてください。そうすれば、jscodeshiftクエリAPIを理解しやすくなります。

これらのオブジェクトとそれぞれのAPI機能の違いを追跡するのは難しい場合があるため、オブジェクトタイプをログに記録し、その他の重要な情報を提供するjscodeshift-helperと呼ばれる便利なツールがあります。

ノード、ノードパス、およびコレクションの違いを知ることは重要です。

ノード、ノードパス、およびコレクションの違いを知ることは重要です。

演習1:コンソールへの呼び出しを削除する

足を濡らすために、コードベース内のすべてのコンソールメソッドへの呼び出しを削除することから始めましょう。 これは、検索と置換、および少しの正規表現を使用して行うことができますが、複数行のステートメント、テンプレートリテラル、およびより複雑な呼び出しで扱いにくくなるため、最初は理想的な例です。

まず、 remove-consoles.jsremove-consoles.input.js consoles.input.jsの2つのファイルを作成します。

 //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

OK、私たちの変換は実際にはまだ何もしていないので、それは少し反気候的でしたが、少なくとも私たちはそれがすべて機能していることを知っています。 まったく実行されない場合は、jscodeshiftをグローバルにインストールしたことを確認してください。 変換を実行するコマンドが正しくない場合、「ERROR変換ファイル…が存在しません」というメッセージが表示されるか、入力ファイルが見つからない場合は「TypeError:パスは文字列またはバッファである必要があります」と表示されます。 何かを太くした場合は、非常にわかりやすい変換エラーを簡単に見つけることができます。

関連: 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エクスプローラーを使用できます。 remove-consoles.input.jsの内容を貼り付けると、ASTが表示されます。 最も単純なコードでも多くのデータがあるため、位置データとメソッドを非表示にするのに役立ちます。 ツリーの上にあるチェックボックスを使用して、ASTエクスプローラーのプロパティの表示を切り替えることができます。

コンソールメソッドの呼び出しは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ノードをラップする1つのノードパスのコレクションを返します。 コレクションのfindメソッドを使用して、次のように特定のタイプの子孫ノードを検索できます。

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

これにより、CallExpressionsであるノードのみを含むノードパスの別のコレクションが返されます。 一見、これは私たちが望んでいることのように見えますが、広すぎます。 変換によって数百または数千のファイルが実行される可能性があるため、意図したとおりに機能することを確信できるように正確にする必要があります。 上記の素朴なfindでは、コンソールのCallExpressionsが検索されるだけでなく、ソース内のすべてのCallExpressionが検索されます。

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

より高い特異性を強制するために、 .findに2番目の引数を提供します。追加のパラメーターのオブジェクト。各ノードを結果に含める必要があります。 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:インポートされたメソッド呼び出しの置き換え

このシナリオでは、「getCircleArea」を優先して非推奨にした「circleArea」という名前のメソッドを持つ「geometry」モジュールがあります。 これらを簡単に見つけて/geometry\.circleArea/gに置き換えることができますが、ユーザーがモジュールをインポートして別の名前を割り当てた場合はどうなりますか? 例えば:

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

geometry.circleArea .circleAreaの代わりにg.circleAreaを置き換える方法をどのように知ることができますか? 確かに、すべてのcircleArea呼び出しが私たちが探しているものであると想定することはできません。何らかのコンテキストが必要です。 これは、codemodがその値を表示し始める場所です。 deprecated.jsdeprecated.input.jsの2つのファイルを作成することから始めましょう。

 //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エクスプローラーを見て、私たちが探しているものを見つけましょう。 私たちのインポートはこの形式を取ります。

 { "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エクスプローラーに表示されるものです。 node-pathは、ノード自体ではなく、ノードのスコープと関係に関する情報であることに注意してください。

 // 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ノードは、コールバックから返すノードに置き換えられます。

Codemodを使用すると、リファクタリングに関する「インテリジェント」な考慮事項をスクリプト化できます。

繰り返しになりますが、これを理解するには、コレクション、ノードパス、ノードの違いを理解する必要があります。

置換が完了したら、通常どおりソースを生成します。 完成した変換は次のとおりです。

 //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エクスプローラーと前の演習で使用したプロセスを使用すると、最初の2つのステップは簡単です。

 //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ノードに単に詰め込むことはできないことがわかりました。 代わりに、ビルダーを使用して適切なノードを作成する必要があります。

関連:フリーランスのJavascript開発者の上位3%を雇います。

ノードビルダー

ビルダーを使用すると、新しいノードを適切に作成できます。 それらはast-typesによって提供され、jscodeshiftを介して表示されます。 彼らは、さまざまなタイプのノードが正しく作成されていることを厳密にチェックします。これは、ロールでハッキングしているときにイライラする可能性がありますが、最終的にはこれは良いことです。 ビルダーの使用方法を理解するには、次の2つの点に注意する必要があります。

使用可能なすべてのASTノードタイプは、ast-types githubプロジェクトのdefフォルダー、主にcore.jsで定義されています。すべてのASTノードタイプのビルダーがありますが、パスカルではなく、キャメルケースバージョンのノードタイプを使用します。 -場合。 (これは明示的には述べられていませんが、ast-typesソースの場合であることがわかります

結果をどのようにするかを示す例を使用してASTExplorerを使用すると、これを非常に簡単に組み合わせることができます。 この例では、新しい単一の引数を一連のプロパティを持つ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リポジトリや、あらゆる種類のタスク用に独自のスクリプトを作成するなど)の転用を開始します。これにより、あなた、チーム、およびパッケージユーザーの効率が向上します。 。