Кэширование в плагинах esbuild используется для сокращения времени повторных сборок за счёт переиспользования уже вычисленных результатов трансформации, резолва модулей и загрузки файлов. При больших проектах именно операции чтения и преобразования модулей становятся основным источником затрат, особенно в режиме watch или при инкрементальных пересборках.
В процессе сборки esbuild выполняет несколько ключевых этапов:
onResolve)onLoad)transform)Наиболее дорогостоящими являются операции чтения файлов и трансформации AST-подобных структур (даже несмотря на то, что esbuild не использует полноценное дерево AST в классическом смысле). Когда плагин добавляет пользовательскую логику (например, парсинг, шаблонизацию, обращение к API), стоимость возрастает многократно.
Кэширование в плагинах позволяет сократить повторные вычисления, особенно если входные данные не изменились между сборками.
Типовой плагин esbuild предоставляет хуки:
onResolveonLoadsetupНа практике кэширование чаще всего внедряется в 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;
});
Здесь используется упрощённый ключ, но в реальных системах предпочтительнее хеширование содержимого.
Неограниченный 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');
}
Память процесса ограничивает эффективность кэша в режиме полной перезагрузки процесса 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-сборках.
Не только 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 кэш становится особенно чувствительным к
инвалидации. Изменение одного файла может влиять на множество
зависимостей.
Типичная стратегия:
Простейший вариант инвалидации:
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;
});
На практике эффективная система использует несколько слоёв:
Приоритет доступа:
Такая архитектура минимизирует повторные операции трансформации и позволяет масштабировать плагины под крупные кодовые базы.