Механизм разрешения модулей (Module Resolution) является одним из ключевых компонентов сборщика Parcel. Каждый раз, когда в исходном коде встречается импорт:
import React from "react";
import logo from "./images/logo.svg";
import styles from "./styles/main.css";
Parcel должен определить:
За эти операции отвечает система Resolver-плагинов.
Resolver-плагин позволяет изменить стандартную логику поиска модулей и ресурсов. С его помощью можно:
Стандартный Resolver Parcel поддерживает большинство сценариев:
import moduleA from "./moduleA";
import React from "react";
Однако в крупных проектах возникают более сложные требования.
Например, импорт может выглядеть следующим образом:
import config from "company-config:frontend";
или
import data from "cms://articles/main";
Подобные схемы Parcel по умолчанию не понимает.
Resolver способен перехватывать такие запросы и самостоятельно определять, какой ресурс должен быть подключён.
Все Resolver-плагины строятся на основе пакета:
@parcel/plugin
Базовая структура выглядит так:
const {Resolver} = require("@parcel/plugin");
module.exports = new Resolver({
async resolve({specifier}) {
return {
filePath: specifier
};
}
});
Объект Resolver принимает набор методов жизненного цикла.
Главным методом является:
resolve()
Именно он вызывается каждый раз при обработке импорта.
Типичная структура проекта:
parcel-resolver-example/
├── package.json
├── src/
│ └── Resolver.js
└── index.js
Файл экспорта:
module.exports = require("./src/Resolver");
Установка зависимостей:
npm install @parcel/plugin
Простейший Resolver возвращает путь к файлу напрямую.
const {Resolver} = require("@parcel/plugin");
const path = require("path");
module.exports = new Resolver({
async resolve({specifier}) {
return {
filePath: path.resolve(specifier)
};
}
});
Когда Parcel встречает:
import data from "./data.json";
вызывается метод:
resolve()
После чего возвращается абсолютный путь к файлу.
Метод получает объект контекста.
Пример:
module.exports = new Resolver({
async resolve(args) {
console.log(args);
return null;
}
});
Основные свойства:
{
specifier,
dependency,
options,
logger
}
Строка импорта.
import x from "./module.js";
Значение:
"./module.js"
Объект зависимости.
Содержит дополнительную информацию:
dependency.specifier
dependency.priority
dependency.env
dependency.meta
Пример:
console.log(dependency.specifier);
Глобальные настройки Parcel.
Например:
console.log(options.projectRoot);
Результат:
/home/project
Инструмент для вывода сообщений.
logger.info({
message: "Resolver started"
});
Логи отображаются во время сборки.
Resolver может вернуть несколько различных типов результатов.
return {
filePath: "/project/src/index.js"
};
Parcel продолжит обработку найденного файла.
return {
isExcluded: true
};
Полезно для специальных импортов.
return null;
Parcel продолжит цепочку Resolver-плагинов.
throw new Error("Module not found");
Сборка завершится ошибкой.
Частая задача — преобразование относительного пути в абсолютный.
const path = require("path");
module.exports = new Resolver({
async resolve({specifier, options}) {
return {
filePath: path.join(
options.projectRoot,
specifier
)
};
}
});
Одно из самых распространённых применений Resolver.
Допустим, необходимо поддерживать запись:
import Button from "@components/Button";
Структура проекта:
src/
└── components/
└── Button.js
Resolver:
const {Resolver} = require("@parcel/plugin");
const path = require("path");
module.exports = new Resolver({
async resolve({specifier, options}) {
if (specifier.startsWith("@components/")) {
const relative = specifier.replace(
"@components/",
""
);
return {
filePath: path.join(
options.projectRoot,
"src/components",
relative + ".js"
)
};
}
return null;
}
});
Теперь импорт будет автоматически перенаправляться.
Иногда требуется создавать файлы, которых физически не существует.
Пример импорта:
import buildInfo from "virtual:build-info";
Resolver:
module.exports = new Resolver({
async resolve({specifier}) {
if (specifier === "virtual:build-info") {
return {
filePath: __filename
};
}
return null;
}
});
Обычно такой Resolver используется совместно с Transformer-плагином, который генерирует содержимое виртуального модуля.
В крупных системах часто применяются специальные схемы:
import settings from "config://frontend";
Проверка схемы:
if (specifier.startsWith("config://")) {
// обработка
}
Получение имени конфигурации:
const name = specifier.replace(
"config://",
""
);
Далее Resolver может определить реальное местоположение файла.
Иногда модули располагаются вне структуры проекта.
Например:
/project
/shared-libraries
Resolver:
const path = require("path");
module.exports = new Resolver({
async resolve({specifier}) {
if (specifier.startsWith("shared/")) {
return {
filePath: path.join(
"/shared-libraries",
specifier.replace("shared/", "")
)
};
}
return null;
}
});
Parcel предоставляет абстракцию над файловой системой.
Получение доступа:
const fs = options.inputFS;
Чтение файла:
const content =
await options.inputFS.readFile(
filePath,
"utf8"
);
Проверка существования:
const exists =
await options.inputFS.exists(filePath);
Такой подход обеспечивает совместимость с различными окружениями.
Пусть существует файл:
{
"@ui": "src/ui",
"@api": "src/api"
}
Resolver:
const {Resolver} = require("@parcel/plugin");
const path = require("path");
const fs = require("fs");
module.exports = new Resolver({
async resolve({specifier, options}) {
const configPath = path.join(
options.projectRoot,
"aliases.json"
);
const aliases = JSON.parse(
fs.readFileSync(configPath, "utf8")
);
for (const alias in aliases) {
if (specifier.startsWith(alias)) {
const result = specifier.replace(
alias,
aliases[alias]
);
return {
filePath: path.join(
options.projectRoot,
result
)
};
}
}
return null;
}
});
Такой подход позволяет централизованно управлять правилами разрешения.
Resolver может вызываться тысячи раз за одну сборку.
Повторные вычисления желательно исключать.
Пример:
const cache = new Map();
module.exports = new Resolver({
async resolve({specifier}) {
if (cache.has(specifier)) {
return cache.get(specifier);
}
const result = {
filePath: computePath(specifier)
};
cache.set(specifier, result);
return result;
}
});
Кэширование значительно ускоряет обработку больших проектов.
Для диагностики удобно использовать встроенный логгер.
logger.verbose({
message: `Resolving ${specifier}`
});
Предупреждения:
logger.warn({
message: "Deprecated import path"
});
Ошибки:
logger.error({
message: "Invalid configuration"
});
Информация:
logger.info({
message: "Resolver initialized"
});
Нежелательно выбрасывать необработанные исключения.
Плохой вариант:
const config = JSON.parse(content);
Без проверки корректности JSON.
Лучше:
try {
const config = JSON.parse(content);
} catch (error) {
throw new Error(
"Failed to parse resolver config"
);
}
Пример импорта:
import Button from "corp:ui/Button";
Resolver:
module.exports = new Resolver({
async resolve({specifier, options}) {
if (!specifier.startsWith("corp:")) {
return null;
}
const moduleName = specifier.replace(
"corp:",
""
);
return {
filePath: path.join(
options.projectRoot,
".corp-cache",
moduleName + ".js"
)
};
}
});
На практике подобный Resolver может:
После публикации пакета:
npm install parcel-resolver-custom
Настройка в .parcelrc:
{
"extends": "@parcel/config-default",
"resolvers": [
"parcel-resolver-custom",
"..."
]
}
Символ:
"..."
означает сохранение остальных Resolver из стандартной конфигурации Parcel.
Parcel вызывает Resolver-плагины последовательно.
Пример:
{
"resolvers": [
"resolver-a",
"resolver-b",
"resolver-c"
]
}
Сценарий работы:
resolver-a.null — вызывается
resolver-b.Поэтому специализированные Resolver обычно располагаются выше стандартных.
Плохо:
await fs.readFile(...)
при каждом вызове Resolver.
Лучше:
const config = loadOnce();
с последующим использованием кэша.
if (!specifier.startsWith("cms://")) {
return null;
}
Это позволяет другим Resolver корректно обработать зависимость.
Правильно:
return {
filePath: absolutePath
};
Неправильно:
return {
filePath: "./file.js"
};
Хорошая практика:
const pathResolver =
require("./pathResolver");
module.exports = new Resolver({
async resolve(args) {
return pathResolver(args);
}
});
Это упрощает тестирование и сопровождение кода.
Часто основная логика выносится в отдельную функцию.
function resolveAlias(specifier) {
if (specifier === "@config") {
return "/project/config.js";
}
return null;
}
Тест:
test("config alias", () => {
expect(
resolveAlias("@config")
).toBe("/project/config.js");
});
Такой подход позволяет проверять корректность работы без запуска полной сборки Parcel.
Resolver является частью общей цепочки обработки ресурсов.
Типичный процесс выглядит следующим образом:
Dependency
↓
Resolver
↓
Transformer
↓
Bundler
↓
Packager
↓
Optimizer
Resolver определяет местоположение ресурса.
Transformer преобразует содержимое.
Bundler формирует граф зависимостей.
Packager создаёт итоговые файлы.
Optimizer выполняет дополнительную оптимизацию.
Поэтому качество реализации Resolver напрямую влияет на корректность всей системы сборки и определяет, каким образом Parcel будет находить и подключать зависимости внутри проекта.