Sansan Tech Blog

Sansanのものづくりを支えるメンバーの技術やデザイン、プロダクトマネジメントの情報を発信

TypeScript / JavaScript の import を自動でソートする

こんにちは。Eight でエンジニアをしている鳥山(@pvcresin)です。
マイブームはコンビニで買える GODIVA のベルギーダークチョコレート(アイス)を食べることです。 濃厚で甘すぎず、量も多すぎないところが気に入っています。
今回は TypeScript や JavaScript の import を自動でソートする話をしたいと思います。 といっても、TypeScript でソートがうまく行けば JavaScript でも大抵うまくいくので、主に TypeScript にフォーカスした話になるかと思います。

背景

import をソートしようと思ったきっかけは、チームのメンバーが出した 1 つの PR(プルリクエスト)でした。 新規作成したファイルの import が、ライブラリからの import とその他のファイルからの import でそれぞれまとめられており、見やすいと感じました。 とはいえ、毎回手動で import を整理するのは大変ですし、他のメンバーに取り組みを広げることも難しいので仕組み化したいという話になりました。

import を自動ソートするメリット

import を自動ソートするメリットについて考えてみると、以下の 2 点が挙げられます。

  1. どのライブラリに依存しているかがひと目で分かる
  2. 同じライブラリ・ファイルからの import に気づきやすくなる

1. どのライブラリに依存しているかがひと目で分かる

今までは import の書き方に関してルールを設けていなかったので、順番はバラバラでした。 ライブラリからの import をまとめておけば、ファイルを見たときにどのライブラリに依存しているかがひと目で分かるようになります。

import axios from "axios";
import foo from "../foo";
import bar from "./bar";
import * as React from "react";

のようにバラバラよりは、

import axios from "axios";
import * as React from "react";

import foo from "../foo";
import bar from "./bar";

のようにライブラリ(axiosreact)からの import がまとまっていたほうが、依存するライブラリが認識しやすくなります。
それに伴って、何をするファイルなのかも想像しやすくなるため、コードリーディングもより速くなると考えられます。

2. 同じライブラリ・ファイルからの import に気づきやすくなる

たくさんの import をしているファイルでは、どこから import しているかを完璧に把握するのは難しくなります。 結果として、既に import しているファイルからの import にも関わらず、まだ一度も import していないファイルからの import と勘違いして行を追加してしまうことが起きます。 例えば、

import Baz from "./baz";
import axios from "axios";
import foo from "../foo";
import bar from "./bar";
import * as React from "react";
import { b } from "./baz"; // 追加した import

のように b を import したければ、

import Baz, { b } from "./baz"; // ここにまとめられる
import axios from "axios";
import foo from "../foo";
import bar from "./bar";
import * as React from "react";

のように一行目にまとめられます。 レビューで「追加した import は上の import とまとめられますね。」とコメントできればよいですが、見逃してしまうこともあります。
import がアルファベット順などでソートされていれば、

import axios from "axios";
import * as React from "react";

import foo from "../foo";
import bar from "./bar";
import Baz from "./baz"; // 元からある import
import { b } from "./baz"; // 追加した import

のように元からある import と追加した import が近くにまとまるため、同じファイルからの import に気づきやすくなります。
また、賢い自動ソートであれば、

import axios from "axios";
import * as React from "react";

import foo from "../foo";
import bar from "./bar";
import Baz, { b } from "./baz"; // まとめてくれる

のように勝手に import をまとめてくれます。 これによってファイルの行数が無駄に長くなってしまうのを防ぐことができます。

ソートしたい import の種類をあげる

そもそもコードにどんな import が使われているのかを調査しました。 これによってソートできなければならないコードはどんなものなのかを明確にすることができます。 調査の結果、大きく分けて以下のような import の種類がありました。

/* import の仕方 */
import "module-name";                           // 副作用のためだけ
import defaultExport from "module-name";        // デフォルト export
import { export1 } from "module-name";          // 個別の export
import type { Export1 } from "module-name";     // 型のみ

/* ファイルのパス */
import defaultExport from "./module-name";      // 相対パス
import defaultExport from "@alias/module-name"; // パスの alias

/* CommonJS */
require("module-name");                         // 副作用のためだけ
const defaultExport = require("module-name");   // module.exports

import の仕方では、polyfill など変数に代入せずに副作用のためだけに import するものと、変数に代入する import がありました。 また、TypeScript v4 を使っているため、型のみを import する import type 構文もあります。
ファイルパスでは ../foo./baz などの相対パスに加えて、@alias/module-name のような独自のパス alias がありました。 パス alias の解決には babel-plugin-module-resolver を使っています。 TypeScript でも認識させるため、tsconfig.json の baseUrl, paths にも alias の設定を書いています。
加えて、リポジトリ内には Node.js のコードもあったため、できれば CommonJS の require もソートしたいという思いがありました。

実現方法の検討

これらを踏まえて、自動ソートの実現方法について考えていきます。 いくつか調べたところ、大きく分けてフォーマッター(コード整形ツール)の Prettier を使って自動整形を行う方法とリンター(静的解析ツール)の ESLint を使ってリントエラーの AutoFix(自動修正)を行う方法がありました。

最終的には以下の 4 つのプラグインを検討しました。

  1. prettier-plugin-sort-imports
  2. sort-imports
  3. eslint-plugin-import (← 採用したもの)
  4. eslint-plugin-simple-import-sort

1. prettier-plugin-sort-imports

prettier-plugin-sort-imports は Prettier に import のソート機能を追加するプラグインです。 import したいファイルパスのパターンを列挙することで import を自動でソートしてくれます。 import のソートはコードの挙動を変えてしまう可能性もあるので、フォーマッターの機能を逸脱している感は否めませんが、 Prettier を既に導入しているプロジェクトであれば、かなり手軽に自動ソートを実現できます。 一方、機能追加が行われている真っ最中という印象で、採用するには時期尚早に感じました。

2. sort-imports

sort-imports は ESLint に標準搭載されているルールで、設定した import の順番でない場合にリントエラーを表示します。 AutoFix による自動ソートも可能です。 行ごとのソートだけでなく、import { b, a } from 'foo';import { a, b } from 'foo'; のように import した変数も並び替えてくれます。 しかし、パス alias の設定ができないなど、カスタマイズ性は低いと感じました。

3. eslint-plugin-import(← 採用したもの)

eslint-plugin-import は ESLint のプラグインで、import についてまとまったルール郡です。 中でも import/order のルールを使うことで、 import の順番を細かく規定することができます。 import/order はパス alias の設定ができ、require にも対応しています。 しかし、import 'foo'; のような副作用のみの import をしている行がうまくソートされませんでした。

4. eslint-plugin-simple-import-sort

eslint-plugin-simple-import-sort も ESLint のプラグインで、import についてまとまったルール郡です。 require には対応していませんが、パス alias の設定ができ、export もソートすることができます。 また、このプラグインは機能を名前の通り simple に保つため、 eslint-plugin-import や Prettier と組み合わせて使うことを視野に入れた作りになっています。

比較

以上、4 つを比較して決定します。表にまとめると

メリット デメリット
prettier-plugin-sort-imports 設定が手軽。 まだ機能が少なめ。
sort-imports 変数の並び替えに対応。 パス alias 非対応。
eslint-plugin-import パス alias、require に対応。 副作用のみの import をしている行がうまくソートされない。
eslint-plugin-simple-import-sort パス alias、export のソートに対応。 require 非対応。

パス alias はコード内でかなり使われていたので、sort-imports ルールを使う方法は不採用としました。また、npm trends でダウンロード数の推移 もチェックしました。

npm trends での各ツールのダウンロード数の推移
npm trends での各ツールのダウンロード数の推移

やはり、古くからある eslint-plugin-import のダウンロード数が多く、カスタマイズ性の高さも加味して、こちらを採用しました。

導入手順

まったく import をソートしていない状態からだと差分が大きいので、段階的にソートする範囲を広げていく方針をとりました。

1. TypeScript で ESLint を認識できるようにする

まず、TypeScript のファイルで ESLint を動かす設定が必要です。 これには、@typescript-eslint を使っています。
Eight では、以下のように拡張子で TypeScript ファイルかを判別し、既存の ESLint の設定を上書くようにしています。(説明のため、関係ない部分は適宜省略)

module.exports = {
  rules: {},
  overrides: [
    {
      // TypeScript 用に設定を上書く
      files: ["*.ts", "*.tsx"],
      parser: "@typescript-eslint/parser",
      plugins: ["@typescript-eslint"],
      extends: ["plugin:@typescript-eslint/recommended"],
      parserOptions: {
        sourceType: "module",
        ecmaVersion: 2020,
        ecmaFeatures: {
          jsx: true,
        },
      },
      rules: {},
    },
  ],
};

2. import のソートに関する設定を追加

ソートには、eslint-plugin-import と TypeScript での import の解決に eslint-import-resolver-typescript を使用します。 その上で、適用する範囲に対して import の順番のルール(import/order)を追加します。

module.exports = {
  plugins: ["import"], // eslint-plugin-import を追加
  settings: {
    // TypeScript の import を eslint-import-resolver-typescript で解決
    "import/resolver": {
      typescript: {
        alwaysTryTypes: true,
      },
    },
  },
  rules: {},
  overrides: [
    {
      // TypeScript 用に設定を上書く
      files: ["*.ts", "*.tsx"],
      rules: {},
    },
    {
      // import を sort するため、AutoFix をかける範囲で設定を上書く
      files: ["src/folder/**/*.{js,jsx,ts,tsx}"],
      rules: {
        "import/order": [
          "error",
          {
            groups: [
              "builtin",
              "external",
              "parent",
              "sibling",
              "index",
              "object",
              "type",
            ],
            pathGroups: [
              {
                pattern: "@alias/**",
                group: "parent",
                position: "before",
              },
            ],
            alphabetize: {
              order: "asc",
            },
            "newlines-between": "always",
          },
        ],
      },
    },
  ],
};

overrides の最後に追加することで files の範囲にあるファイルにおいて、既存のルールにソートの設定を追加しています。
rules に import/order を追加し、この中で詳細な import の順番を定義していきます。 groups は下のような順になるように定義しています。

// 1. "builtin": Node.js のビルトイン
import fs from "fs";

// 2. "external": 外部ライブラリ
import _ from "lodash";

// 3. "parent": 親ディレクトリ
import foo from "../foo";

// 4. "sibling": 兄弟ディレクトリ
import bar from "./bar";

// 5. "index": 同ディレクトリの index ファイル
import main from "./";

// 6. "object": オブジェクト
import log = console.log;

// 7. "type": 型
import type { Foo } from "foo";

また、pathGroups で @alias/foo のパスをどこのグループ間に入れるかを決めることができます。 今回の設定では parent グループの前に挿入されます。

// 2. "external": 外部ライブラリ
import _ from "lodash";

// @alias はここに挿入される
import hello from "@alias/hello";

// 3. "parent": 親ディレクトリ
import foo from "../foo";

alphabetize でグループ内ではアルファベット順に並べ、"newlines-between": "always" でグループ間に 1 行のスペースを確保するようにしました。

3. コードのソート

コードのソートを行っていきます。 eslint src --ext js,jsx,ts,tsx --fix のように AutoFix 用のオプションをつけてコマンドを実行してあげれば、適用範囲内のファイルのみ import が自動でソートされます ✨

ソートを行っていく中でいくつか気づいたことがあります。
まず、import の説明のために上の行などにつけているコメントは連動してソートされないため、事前に消しておいたほうが良いです。

import baz from "./baz";
// comment
import foo from "foo";

をソートすると

import foo from "foo";

import baz from "./baz";

// comment

のようにコメントが取り残されてしまいます。

また、import 'foo'; のような副作用のための import はソートされなかったため

import baz from "./baz";
import "hello";
import foo from "foo";
import "world";

import "hello";
import "world";

import foo from "foo";

import baz from "./baz";

のように順番を変えないように一番上に固めるようにしました。

最後に、import の数が多く順番がバラバラのファイルの場合、1 回の AutoFix ではソートしきれないことがあったので、自動で修正可能なエラーが表示されなくなるまで 2・3 回 AutoFix を繰り返しました。 一度 import がきれいにソートされれば、数回の AutoFix が必要になることも少なくなると考えています。

4. pre-commit hook、CI の設定

今後のファイルに変更を加える場合には AutoFix が行われるように、 Git のコミット前フックを利用し、範囲内のファイルのコミット時に AutoFix をかけるようにしました。 これには Huskylint-staged を使いました。

また、Circle CI の job に ESLint のリントチェックを行う step(eslint src --ext js,jsx,ts,tsx)を追加しました。 これで新規のコードに対しても自動ソートが行われる状態を作ることができました。

ちなみに Visual Studio Code(VSCode)を使っている場合は、ESLint の拡張 をインストールし、以下の設定を行うことでファイル保存時に AutoFix が走って便利です。

// .vscode/settings.json
{
  "editor.codeActionsOnSave": {
    "source.fixAll.eslint": true
  }
}

5. 適用範囲を広げる

最後に、ESLint で設定した import のソートを行う範囲を少しずつ広げて、PR を出し続けていきます。
基本的には自動でいい感じにソートされますが、import につけていたコメントが取り残されたり、特定のパターンでソートされないといった問題がないかチェックしながら進めていきます。 適用範囲がリポジトリ全体に広がれば、ESLint で override した部分は共通の rules に移動して OK です。

まとめ

実際に導入してみての感想ですが、想定通りコードリーディングの速度は上がったように感じます🎉
既存のコードはもちろんのこと、多少ですが PR レビューもしやすくなりました。
当初は割とサクッと導入が終わると考えていたのですが、予想以上に import のソートを行うためのツールがたくさんあり、使い勝手も異なるので選定に苦労しました。
ツールが多いということは、逆に言えばどれかが仮にメンテナンスされなくなったとしても、ある程度は別のツールで代替できることを意味するので、そこまで悪いことでもないと思いました。
import の自動ソートは「地味に便利」程度のことですが、小さくても誰かのノウハウを仕組みに落とし込むことが重要だなと感じました。


buildersbox.corp-sansan.com

© Sansan, Inc.