Кэширование на уровне файловой системы

При работе над крупными JavaScript-проектами значительная часть времени сборки тратится на повторную обработку файлов, которые фактически не изменялись между запусками. Даже высокопроизводительные инструменты сталкиваются с затратами на чтение исходников, трансформацию кода, разрешение зависимостей и генерацию выходных артефактов.

Кэширование на уровне файловой системы представляет собой механизм сохранения промежуточных результатов сборки на диске с целью их повторного использования при последующих запусках. Вместо повторного выполнения дорогостоящих операций система анализирует изменения файлов и использует ранее сохранённые результаты там, где это возможно.

Для современных сборщиков данный подход особенно важен при:

  • больших кодовых базах;
  • монорепозиториях;
  • CI/CD-конвейерах;
  • инкрементальных сборках;
  • локальной разработке с частыми пересборками.

Esbuild изначально проектировался как чрезвычайно быстрый инструмент и достигает высокой производительности за счёт собственной архитектуры, написанной на языке Go. Однако понимание принципов файлового кэширования остаётся важным при проектировании инфраструктуры сборки вокруг Esbuild.


Особенности подхода Esbuild

В отличие от некоторых других сборщиков, Esbuild не предоставляет полноценный дисковый кэш результатов сборки «из коробки» по аналогии с webpack Filesystem Cache или Turborepo Cache.

Основная ставка сделана на:

  • чрезвычайно быстрое чтение файлов;
  • эффективный парсер;
  • высокопроизводительный механизм разрешения зависимостей;
  • инкрементальные сборки в памяти;
  • режим наблюдения за изменениями файлов.

Поэтому в большинстве сценариев повторная обработка файлов оказывается настолько дешёвой, что необходимость в постоянном файловом кэше существенно снижается.

Тем не менее файловое кэширование может быть реализовано:

  • через собственные плагины;
  • через внешние инструменты;
  • через системы удалённого и локального кэша;
  • через сохранение артефактов сборки.

Инкрементальная сборка как альтернатива файловому кэшу

Наиболее близким механизмом кэширования в Esbuild является инкрементальная сборка.

Пример:

import * as esbuild from 'esbuild';

const ctx = await esbuild.context({
    entryPoints: ['src/index.js'],
    bundle: true,
    outfile: 'dist/app.js'
});

await ctx.watch();

После первоначальной сборки Esbuild сохраняет внутренние структуры в памяти процесса:

  • разобранные AST;
  • информацию о зависимостях;
  • метаданные модулей;
  • результаты разрешения импортов.

При изменении одного файла выполняется частичная пересборка только затронутых участков графа зависимостей.

С точки зрения производительности это часто превосходит файловое кэширование, поскольку исключает обращения к диску.


Какие данные обычно кэшируются

При реализации собственного файлового кэша необходимо понимать, какие данные действительно выгодно сохранять.

Чаще всего кэшируются:

Результаты трансформации

Исходный файл:

const value = user?.profile?.name;

После трансформации:

var _a;
const value =
  (_a = user == null ? void 0 : user.profile) == null
    ? void 0
    : _a.name;

Если содержимое файла не изменилось, результат можно извлекать из кэша.


Минифицированный код

Минификация является относительно затратной операцией.

Результат:

function a(o){return o*2}

может быть сохранён в кэше и повторно использован.


Sourcemaps

Генерация карт исходников также занимает ресурсы.

{
  "version":3,
  "sources":["index.ts"],
  ...
}

Повторное создание таких структур для неизменённых файлов не требуется.


Метаданные зависимостей

Например:

{
  "imports": [
    "./utils.js",
    "./config.js"
  ]
}

Подобная информация позволяет быстрее восстанавливать граф модулей.


Идентификация изменений файлов

Ключевая задача файлового кэширования — определение того, изменился файл или нет.

Наиболее распространённые методы:

Проверка времени модификации

Используется значение mtime.

const stat = await fs.promises.stat(file);

console.log(stat.mtimeMs);

Преимущества:

  • высокая скорость;
  • минимальные накладные расходы.

Недостатки:

  • возможны ложные срабатывания;
  • перенос файлов между системами может изменять временные метки.

Проверка размера файла

Дополнительная валидация:

const stat = await fs.promises.stat(file);

console.log(stat.size);

Размер часто комбинируется с mtime.


Хэш содержимого

Наиболее надёжный вариант.

import crypto from 'crypto';

function hash(content) {
    return crypto
        .createHash('sha256')
        .update(content)
        .digest('hex');
}

Пример результата:

c3ab8ff13720e8ad9047dd39466b3c89...

Если хэш совпадает, содержимое гарантированно идентично.


Структура файлового кэша

Типичная организация каталога:

.cache/
├── manifest.json
├── transforms/
├── bundles/
└── maps/

Файл манифеста:

{
  "src/index.ts": {
    "hash": "9d1e3f...",
    "cacheFile": "transforms/9d1e3f.cache"
  }
}

Такая схема обеспечивает быстрый поиск сохранённых результатов.


Использование хэшей как имён файлов

Наиболее популярный подход выглядит следующим образом:

const key = hash(sourceCode);

const cachePath =
    `.cache/${key}.json`;

Пример:

.cache/
├── 3f4c8f2a.json
├── 7ab1f93c.json
└── a4e2c781.json

Преимущества:

  • отсутствие коллизий имён;
  • простота поиска;
  • удобство очистки старых данных.

Реализация простого файлового кэша

Создадим примитивную систему кэширования результатов трансформации.

Получение ключа

import fs from 'fs';
import crypto from 'crypto';

function createKey(content) {
    return crypto
        .createHash('sha256')
        .update(content)
        .digest('hex');
}

Чтение из кэша

function readCache(key) {
    const file =
        `.cache/${key}.json`;

    if (!fs.existsSync(file)) {
        return null;
    }

    return JSON.parse(
        fs.readFileSync(file, 'utf8')
    );
}

Запись в кэш

function writeCache(key, data) {
    fs.mkdirSync('.cache', {
        recursive: true
    });

    fs.writeFileSync(
        `.cache/${key}.json`,
        JSON.stringify(data)
    );
}

Использование

const source =
    fs.readFileSync(file, 'utf8');

const key =
    createKey(source);

let result =
    readCache(key);

if (!result) {
    result = await esbuild.transform(
        source,
        {
            minify: true
        }
    );

    writeCache(key, result);
}

При следующем запуске трансформация будет пропущена.


Кэширование через плагины Esbuild

Система плагинов позволяет перехватывать загрузку файлов.

Пример:

const cachePlugin = {
    name: 'cache',

    setup(build) {
        build.onLoad(
            { filter: /\.js$/ },
            async (args) => {

                const source =
                    await fs.promises.readFile(
                        args.path,
                        'utf8'
                    );

                const key =
                    createKey(source);

                const cached =
                    readCache(key);

                if (cached) {
                    return cached;
                }

                const result = {
                    contents: source,
                    loader: 'js'
                };

                writeCache(
                    key,
                    result
                );

                return result;
            }
        );
    }
};

Подобный механизм может кэшировать:

  • результаты компиляции;
  • загрузку удалённых ресурсов;
  • преобразование Markdown;
  • генерацию CSS;
  • обработку изображений.

Кэширование результатов API transform()

Метод transform() особенно хорошо подходит для файлового кэша.

Исходный код:

const result =
    await esbuild.transform(
        source,
        {
            loader: 'ts',
            minify: true
        }
    );

Результат содержит:

{
  code: "...",
  map: "..."
}

Оба значения можно сохранять на диск.

writeCache(key, {
    code: result.code,
    map: result.map
});

Кэширование промежуточных артефактов

В крупных системах сборка часто разбивается на этапы:

TypeScript
      ↓
 JavaScript
      ↓
 Babel
      ↓
 Minify
      ↓
 Bundle

После каждого этапа можно сохранять результат.

Например:

.cache/
├── ts/
├── babel/
├── minify/
└── bundle/

Если изменился только один исходный файл, большая часть цепочки остаётся валидной.


Стратегии очистки кэша

Без контроля размер кэша будет постоянно расти.

Распространённые подходы:

Очистка по возрасту

Удаление записей старше определённого срока.

if (
    Date.now() - stat.mtimeMs >
    7 * 24 * 60 * 60 * 1000
) {
    fs.unlinkSync(file);
}

Ограничение объёма

Например:

Максимальный размер:
5 ГБ

При превышении лимита старые записи удаляются.


Очистка по версии сборки

Изменение конфигурации должно инвалидировать старый кэш.

{
  "cacheVersion": "2.0"
}

После смены версии каталог пересоздаётся.


Инвалидация кэша

Инвалидация — самая сложная часть любой системы кэширования.

Недостаточно учитывать только содержимое файла.

Необходимо учитывать:

  • версию Esbuild;
  • версию плагинов;
  • конфигурацию сборки;
  • параметры минификации;
  • целевые браузеры;
  • переменные окружения.

Пример формирования ключа:

const key = hash(
    source +
    JSON.stringify(config) +
    process.version
);

Изменение любого параметра приведёт к пересозданию результата.


Кэширование в CI/CD

В конвейерах непрерывной интеграции файловый кэш способен значительно сократить время сборки.

Типичная схема:

Restore Cache
      ↓
Install Dependencies
      ↓
Build
      ↓
Save Cache

Кэшируются:

  • node_modules;
  • результаты трансформации;
  • каталоги .cache;
  • промежуточные артефакты.

При повторном запуске значительная часть работы пропускается.


Кэширование в монорепозиториях

В монорепозиториях количество файлов может исчисляться десятками тысяч.

Структура:

packages/
├── ui/
├── core/
├── api/
├── cli/
└── shared/

Изменение одного пакета не должно приводить к полной пересборке остальных.

Для этого используются:

  • хэши пакетов;
  • граф зависимостей;
  • отдельные кэши для каждого пакета.

Пример:

.cache/
├── ui/
├── core/
├── api/
└── shared/

Взаимодействие с внешними системами кэширования

Esbuild часто используется совместно с инструментами более высокого уровня.

Наиболее распространённые варианты:

  • Nx;
  • Turborepo;
  • Bazel;
  • Moonrepo.

Такие системы способны:

  • сохранять результаты на диск;
  • передавать кэш между разработчиками;
  • использовать удалённые хранилища;
  • избегать повторных сборок на разных машинах.

В подобных сценариях Esbuild выполняет собственную работу по трансформации и упаковке файлов, а задачи долговременного хранения результатов делегируются внешней инфраструктуре.


Практические рекомендации

Инкрементальные сборки следует использовать в первую очередь. Для локальной разработки они обычно дают максимальный прирост производительности.

Хэш содержимого надёжнее временных меток. Проверка через SHA-256 практически исключает ложные совпадения.

Конфигурация должна участвовать в формировании ключа кэша. Иначе возможно получение устаревших результатов после изменения параметров сборки.

Размер кэша необходимо контролировать. Без механизма очистки каталоги кэша быстро разрастаются.

Кэширование эффективно только для дорогих операций. Если преобразование занимает несколько миллисекунд, накладные расходы на чтение и запись файлов могут оказаться выше потенциальной выгоды.

В монорепозиториях предпочтительно разделять кэш по пакетам. Это упрощает инвалидацию и снижает объём пересчитываемых данных.

Для CI/CD полезно сохранять каталоги кэша между запусками. Особенно заметный эффект наблюдается при больших объёмах TypeScript-кода и сложных цепочках обработки файлов.