Плагин с кэшированием результатов

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

В процессе сборки esbuild выполняет несколько ключевых этапов:

  • разрешение путей модулей (onResolve)
  • загрузка содержимого файлов (onLoad)
  • трансформация исходного кода (transform)
  • связывание и генерация бандла

Наиболее дорогостоящими являются операции чтения файлов и трансформации AST-подобных структур (даже несмотря на то, что esbuild не использует полноценное дерево AST в классическом смысле). Когда плагин добавляет пользовательскую логику (например, парсинг, шаблонизацию, обращение к API), стоимость возрастает многократно.

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


Базовая модель кэширования в плагине

Типовой плагин esbuild предоставляет хуки:

  • onResolve
  • onLoad
  • setup

На практике кэширование чаще всего внедряется в onLoad, так как именно там формируется финальное содержимое модуля.

Основная идея: сопоставить входной ключ (например, путь файла + параметры) с результатом обработки.

const cache = new Map();

export const cachingPlugin = {
  name: 'caching-plugin',
  setup(build) {
    build.onLoad({ filter: /\.txt$/ }, async (args) => {
      const key = args.path;

      if (cache.has(key)) {
        return cache.get(key);
      }

      const fs = await import('fs/promises');
      const contents = await fs.readFile(args.path, 'utf8');

      const result = {
        contents: `export default ${JSON.stringify(contents)}`,
        loader: 'js',
      };

      cache.set(key, result);
      return result;
    });
  },
};

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


Выбор ключа кэша

Корректность кэша определяется качеством ключа. Ошибки в выборе ключа приводят к возврату устаревших данных.

На практике ключ формируется из комбинации:

  • абсолютного пути файла
  • времени изменения (mtime)
  • содержимого файла (хеш)
  • параметров трансформации
  • окружения сборки

Пример более устойчивого ключа:

import { createHash } fr om 'crypto';
import fs fr om 'fs/promises';

async function createKey(path) {
  const content = await fs.readFile(path, 'utf8');
  const hash = createHash('sha256').update(content).digest('hex');
  return `${path}:${hash}`;
}

Такой подход гарантирует, что любое изменение содержимого invalidates кэш.


Кэширование трансформаций

Особенно эффективно кэширование при использовании onLoad совместно с кастомной трансформацией кода.

const transformCache = new Map();

build.onLoad({ filter: /\.md$/ }, async (args) => {
  const fs = await import('fs/promises');
  const raw = await fs.readFile(args.path, 'utf8');

  const key = args.path + ':' + raw.length;

  if (transformCache.has(key)) {
    return transformCache.get(key);
  }

  const html = markdownToHtml(raw);

  const result = {
    contents: `export default ${JSON.stringify(html)}`,
    loader: 'js',
  };

  transformCache.set(key, result);
  return result;
});

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


LRU-кэш для ограничения памяти

Неограниченный Map приводит к росту памяти при длительной работе в watch-режиме. Решением является LRU-стратегия (Least Recently Used).

class LRUCache {
  constructor(lim it = 200) {
    this.lim it = limit;
    this.cache = new Map();
  }

  get(key) {
    if (!this.cache.has(key)) return null;

    const value = this.cache.get(key);
    this.cache.delete(key);
    this.cache.set(key, value);
    return value;
  }

  set(key, value) {
    if (this.cache.has(key)) {
      this.cache.delete(key);
    } else if (this.cache.size >= this.limit) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }

    this.cache.set(key, value);
  }
}

Интеграция в плагин:

const cache = new LRUCache(100);

build.onLoad({ filter: /\.json$/ }, async (args) => {
  const cached = cache.get(args.path);
  if (cached) return cached;

  const fs = await import('fs/promises');
  const json = await fs.readFile(args.path, 'utf8');

  const result = {
    contents: `export default ${json}`,
    loader: 'js',
  };

  cache.set(args.path, result);
  return result;
});

Инвалидация кэша при изменениях зависимостей

Сложность возникает, когда результат зависит не только от файла, но и от внешних факторов:

  • импортируемых модулей
  • переменных окружения
  • конфигурации сборки

Пример проблемы: файл остаётся неизменным, но меняется NODE_ENV, что должно влиять на результат.

Решение — включение окружения в ключ:

const env = process.env.NODE_ENV || 'development';

const key = `${args.path}:${env}`;

Для более сложных случаев используется комбинированный хеш:

function buildCacheKey({ path, content, env, options }) {
  const hash = createHash('sha256');

  hash.update(path);
  hash.update(content);
  hash.update(env);
  hash.update(JSON.stringify(options));

  return hash.digest('hex');
}

Кэширование между сборками (persistent cache)

Память процесса ограничивает эффективность кэша в режиме полной перезагрузки процесса esbuild. Для этого применяется файловый кэш.

import fs from 'fs/promises';
import path from 'path';

const CACHE_DIR = '.cache-esbuild';

async function readCache(key) {
  try {
    const file = path.join(CACHE_DIR, key + '.json');
    const data = await fs.readFile(file, 'utf8');
    return JSON.parse(data);
  } catch {
    return null;
  }
}

async function writeCache(key, value) {
  const file = path.join(CACHE_DIR, key + '.json');
  await fs.mkdir(CACHE_DIR, { recursive: true });
  await fs.writeFile(file, JSON.stringify(value));
}

Интеграция:

build.onLoad({ filter: /\.txt$/ }, async (args) => {
  const fs = await import('fs/promises');
  const content = await fs.readFile(args.path, 'utf8');

  const key = args.path + ':' + content.length;

  const cached = await readCache(key);
  if (cached) return cached;

  const result = {
    contents: `export default ${JSON.stringify(content)}`,
    loader: 'js',
  };

  await writeCache(key, result);
  return result;
});

Этот подход снижает нагрузку при повторных CI-сборках.


Кэширование результатов resolve-операций

Не только onLoad, но и onResolve может выигрывать от кэширования, особенно при большом количестве импортов.

const resolveCache = new Map();

build.onResolve({ filter: /.*/ }, (args) => {
  const key = args.path + '|' + args.resolveDir;

  if (resolveCache.has(key)) {
    return resolveCache.get(key);
  }

  const result = {
    path: require.resolve(args.path, { paths: [args.resolveDir] }),
  };

  resolveCache.set(key, result);
  return result;
});

Проблемы конкурентного выполнения

esbuild может выполнять плагины параллельно. Это означает, что два одинаковых запроса могут одновременно пройти проверку cache.has(key) === false и оба начнут вычисление результата.

Решение — хранение Promise вместо результата:

const cache = new Map();

build.onLoad({ filter: /\.data$/ }, async (args) => {
  if (cache.has(args.path)) {
    return cache.get(args.path);
  }

  const promise = (async () => {
    const fs = await import('fs/promises');
    const data = await fs.readFile(args.path, 'utf8');

    return {
      contents: `export default ${JSON.stringify(data)}`,
      loader: 'js',
    };
  })();

  cache.set(args.path, promise);
  return promise;
});

Это предотвращает дублирование работы.


Кэширование и watch-режим

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

Типичная стратегия:

  • хранить кэш по файлам
  • очищать зависимости при изменении исходника
  • использовать граф зависимостей (dependency graph)

Простейший вариант инвалидации:

build.onLoad({ filter: /\.js$/ }, async (args) => {
  cache.delete(args.path);

  const fs = await import('fs/promises');
  const contents = await fs.readFile(args.path, 'utf8');

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

  cache.set(args.path, result);
  return result;
});

Комбинация уровней кэширования

На практике эффективная система использует несколько слоёв:

  • L1: in-memory Map / LRU
  • L2: файловый кэш
  • L3: внешние источники (например, CDN или генераторы)

Приоритет доступа:

  1. память
  2. диск
  3. вычисление

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