Плагин виртуальных модулей в esbuild строится вокруг идеи подмены файловой системы логическими сущностями, которые существуют только в памяти сборщика. Виртуальный модуль не имеет физического файла на диске, но ведёт себя как обычный импортируемый модуль, проходящий этапы резолва и загрузки. Такая техника используется для генерации кода на лету, инъекции конфигурации, создания адаптеров окружения и построения абстракций поверх статических импортов.
Виртуальный модуль определяется как идентификатор, который:
Ключевая особенность заключается в том, что сборщик не различает
физические и виртуальные ресурсы после завершения стадии
onResolve. Это позволяет встроить произвольную логику
генерации кода в процесс бандлинга.
Типичный сценарий применения:
Плагин в esbuild представляет собой объект с функцией
setup, получающей API сборщика. Основные точки
расширения:
onResolve — перехват импорта и переопределение
путиonLoad — загрузка содержимого модуля по виртуальному
путиnamespace — изоляция виртуальных сущностей от реальной
файловой системыСвязка этих механизмов формирует основу виртуальных модулей:
onResolveonLoad возвращает исходный кодНаиболее распространённая схема использует префикс:
virtual:configvirtual:envvirtual:apiexport const virtualPlugin = {
name: 'virtual-modules',
setup(build) {
build.onResolve({ filter: /^virtual:/ }, args => {
return {
path: args.path,
namespace: 'virtual'
};
});
build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => {
if (args.path === 'virtual:config') {
return {
contents: `
export const config = {
mode: "production",
version: "1.0.0"
};
`,
loader: 'js'
};
}
if (args.path === 'virtual:env') {
return {
contents: `
export const env = process.env.NODE_ENV;
`,
loader: 'js'
};
}
return null;
});
}
};
В этом случае любой импорт:
import { config } from "virtual:config";
будет преобразован в модуль, созданный во время сборки.
Использование namespace критично для корректной работы
виртуальных модулей. Оно позволяет:
onLoadПример изоляции:
build.onResolve({ filter: /^vmod:/ }, args => {
return {
path: args.path,
namespace: 'vmod-space'
};
});
build.onLoad({ filter: /.*/, namespace: 'vmod-space' }, args => {
return {
contents: `export const id = "${args.path}"`,
loader: 'js'
};
});
Такой подход предотвращает случайное пересечение с реальными путями проекта.
Виртуальные модули часто параметризуются через строку импорта:
import { api } from "virtual:api/user";
Плагин может извлекать сегменты пути:
build.onResolve({ filter: /^virtual:api\// }, args => {
return {
path: args.path,
namespace: 'api'
};
});
build.onLoad({ filter: /.*/, namespace: 'api' }, args => {
const entity = args.path.split('/')[1];
const code = `
export const endpoint = "/api/${entity}";
export async function fetchData() {
const res = await fetch(endpoint);
return res.json();
}
`;
return {
contents: code,
loader: 'js'
};
});
Это превращает импорт в декларативное описание API.
При генерации сложных модулей возникает необходимость кеширования результатов, чтобы избежать повторной генерации.
Подход основан на Map:
const cache = new Map();
build.onLoad({ filter: /.*/, namespace: 'virtual' }, args => {
if (cache.has(args.path)) {
return cache.get(args.path);
}
const result = {
contents: `export const value = "${args.path}"`,
loader: 'js'
};
cache.set(args.path, result);
return result;
});
Кеширование особенно важно при:
Виртуальные модули требуют согласования с типизацией. Обычно используется декларационный файл:
declare module "virtual:config" {
export const config: {
mode: string;
version: string;
};
}
Это позволяет сохранить статическую проверку при отсутствии физического файла.
Виртуальные модули часто выступают мостом между сборкой и внешними источниками:
Пример генерации из JSON:
import fs from "fs";
build.onResolve({ filter: /^virtual:json\// }, args => {
return {
path: args.path,
namespace: 'json'
};
});
build.onLoad({ filter: /.*/, namespace: 'json' }, args => {
const file = args.path.replace("virtual:json/", "");
const data = JSON.parse(fs.readFileSync(file, "utf8"));
return {
contents: `export default ${JSON.stringify(data)};`,
loader: 'js'
};
});
Виртуальный модуль может сам порождать зависимости. Для этого
используются onResolve внутри цепочки:
Пример каскадной генерации:
build.onResolve({ filter: /^gen:/ }, args => {
return {
path: args.path,
namespace: 'gen'
};
});
build.onLoad({ filter: /.*/, namespace: 'gen' }, args => {
return {
contents: `
import { helper } from "gen:helper";
export const value = helper() + 1;
`,
loader: 'js'
};
});
Несмотря на гибкость, существует ряд ограничений:
Особенно критично поведение при инкрементальной сборке: изменение логики плагина может требовать полного пересчёта графа зависимостей.
Генерация виртуального модуля должна оставаться детерминированной. Любые побочные эффекты приводят к нестабильности сборки. В рамках плагинов esbuild важно:
Корректная модель предполагает, что один и тот же входной
args.path всегда возвращает идентичный
contents.
На практике виртуальные модули часто становятся промежуточным DSL-слоем:
Такая модель позволяет переносить вычисления из выполнения программы в этап сборки, уменьшая runtime-стоимость и упрощая архитектуру приложения.