В экосистеме сборщиков модулей JavaScript работа со статическими файлами — изображениями, шрифтами, favicon, JSON-данными, HTML-шаблонами и прочими ресурсами — не всегда входит в базовый функционал. В Esbuild основная задача сосредоточена на обработке JavaScript/TypeScript и связанных модулей, тогда как копирование неизменяемых ресурсов реализуется через плагины или внешние шаги сборки.
Статические файлы обычно располагаются в отдельной директории
(public, static, assets) и должны
попадать в итоговую папку сборки без трансформации. Важный момент:
Esbuild не предоставляет встроенной команды “copy assets”, поэтому
архитектура решения строится вокруг плагинов и использования Node.js
API.
Типичная структура проекта:
project/
src/
index.ts
public/
favicon.ico
robots.txt
images/
logo.png
dist/
Папка public содержит файлы, которые должны быть
перенесены в dist без изменений.
Esbuild при этом отвечает только за src, а копирование
выполняется отдельным шагом или расширением через plugin API.
Наиболее прямолинейный способ — использование стандартного
fs API.
import fs from "fs";
import path from "path";
function copyDir(src, dest) {
fs.mkdirSync(dest, { recursive: true });
const entries = fs.readdirSync(src, { withFileTypes: true });
for (const entry of entries) {
const srcPath = path.join(src, entry.name);
const destPath = path.join(dest, entry.name);
if (entry.isDirectory()) {
copyDir(srcPath, destPath);
} else {
fs.copyFileSync(srcPath, destPath);
}
}
}
copyDir("public", "dist");
Этот подход прост, но плохо интегрируется с режимом watch, инкрементальными сборками и плагинами Esbuild.
Esbuild предоставляет механизм плагинов, позволяющий подключаться к
этапам сборки. Для копирования статических файлов чаще всего
используется хук onEnd.
import esbuild from "esbuild";
import fs from "fs";
import path from "path";
const copyPlugin = (options = {}) => {
const { src = "public", dest = "dist" } = options;
return {
name: "copy-static",
setup(build) {
build.onEnd(() => {
fs.mkdirSync(dest, { recursive: true });
const copy = (from, to) => {
const entries = fs.readdirSync(from, { withFileTypes: true });
for (const entry of entries) {
const fromPath = path.join(from, entry.name);
const toPath = path.join(to, entry.name);
if (entry.isDirectory()) {
fs.mkdirSync(toPath, { recursive: true });
copy(fromPath, toPath);
} else {
fs.copyFileSync(fromPath, toPath);
}
}
};
copy(src, dest);
});
}
};
};
esbuild.build({
entryPoints: ["src/index.ts"],
bundle: true,
outdir: "dist",
plugins: [copyPlugin()]
});
Такой подход привязывает копирование к завершению каждой сборки, включая watch-режим.
При использовании watch: true Esbuild пересобирает
проект при изменениях исходников. Однако onEnd срабатывает
после каждого билда, что приводит к повторному копированию всей
директории.
Это поведение может создавать избыточную нагрузку при больших наборах файлов. Для оптимизации вводятся следующие стратегии:
mtimeMs);onEnd.Пример с простым debounce:
let timer;
build.onEnd(() => {
clearTimeout(timer);
timer = setTimeout(() => {
copy("public", "dist");
}, 50);
});
Более эффективная стратегия — отслеживание изменений и копирование только обновлённых ресурсов.
const cache = new Map();
function shouldCopy(filePath, stats) {
const prev = cache.get(filePath);
if (!prev || prev !== stats.mtimeMs) {
cache.set(filePath, stats.mtimeMs);
return true;
}
return false;
}
Использование внутри обхода директории:
const stats = fs.statSync(filePath);
if (shouldCopy(filePath, stats)) {
fs.copyFileSync(filePath, destPath);
}
Esbuild поддерживает настройку outdir и
outbase, что влияет на структуру выходных файлов. При
копировании статики важно соблюдать согласованность структуры:
public/images/logo.pngdist/images/logo.pngЧтобы избежать дублирования логики путей, используется
path.relative:
const relative = path.relative(src, filePath);
const destPath = path.join(dest, relative);
Статические файлы часто включают HTML-файлы, которые не обрабатываются Esbuild по умолчанию. В таких случаях применяются два подхода:
Простейшее копирование:
if (filePath.endsWith(".html")) {
fs.copyFileSync(filePath, destPath);
}
При необходимости интеграции с бандлом добавляется пост-обработка:
<script src="...">В экосистеме существуют готовые решения, реализующие копирование статических файлов:
Типичная конфигурация:
import { copy } from "esbuild-plugin-copy";
esbuild.build({
entryPoints: ["src/index.ts"],
bundle: true,
outdir: "dist",
plugins: [
copy({
assets: {
from: ["public/**/*"],
to: ["dist"]
}
})
]
});
Плагины часто добавляют дополнительные возможности:
ignore);При сборке SPA или SSR-проектов часто требуется сопоставление оригинальных файлов и их итоговых путей.
Пример структуры:
{
"logo.png": "/assets/logo-8d91f2.png",
"main.css": "/assets/main-a31c0c.css"
}
Хотя Esbuild не генерирует такой манифест автоматически для статических файлов, он может быть создан в plugin API:
const manifest = {};
build.onEnd(() => {
fs.writeFileSync(
"dist/manifest.json",
JSON.stringify(manifest, null, 2)
);
});
При сложной структуре проекта возникают конфликты путей:
public/
assets/
assets/
logo.png
Для предотвращения дублирования используются нормализаторы путей:
const normalize = (p) => p.replace(/\\/g, "/");
Также важно учитывать поведение path.join, которое
зависит от платформы.
Esbuild поддерживает загрузку файлов через loader:
import logo from "./logo.png";
При этом файл не копируется автоматически как статический ресурс вне
графа зависимостей. Он попадает в outdir только если
импортирован.
Различие моделей:
loader — ассет как часть графа модулей;Эти подходы часто комбинируются:
loader;Типичная цепочка:
distПри этом копирование через onEnd может выполняться
несколько раз в зависимости от режима.
Модель копирования статических файлов в Esbuild имеет ряд ограничений:
Эти ограничения компенсируются гибкостью plugin API и возможностью интеграции с Node.js экосистемой.
При увеличении количества ассетов применяются следующие техники:
public на подкаталоги;Promise.all;fs.createReadStream);chokidar).Пример параллельного копирования:
await Promise.all(files.map(file => {
return fs.promises.copyFile(file.src, file.dest);
}));
В монорепозиториях статические ресурсы часто распределены между пакетами. В этом случае копирование выполняется:
Важно избегать конфликтов путей и дублирования ассетов между пакетами.
В продакшн-сборках часто требуется подмена путей:
/assets/logo.png → https://cdn.example.com/logo.png
Реализация обычно выполняется на этапе пост-обработки:
const html = fs.readFileSync("dist/index.html", "utf-8");
const replaced = html.replace(
/\/assets\//g,
"https://cdn.example.com/"
);
fs.writeFileSync("dist/index.html", replaced);
При копировании могут возникать ошибки:
Корректная обработка строится на try/catch:
try {
fs.copyFileSync(src, dest);
} catch (e) {
console.error("Copy error:", src, e);
}
Плагины Esbuild могут быть реализованы в обеих системах модулей. Однако при работе с файловой системой и путями предпочтение обычно отдаётся ESM:
import fs from "fs";
import path from "path";
Существует три устойчивых модели организации статических файлов в проектах с Esbuild:
public с копированием
целиком;Каждая модель выбирается в зависимости от сложности проекта, количества ассетов и требований к деплою.