При работе над крупными JavaScript-проектами значительная часть времени сборки тратится на повторную обработку файлов, которые фактически не изменялись между запусками. Даже высокопроизводительные инструменты сталкиваются с затратами на чтение исходников, трансформацию кода, разрешение зависимостей и генерацию выходных артефактов.
Кэширование на уровне файловой системы представляет собой механизм сохранения промежуточных результатов сборки на диске с целью их повторного использования при последующих запусках. Вместо повторного выполнения дорогостоящих операций система анализирует изменения файлов и использует ранее сохранённые результаты там, где это возможно.
Для современных сборщиков данный подход особенно важен при:
Esbuild изначально проектировался как чрезвычайно быстрый инструмент и достигает высокой производительности за счёт собственной архитектуры, написанной на языке Go. Однако понимание принципов файлового кэширования остаётся важным при проектировании инфраструктуры сборки вокруг 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 сохраняет внутренние структуры в памяти процесса:
При изменении одного файла выполняется частичная пересборка только затронутых участков графа зависимостей.
С точки зрения производительности это часто превосходит файловое кэширование, поскольку исключает обращения к диску.
При реализации собственного файлового кэша необходимо понимать, какие данные действительно выгодно сохранять.
Чаще всего кэшируются:
Исходный файл:
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}
может быть сохранён в кэше и повторно использован.
Генерация карт исходников также занимает ресурсы.
{
"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);
}
При следующем запуске трансформация будет пропущена.
Система плагинов позволяет перехватывать загрузку файлов.
Пример:
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;
}
);
}
};
Подобный механизм может кэшировать:
Метод 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"
}
После смены версии каталог пересоздаётся.
Инвалидация — самая сложная часть любой системы кэширования.
Недостаточно учитывать только содержимое файла.
Необходимо учитывать:
Пример формирования ключа:
const key = hash(
source +
JSON.stringify(config) +
process.version
);
Изменение любого параметра приведёт к пересозданию результата.
В конвейерах непрерывной интеграции файловый кэш способен значительно сократить время сборки.
Типичная схема:
Restore Cache
↓
Install Dependencies
↓
Build
↓
Save Cache
Кэшируются:
node_modules;.cache;При повторном запуске значительная часть работы пропускается.
В монорепозиториях количество файлов может исчисляться десятками тысяч.
Структура:
packages/
├── ui/
├── core/
├── api/
├── cli/
└── shared/
Изменение одного пакета не должно приводить к полной пересборке остальных.
Для этого используются:
Пример:
.cache/
├── ui/
├── core/
├── api/
└── shared/
Esbuild часто используется совместно с инструментами более высокого уровня.
Наиболее распространённые варианты:
Такие системы способны:
В подобных сценариях Esbuild выполняет собственную работу по трансформации и упаковке файлов, а задачи долговременного хранения результатов делегируются внешней инфраструктуре.
Инкрементальные сборки следует использовать в первую очередь. Для локальной разработки они обычно дают максимальный прирост производительности.
Хэш содержимого надёжнее временных меток. Проверка через SHA-256 практически исключает ложные совпадения.
Конфигурация должна участвовать в формировании ключа кэша. Иначе возможно получение устаревших результатов после изменения параметров сборки.
Размер кэша необходимо контролировать. Без механизма очистки каталоги кэша быстро разрастаются.
Кэширование эффективно только для дорогих операций. Если преобразование занимает несколько миллисекунд, накладные расходы на чтение и запись файлов могут оказаться выше потенциальной выгоды.
В монорепозиториях предпочтительно разделять кэш по пакетам. Это упрощает инвалидацию и снижает объём пересчитываемых данных.
Для CI/CD полезно сохранять каталоги кэша между запусками. Особенно заметный эффект наблюдается при больших объёмах TypeScript-кода и сложных цепочках обработки файлов.