🚧

jscodeshift によるひとつ上のリファクタリング・テクニックを学ぶ

2023/03/15に公開

イントロダクション

jscodeshift という codemod ツールキットを使って JavaScript / TypeScript コードをリファクタリングするための基礎テクニックをご紹介します。

codemod とは

AST に変換したコードに何かしらの手を加えて別のコードに変換する技術の通称です。grep 置換や IDE を駆使した置換はあくまでコードを文字列のままで書き換えますが、codemod は構造体に変換してから書き換えるというステップを踏むため、非常に複雑なコード変換が可能です。

ちなみに codemod は JavaScript 特有のものではなく、Ruby や JVM 系などさまざまなプログラミング言語の世界にも存在します。

AST とは

https://ja.wikipedia.org/wiki/抽象構文木

正式名は Abstract Syntax Tree (抽象構文木)。簡潔に言うと、構文構造をデータ構造に書き起こしたものです。おそらく web フロントエンドにとって最も身近な例が DOM ツリーです。他にもコンパイラやインタプリタといったプログラミング言語処理の過程で登場することもあります。Prettier, ESLint, Babel, そして TypeScript もコードを AST に変換してから別のものに書き換えるというステップを踏んでいます。実は既に馴染み深い技術と言えるでしょう。

jscodeshift とは

https://github.com/facebook/jscodeshift

Facebook で有名な Meta 社が開発する JavaScript 用の codemod ツールキットです。コードの変更内容を実装するための API と、それを実行するための CLI が同梱されています。元々は Meta 社内向けとして開発されたものですが、後に OSS として公開されました。

React や Storybook のような著名ライブラリーが利用者のアップデート作業を補助するツールを提供することがありますが、大抵が jscodeshift を使って実装されたものです。そのため知らず識らずのうちに jscodeshift を体験した方もいるでしょう。

基本的な使い方

transformer と呼ばれる変換スクリプトを作成し、それを CLI から実行します。

./transformers/example.ts
import type { API, FileInfo } from 'jscodeshift';

// この関数は 1ファイルごとに実行される
export default function transformer(file: FileInfo, api: API) {
  const collection = api.jscodeshift(file.source);

  return collection
    // Step1. node コレクションをトラバース(≒ 探索)し、変換対象となる node を絞り込む。
    .find(/* ... */)
    // Step2. 対象 node を変換する。
    .forEach((path) => {/* ... */})
    // Step3. node コレクションをコード文字列に戻す。
    .toSource();
}
CLI で実行
yarn jscodeshift \
  --transform ./transforms/example.ts src/**/*.ts \
  --parser tsx

--transform ( -t ) オプションで実行する transformer スクリプトファイルパスと変換対象のファイルパス、 --parser オプションでソースコードファイルのパーサーを指定します。デフォルト値は babel ですが、TypeScript コードを変換する際は tsx と指定しておくと良いでしょう[1]tsxts の上位互換に相当)。

success

成功すると上図のようなログが出力されます。 unmodified は変換可能な箇所が見当たらなかったファイルの数を示します。上図は 1 ファイルの変換に成功したことを示しています。

transformer コーディングの Tips

jscodeshift は非常に多くの API を備えていながら公式ドキュメントの情報量が極めて少ないうえ解説記事も殆どないのが実情です。そこで少しでもコーディング体験を向上させるために筆者が実践していることをご紹介します。

TypeScript による入力補完を活用する

先述したサンプルコードにもある通り、jscodeshift は TypeScript で記述された transformer もそのまま実行できます。そのため必ず TypeScript で記述します。 @types/jscodeshift が公開されているため、これを導入することで各種 API の引数・戻り値やそもそもどのような API が存在するのかがある程度分かりやすくなります。

https://github.com/DefinitelyTyped/DefinitelyTyped/tree/master/types/jscodeshift

具体的な使用例(サンプルコード)までは書かれていないものの、そもそもどのような API があるのかすら分からない状態で記述するのと比べれば開発体験が雲泥の差であることは自明でしょう。

AST Explorer を活用する

https://astexplorer.net/

AST Explorer は、ソースコードをリアルタイムに解析して AST をプレビューする web サービスです。例えば some.foo();some.bar(); に変換したい場合は、双方のコードをエディターに記述し、それぞれの AST を見比べながら transformer を記述することで何を・どのように書くべきかが見えやすくなります。

さらに AST Explorer には transformer を記述するエディターと実行結果をプレビューする GUI も搭載されています。

ast explorer の GUI
左上: 変換前のコード | 右上: 変換前コードの AST | 左下: transformer エディタ | 右下: 変換後のコード

ただし、この transformer エディターの入力補完は非常に貧弱なため、実際のコーディングは手元の IDE で行う方が効率的と思われます。

設定

jscodeshift のパーサーは recast なため、AST Explorer の設定もそのようにします。

settings
言語: JavaScript | パーサー: recast | transform: jscodeshift

view option
AST プレビューの推奨設定。 transformer 作成に不要な情報はすべて隠しておくと良い。

parser setting
TypeScript コードを変換したい場合は tsx にすること。

実践的な transformer を書く

サンプルコードはこちら。

https://github.com/wakamsha/learn-jscodeshift

要件

例えば以下のようなコードがあるとします。

./src/main.tsx
import React from "react";

export const App = ({ count }: { count: number }) => (
  <>
    <ul>
      {[...Array(count).keys()].map((i) => (
        <li key={i}>item {i}</li>
      ))}
    </ul>
    <ol>
      {[...Array(4).keys()].map((i) => (
        <li key={i}>item {i}</li>
      ))}
    </ol>
  </>
);

[...Array(n).keys()][0, 1, 2, ...,] という配列を生成するコードで、任意の回数だけループを回す際によく使います。便利なコードですが都度これを記述するのは冗長のため、以下のようなユーティリティー関数として共通化したいところです。

./src/utils/array.ts
/**
 * 0 から `length` まで( `length` の値は含まない)の配列を作成する。
 *
 * @remarks
 * `length` に負の値を指定すると長さ 0 の配列を作成する。
 *
 * @param length 作成する配列の長さ(0以上)。
 * @returns 0 から指定の範囲の配列。
 */
export function range(length: number): number[] {
  if (length < 0) return [];
  return [...Array(length).keys()];
}

要は [...Array(n).keys()]range(n) に置換するだけなのですが、 Array の引数が不定のため単純な grep 置換では実現できません。正規表現を駆使すれば可能でしょうが、検証のためのコストが高くつきます。そこでこれを jscodeshift を使って一撃で書き換えてみます。

設計

transformer は以下のように設計します。

  1. (Step.1) [...Array(n).keys()] に該当する node コレクションを取得する。
  2. (Step.2) 取得した node コレクションを置換する。
    1. [...Array(n).keys()]range(n) に置換する。
    2. import { range } from './utils/array'; を挿入する。
  3. (Step.3) node コレクションをコード文字列に戻す。

2.2 のインポート文の挿入処理は、丁寧にやるなら「既に import {...} from './utils/array' があれば {...} 内に range を挿入するだけにし、なければインポート文ごと挿入する」といった条件分岐まで実装することになります。

// BAD
import { some } from "./utils/array";
import { range } from "./utils/array";

// GOOD
import { range, some } from "./utils/array";

ですが、今回はあえてそのような考慮をせず、何も考えず import { range } from './utils/array'; を挿入します。上記の BAD パターンになりますが、この程度であれば eslint-plugin-import による自動補正の範疇のため、そちらに委ねる方が効率的だからです。

実装

完成済みのコード
./transforms/replace-range.ts
import type { API, Collection, FileInfo, JSCodeshift } from 'jscodeshift';

export default function transform({ source }: FileInfo, { jscodeshift: j }: API) {
  const rootCollection = j(source);

  // Step.1
  // `[...Array(n).keys()]` に該当する node コレクションを取得する
  const collection = rootCollection.find(j.ArrayExpression, {
    elements: [
      {
        type: 'SpreadElement',
        argument: {
          callee: {
            object: {
              callee: {
                name: 'Array',
              },
            },
            property: {
              name: 'keys',
            },
          },
        },
      },
    ],
  });

  if (collection.length > 0) {
    // Step.2
    replaceToRange(j, collection);
    insertImportSpecifier(j, rootCollection);
  }

  // Step.3
  return rootCollection.toSource();
}

/**
 * `[...Array(n).keys()]` を `range(n)` に置換する。
 */
function replaceToRange(j: JSCodeshift, collection: Collection) {
  collection.forEach((path) => {
    j(path).replaceWith(
      j.callExpression(
        j.identifier('range'),
        path.value.elements[0].argument.callee.object.arguments,
      ),
    );
  });
}

/**
 * `import { range } from './utils/Array';` を挿入する。
 */
function insertImportSpecifier(j: JSCodeshift, rootCollection: Collection) {
  rootCollection
    .find(j.ImportDeclaration)
    .at(0)
    .get()
    .insertBefore(
      j.importDeclaration(
        [j.importSpecifier(j.identifier('range'))],
        j.stringLiteral('./utils/array'),
      ),
    );
}

まずは transformer のアウトラインを書きます。

./transforms/replace-range.ts
import type { API, FileInfo } from 'jscodeshift';

export default function transform({ source }: FileInfo, { jscodeshift: j }: API) {
  const rootCollection = j(source);

  // Step.1
  // `[...Array(n).keys()]` に該当する node コレクションを取得する。
  const collection = rootCollection.find();

  // Step.2
  // [...Array(n).keys()]` を `range(n)` に置換する。
  // `import { range } from './utils/Array';` を挿入する。

  // Step.3
  return rootCollection.toSource();
}

Step.1

先ほど紹介した AST Explorer を併用しながら TypeScript の入力補完を頼りに find の引数を書きます。

Step.1
// Step.1
// `[...Array(n).keys()]` に該当する node コレクションを取得する
const collection = rootCollection.find(j.ArrayExpression, {
  elements: [
    {
      type: "SpreadElement",
      argument: {
        callee: {
          object: {
            callee: {
              name: "Array",
            },
          },
          property: {
            name: "keys",
          },
        },
      },
    },
  ],
});

[...Array(n).keys()] は配列型なので find の第一引数に j.ArrayExpression という定数を渡します。第二引数には配列のうち [...Array(n).keys()] に該当する構造の型をフィルタリング条件として渡します。なお、 find をはじめ jscodeshift の API の多くはメソッドチェーンに対応しているため、上記のように一度に完全な構造を渡さず何回かに分けてのフィルタリングも可能です。

Step.2

ここでは 2 つの処理を実装しますが、1 つのメソッドチェーンで表現するには複雑なため、2 回に分けて実装します。

Step.2
// 該当する node コレクションがある場合のみ処理する。
if (collection.length > 0) {
  // Step.2
  // `[...Array(n).keys()]` を `range(n)` に置換する。
  collection.forEach((path) => {
    j(path).replaceWith(
      j.callExpression(
        j.identifier('range'),
        path.value.elements[0].argument.callee.object.arguments,
      ),
    );
  });
  // `import { range } from './utils/Array';` を挿入する。
  rootCollection
    .find(j.ImportDeclaration)
    .at(0)
    .get()
    .insertBefore(
      j.importDeclaration(
        [j.importSpecifier(j.identifier('range'))],
        j.stringLiteral('./utils/array'),
      ),
    );
}

Step.1 で該当する node コレクションが取得できなかった(= 見つからなかった)場合にここの処理をスキップするため、全体を if 文で囲っています。 j.callExpression の第一引数には range という node を生成して渡し、第二引数にはその range に渡す引数の node を渡しています。こちらは変換前の値をそのまま渡す必要があるため、もとの値の node である path.value.elements[0].argument.callee.object.arguments を渡しています。

Step.3

基本的には rootCollection.toSource() の戻り値を返せば OK です。

Step.3
// Step.3
return rootCollection.toSource();

ちなみに toSource の引数に { parser: 'tsx' } を渡すことで CLI の --parser tsx オプションと同じ効果を得られます。他にも tabWidthquote などフォーマットに関するオプションも用意されており、ここで Prettier のような処理を適用できます。

https://github.com/facebook/jscodeshift#passing-options-to-recast

適用

CLI を実行して codemod を実施します。

yarn jscodeshift \
  --transform ./transforms/replace-range.ts src/**/*.tsx \
  --parser tsx

成功すると以下のように変換されます。

./src/main.tsx
+import { range } from './utils/array';
 import React from "react";

 export const App = ({ count }: { count: number }) => (
   <>
     <ul>
-      {[...Array(count).keys()].map((i) => (
+      {range(count).map((i) => (
         <li key={i}>item {i}</li>
       ))}
     </ul>
     <ol>
-      {[...Array(4).keys()].map((i) => (
+      {range(4).map((i) => (
         <li key={i}>item {i}</li>
       ))}
     </ol>
   </>
 );

長くなりましたが、実践的な transform をコーディングする流れをステップ・バイ・ステップで紹介しました。

codemod は習得・導入するに値する技術なのか

少ないながらも実務で導入した実績がある筆者ですが、積極的に導入を推進していきたいとは言い難いのが本音です。

多くの人にとって学習コストは高くつく

大前提として AST の仕組みをある程度理解する必要があります。Linter やパーサー、そしてコンパイラーのようなツールの開発経験がある人ならまだしも、アプリケーション開発を主な生業としている人にとっては馴染みが薄いうえ、体系的に学習できる教材へのアクセスも容易ではないことから、そのハードルの高さは想像に難くないでしょう。

また、jscodeshift のドキュメントや解説記事が不足しているのも理由として挙げられます。Getting started 程度のチュートリアルしかないうえ API リファレンスすら無いため、ソースコードを読みながらひとつひとつ API を読み進める忍耐が求められます。CSS のようにひたすら自分で書いて少しずつ習得するしかありません。

codemod の知見がチームに貯まりにくい

作成する transformer はあくまでリファクタリングのためのものなので、基本的に書き捨てとなります。そのためアプリケーションコードやタスクランナー用スクリプトと違って保守・再利用されるようなものではないため、作成者以外のメンバーに知見が波及しにくいと言えます。

そもそも codemod は中〜大規模なリファクタリングを想定しているため、自ずと利用頻度は少なくなります。まして昨今は IDE による一括置換である程度のリファクタリングは達成できてしまうため、codemod を利用するシーンはそれほど多くないでしょう。むしろ頻繁にこれを使わねばならないような状態なら、先にコードベースの設計の脆弱さと開発方針の稚拙さを先に見直すべきです。

codemod は迅速な大規模リプレイスの夢を見せるか

ネガティブなことを述べましたが、手札として持っておけば間違いなく超強力な武器になることは間違いありません[2]。もし 1 つのリファクタリングを半年かけて手作業で行うとなると、その期間中は負債したコードと新設計のコードの双方が混在することとなります。その期間中に参画した新人は、新旧のコードのうちどちらが正解なのか担当者に訊くまで判断できません。もし担当者がリファクタリング途中のまま(退職や長期休暇などで)不在になると、大して理解できないまま見様見真似でコーディングすることになりますが、この期間が短ければ短いほどそういったリスクは回避できます。

コードに一貫性がなければ役に立たない

大規模かつ複雑なパターンのコードを変換できる codemod ですが、所詮はある特定のルールに基づき一括変換するツールであることに変わりはありません。すなわち対象となるコードベースに一貫性が備わっていなければ、十分にその機能を発揮できません。

先述した range(n) に変換するリファクタリングを実際に行った際、一部のコードが [...Array(n).keys()] でなく [...Array(n)] となっていたため一撃で変換できませんでした。

[...Array(4).keys()]; // [0, 1, 2, 3]
[...Array(4)]; // [undefined, undefined, undefined, undefined]

どちらも指定の長さの配列を生成するという意味では同じなため、双方が混在してしまったというわけです。key() 付きのコードに揃えることで無事に codemod を適用できたものの、期待していた恩恵が受けられたとは言えません。どんなに便利かつ強力なツールであっても、対象となるコードベースに一貫性がなければ十分な役目を果たせないのです。中途半端な書き直しのまま放置されるくらいなら、たとえ冗長であっても一貫性が担保されているコードの方が遥かに保守性があります。codemod が適用しやすいのはもちろん、開発者が迷わずにコーディングできるからです。そこの重要性こそが、筆者が jscodeshift を通じて得た一番の学びです。

脚注
  1. babel だと型定義部分のコードをパースできず失敗します。 ↩︎

  2. かつて Facebook は Mocha で書かれた数十万行のテストコードを数日で Jest 用テストコードに書き換えたという逸話があります。 ↩︎

Discussion