Lintを遅くしているESLintルールを特定する方法
TIMING=1とConfig Inspectorで遅いESLintルールを特定。型情報を使うルールや負荷の高いプラグインを比較し、設定を調整してLintを高速化します。
ESLintが遅い場合、原因はたいてい一部のルールにあります。具体的には、型情報を利用するtypescript-eslintのルール、eslint-plugin-prettier、@stylistic/indent、そしてimport/no-cycleのようにファイルをまたいで解析するeslint-plugin-importのルールです。これらはTIMING=1とESLint Config Inspectorのstatsパネルを使えば特定できます。
Lintの実行が遅いと、そのコストはすぐに膨らみます。pre-commitフックに時間がかかるようになると、開発者は--no-verifyを付けてコミットし始め、そうなるとLint設定はもはや何も守れなくなります。
本ガイドでは、ルールごとのコストを計測する方法、問題になりやすいルール、そしてそれぞれについてeslint.config.jsで何を変更すべきかを解説します。
重要なポイント
- 型情報を利用したLint(型付きLint)を行う場合、ESLintのフル実行にかかる時間は、同じプロジェクトでの
tscの実行時間とおおむね同程度になるはずです。それより大幅に長い場合は、特定のルールまたは設定に問題があります。 TIMING=1 npx eslint .を実行すると、最も遅いルールが表示されます。TypeScriptは型情報をキャッシュするため、最初に実行される型情報利用ルールがほぼ必ず最も遅く見えます。疑わしいルールは1つずつ検証してください。- ESLint Config Inspector 3.4.0では
--statsフラグと、必要に応じてESLintのタイミング計測を実行するstatsパネルが追加されました。これはESLint本体の--statsとは別のフラグです。 - typescript-eslintのドキュメントでは、
import/no-cycle、import/no-unused-modules、import/no-deprecated、import/no-named-as-defaultはTypeScriptに同等のチェックがないため、CIまたはpush時にのみ実行することが推奨されています。 eslint-plugin-prettierはすべてのファイルを2回パースします。prettier --checkを独立したステップとして実行すれば、同じ合否結果が得られます。
ESLintが遅いのはなぜか
ESLintが遅い原因は、TypeScriptそのものではなく、少数の高コストなルールであることがほとんどです。型付きLintを行う場合、ESLintのフル実行は型チェックとほぼ同じ時間で終わるはずです。typescript-eslintのパフォーマンスガイドによると、速度低下のよくある原因は、型情報を利用するルールがTypeScriptの型チェッカーに情報を問い合わせることです。まずはベースラインを計測しましょう。
npx tsc --noEmit
tscが速いのにLintが大幅に遅い場合、原因はTypeScriptそのものではなく、ルールまたは設定にあります。
TIMING=1でルールの実行時間を確認するには
環境変数TIMINGを設定すると、ESLintは最も時間のかかったルールを表示します。追加のインストールは不要です。
TIMING=1 npx eslint .
この動作はESLintのProfile Rule Performanceドキュメントに記載されています。
出力を読む際には注意が必要です。TypeScriptは、最初の型情報利用ルールが要求した時点で型情報を構築し、それ以降のすべてのルールのためにキャッシュします。その結果、実行中で最初に動く型情報利用ルールは、実際には低コストであってもほぼ必ず最も遅く見えます。
公平に比較するには、型情報利用ルールを1つずつ、他のルールを無効にした状態で個別に計測します。1つの方法として、疑わしいルールを1つだけ含む簡略化した設定ファイルを用意し、-c/--configでESLintに指定します。
TIMING=1 npx eslint -c eslint.timing.config.js .
ESLint Config Inspectorで実行時間を確認する
ESLint Config Inspectorにはタイミング表示機能があります。リリースノートによると、バージョン3.4.0でdevコマンドとbuildコマンドに--statsフラグが追加され、必要に応じてESLintのタイミング計測を実行するstatsパネルも追加されました。したがって、タイミング表示にはConfig Inspector 3.4以降が必要です。なお、3.4.1ではESLintがパースのタイミング情報なしでパスを報告した際にbuild --statsがクラッシュする問題が修正されているため(issue #333)、3.x系の最新版を使用してください。
この--statsはConfig Inspectorのものです。ESLint本体にも独自の--statsフラグがありますが、こちらは各ファイルのLint結果にタイミングの数値を含め、フォーマッターで表示できるようにするもので、パネルを開くものではありません。
以下のコマンドは、eslint.config.jsがあるフォルダで実行してください。
# インストール済みのバージョンで利用可能なオプションを一覧表示
pnpx @eslint/config-inspector --help
# タイミングデータ付きの静的スナップショットを dist/__eslint-config-inspector に出力
pnpx @eslint/config-inspector build --stats
pnpx @eslint/config-inspectorをそのまま実行すると、http://localhost:7777でインスペクターが起動します。devコマンドに--statsを渡し(正確な書式は--helpで確認できます)、statsパネルを開いて、時間の大半を占めているルールやプラグインを探してください。最初の型情報利用ルールに関する注意点は、このビューでも同様に当てはまります。静的ビルドはスナップショットなので、設定変更前のものを保存しておき、変更後に取得したものと比較できます。
特に遅くなりやすいESLintルールは
タイミング出力の上位には、次の4つのグループのESLintルールが繰り返し登場します。
- 型情報を利用するルール。
recommendedTypeCheckedなどのプリセットは、型チェッカーに依存するルールを追加します。セットアップについてはESLintでTypeScriptをLintするを参照してください。 eslint-plugin-prettier。 このプラグインはファイル全体に対してPrettierを実行しなければフォーマットの差分を検出できないため、Lint対象のすべてのファイルがESLintで1回、Prettierでもう1回パースされます。両ツールの役割分担についてはESLintとPrettierの記事で解説しています。@stylistic/indent。 各ファイルのトークンごとにインデントを計算するため、大規模なコードベースではその処理量が積み重なります。eslint-plugin-importのルール、特にimport/no-cycle。 これらのルールでは、プラグインがESLintとは別に独自にファイルをパースしてimportを追跡するため、コストはコードベースの規模に比例して増大します。このプラグインが検出できる問題についてはコード品質を向上させるESLintプラグインを参照してください。
遅いルールごとの対処法
遅いESLintルールのグループには、それぞれ固有の対処法があります。以下の推奨事項の多くは、typescript-eslintのパフォーマンスガイドに基づいています。
| 原因 | 遅い理由 | 対処法 |
|---|---|---|
| 型情報を利用するルール | 型チェッカーを呼び出す | filesでソースファイルのみに限定し、tsconfigのincludeを絞り込む |
eslint-plugin-prettier | 各ファイルを2回パースする | プラグインを削除し、prettier --check .を実行する |
@stylistic/indent | すべてのトークンを処理する | ルールを削除し、インデントはPrettierに任せる |
import/named、import/namespace、import/default、import/no-named-as-default-member、import/no-unresolved | TypeScriptのチェックと重複する | 無効にする(no-unresolvedは、コードがrequireではなくimportを使用している場合のみ) |
import/no-cycle、import/no-unused-modules、import/no-deprecated、import/no-named-as-default | TypeScriptに同等の機能がないファイル横断解析 | CIまたはpush時にのみ実行する |
tsconfigも確認してください。includeが指定されていない、あるいは**/*のようなglobが指定されているtsconfigでは、パーサーがビルド成果物も含め、Lint対象として意図した範囲をはるかに超える数のファイルを事前にパースしてしまいます。includeは実際にLintしたいフォルダに限定しましょう。
{ "compilerOptions": { /* ... */ }, "include": ["src"] }
プラグインの代わりに、Prettierの--checkをCIの独立したステップとして使用してください。ローカルで繰り返し実行する場合は、ESLintの--cacheフラグを使うと、前回の実行以降に変更されていないファイルをスキップできます。
例:変更前と変更後
以下の変更前の設定では、すべてのファイルに対してすべてのルールを実行しています。
// @ts-check
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
import stylistic from '@stylistic/eslint-plugin';
import prettierPlugin from 'eslint-plugin-prettier';
export default defineConfig({
files: ['**/*.{js,ts}'],
extends: [tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
plugins: { import: importPlugin, '@stylistic': stylistic, prettier: prettierPlugin },
rules: {
'prettier/prettier': 'error',
'@stylistic/indent': ['error', 2],
'import/named': 'error',
'import/no-unresolved': 'error',
'import/no-cycle': 'error',
},
});
変更後の設定では、型情報を利用するルールをsrcに限定し、フォーマット系プラグインとTypeScriptがすでにカバーしているimportルールを削除したうえで、import/no-cycleは環境変数が設定されている場合にのみ有効にしています。
// @ts-check
import { defineConfig } from 'eslint/config';
import tseslint from 'typescript-eslint';
import importPlugin from 'eslint-plugin-import';
const full = process.env.LINT_FULL === '1';
export default defineConfig(
{
files: ['src/**/*.ts'],
extends: [tseslint.configs.recommendedTypeChecked],
languageOptions: {
parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
},
},
{
files: ['src/**/*.ts'],
plugins: { import: importPlugin },
rules: {
...(full ? { 'import/no-cycle': 'error' } : {}),
},
},
);
この設定では、pre-commitフックでnpx eslint .を実行し、CIではLINT_FULL=1 npx eslint .とnpx prettier --check .を実行します。
以下の表は、この設定変更によってTIMING=1の一覧がどのように変化するかを示した例であり、実際の計測結果ではありません。
| ルール | 変更前 | 変更後 |
|---|---|---|
prettier/prettier | 上位付近 | 消える(prettier --checkに置き換え) |
@stylistic/indent | 上位付近 | 消える |
import/no-cycle | 上位付近 | LINT_FULL=1での実行時のみ表示 |
import/named、import/no-unresolved | 表示される | 消える |
| 最初の型情報利用ルール | 最も遅いと表示される | 引き続き最上位。tscの実行時間と比較する |
まとめ
何かを削除する前に、まず計測しましょう。tscでベースラインを取得したうえで、TIMING=1とConfig Inspectorのstatsパネルを実行し、疑わしい型情報利用ルールは1つずつ計測します。どのルールのコストが高いかを把握したら、ローカルおよびpre-commitでのLintは高速な単一ファイル向けルールに限定し、ファイル横断のチェックとフォーマットはCIに移しましょう。今すぐ自分のプロジェクトでTIMING=1 npx eslint .を実行し、上位に挙がったルールについて対処法の表に沿って対応してみてください。
よくある質問
ESLintのTIMING出力で10件を超えるルールを表示するには?
TIMINGにより大きな数値、またはallを設定します。TIMING=1では実行時間の長い上位10件のルールが、TIMING=50では50件が、TIMING=allではすべてのルールが表示されます。各行には、ルールの実行時間(ルールの生成と実行の合計、ミリ秒単位)と、ルール全体の実行時間に占める割合が表示されます。単一のルールを計測するには、--no-config-lookupと--ruleを組み合わせます。この方法は、パーサーの設定が不要なコアルールに適しています。
ESLintの--cacheは型情報を利用するルールと併用しても安全ですか?
完全に安全とは言えません。ESLintのキャッシュは、ファイルまたは設定が変更されたかどうかしか確認せず、そのファイルが依存している他のファイルの変更は考慮しません。そのため、型情報を利用するtypescript-eslintのルールや、importを解決するeslint-plugin-importのルールは、新たな問題を見逃したり、すでに修正済みの問題を報告し続けたりと、どちらの方向にも古い結果を報告する可能性があります。typescript-eslintのFAQでは--cacheは推奨されていないため、CIではキャッシュを使わずに実行してください。
ESLintでファイルごと・ルールごとの実行時間を取得するには?
ESLint本体の--statsフラグと、statsデータを出力するフォーマッターを組み合わせて実行します。例えばnpx eslint --stats -f jsonのように実行します。各結果オブジェクトにstatsキーが追加され、そのファイルのパース時間、修正時間、ルールごとのLint時間が含まれます。一方、TIMINGは実行全体の要約しか表示しません。すべてのフォーマッターがstatsデータを出力するわけではありません。最初の型情報利用ルールのキャッシュに関する注意点は--statsにも当てはまります。
parserOptions.projectServiceを使うと、parserOptions.projectより型付きLintが速くなりますか?
確実に速くなるとは言えません。typescript-eslintはv8.0.0から利用可能なProject Serviceによって高速化されると見込んでおり、期待した速度向上が得られていない点はバグとして扱っているとしています。主な利点は設定の簡素化で、再帰的な**パターンによって余分なディスクIOが発生するparserOptions.projectのglobが不要になります。allowDefaultProjectは小さく保ってください。一致するファイルが1つ増えるごとにLintは目に見えて遅くなり、またデフォルトでは一致できるファイルは最大8つまでです。