ESLintPrettierなどは--cacheフラグという一度チェックしたファイルは、ファイルが変更されるまで再チェックしないキャッシュの仕組みを実装しています。

同様の仕組みをtextlintを実装したことはありますが、file-entry-cacheを使い結構煩雑な実装が必要になります。ファイル変更を元に処理結果をキャッシュする仕組みはある程度定型化されているのに、毎回同じような実装をツールごとに書くのは微妙だなと思ったので、ライブラリを書きました。

--cacheフラグの仕組み

--cacheフラグの仕組みとしては、ファイルの内容のハッシュ値 or ファイルの更新時刻をキャッシュファイルとして保存して置きます。 次回に実行する際には、キャッシュファイルと実際のファイルの情報を比較して、変更がなければ処理をスキップするという仕組みで、処理時間の短縮を目的にしています。 (エラー結果をキャッシュするかはツールによります。しないツールの方が多い気はしています)

たとえば、A.jsに対してLintする場合には、A.jsにLintの処理をしてパスした場合はキャッシュに保存しておきます。 次回、LintしたときにA.jsの内容が変更されていない場合は、あらためてチェックはせずにそのままスキップします。

これに加えて、強制的にキャッシュをクリアしたいパターンもあります。 たとえば、ツール自体のバージョンを更新した場合は処理内容が変わる可能性があるので、キャッシュを利用せずに常に再チェックするのが正しいです。 また、ツールの設定内容が変更された場合も処理結果が変わる可能性があるので、キャッシュを利用せずに常に再チェックするのが正しいです。

@file-cacheでの実装

@file-cacheは、@file-cache/coreパッケージとキャッシュのkeyを作るパッケージからなっています。 多くのツールでは、パッケージのバージョンでキャッシュをクリアしたいので@file-cache/npmというパッケージを合わせてインストールします。

npm install @file-cache/core @file-cache/npm

@file-cacheでは、次のようなイメージでキャッシュの処理を実装できます。

import { createCache } from "@file-cache/core";
import { createNpmPackageKey } from "@file-cache/npm"

const config = {/* ... */ };
const cache = await createCache({
    // Use hash value of the content for detecting changes 
    mode: "content", // or "metadata"
    // create key for cache
    keys: [
        // use dependency(version) as cache key
        () => createNpmPackageKey(["your-package-name"]),
        // use custom key
        () => {
            return JSON.stringify(config);
        }
    ],
    noCache: process.env.NO_CACHE === "true" // disable cache by the flag
});

const targetFiles = ["a.js", "b.js", "c.js"];
const doHeavyTask = (filePath) => {
    // do heavy task
}
for (const targetFile of targetFiles) {
    const result = await cache.getAndUpdateCache(targetFile);
    if (result.error) {
        throw result.error
    }
    if (!result.changed) {
        continue; // no need to update
    }
    doHeavyTask(targetFile);
}
// write cache state to file for persistence
await cache.reconcile();

このサンプルコードでは、次の場合にdoHeavyTaskの処理をファイルに対して行います。

  • ファイルのコンテンツの中身が変更された場合
  • your-package-nameパッケージのversionが変更されたとき
  • configの中身が変更されたとき
  • NO_CACHE=true で実行されたとき

オプションはそれぞれ次のような意味合いです。

  • mode: “content” or “metadata”
    • “content” はファイルのハッシュ値を使う
    • “metadata” はファイルの変更時間を使う
      • “content”のようにファイルを読み込む必要がないため、早い
      • CIなどではgit cloneすると時間が更新されるため、キャッシュは利用できない
  • keys:
    • キャッシュの複合キーを設定します
    • @file-cache/npmは指定したパッケージのバージョンをキーとして扱う関数を提供しています
  • noCache:
    • trueの場合は、結果はキャッシュせずに、常にchanged: trueを返します
    • --cacheフラグがあるときだけキャッシュするような実装に便利です

file-entry-cacheもそうですが、キャッシュファイルに実際に書き込みが行われるのはreconcile()を呼び出したタイミングです。 これは、書き込まれるまではメモリ上で管理していて、I/Oへのアクセスを減らしてパフォーマンスを安定させるためです。

デフォルトでは、キャッシュファイルはnode_modules/.cache/の下に保存されます。 cacheDirectoryオプションで保存先を変更できます。

|- node_modules
  |- .cache
    |- <pkg-name>
      |- <hash-of-cache-key>-<mode>

create-validator-tsではv4.0.0で、この@file-cacheを使った--cacheフラグを実装しています。

create-validator-tsではデフォルトはキャッシュしないのでnoCacheオプションを使い、--cacheフラグがついた時だけキャッシュを使うようにしています。

@file-cacheのmonorepo

@file-cacheはmonorepoになっていて、moonPackemonを使ってパッケージを公開しています。 次のスライドで、moonPackemonについて紹介しています。

@file-cache自体はtype: moduleのESMとして作っていますが、Packemonを使ってCJS/ESM両方に対応したdual packageとして公開しています。 そのため、CJSとESMどちらのツールからも利用できるようにしています。

また、CJSとESM両方に対応したパッケージとして公開していますが、内部的にはPure ESMの外部パッケージを読み込んでいます。 CJSからもimport()を使うことでPure ESMのパッケージであるpkg-dirを読み込むことができます。

CJS/ESMのdual packagesは色々な問題がありますが、なんとか動くものができた感じがします。

TypeScript単独だと.cjs.mjsの出し分けは現実的に難しいです。 なぜなら、TypeScriptはimport a from "./a.js"するときに.js suffixのパスを要求しますが、そのパスを書き換えずに出力するため、.cjs.mtsの出しわけがtscのみだとできません。 (根本的にNode.jsのtype/exportsの仕組みとTypeScript Design Goalsが噛み合ってない気がします)

.cjs.mjsを出しわけしない方法として、どちらかをメインにしてラッパーを作るアプローチもあります。

どちらにしても何かしらのtranspilerやbundlerがないと、CJS/MJSに対応したdual packagesを作るのは難しいかなというのが感想です。 Packemonは、その中でもメンテコストが少ないかなーという印象です。 (というよりもexportsの仕様が複雑すぎて、exportsを解釈するツールのバグがあったりと、本当に難しい)