Написание Resolver-плагина

Механизм разрешения модулей (Module Resolution) является одним из ключевых компонентов сборщика Parcel. Каждый раз, когда в исходном коде встречается импорт:

import React from "react";
import logo from "./images/logo.svg";
import styles from "./styles/main.css";

Parcel должен определить:

  • где находится запрашиваемый ресурс;
  • какой файл необходимо загрузить;
  • каким образом обрабатывать найденный ресурс;
  • какие дополнительные зависимости необходимо учитывать.

За эти операции отвечает система Resolver-плагинов.

Resolver-плагин позволяет изменить стандартную логику поиска модулей и ресурсов. С его помощью можно:

  • реализовывать собственные алиасы;
  • подключать виртуальные файлы;
  • изменять правила поиска модулей;
  • загружать данные из удалённых источников;
  • интегрировать внутренние корпоративные репозитории;
  • создавать специальные схемы URL.

Когда требуется собственный 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-плагинов

Все 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

Простейший 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()

После чего возвращается абсолютный путь к файлу.


Аргументы метода resolve

Метод получает объект контекста.

Пример:

module.exports = new Resolver({
  async resolve(args) {
    console.log(args);

    return null;
  }
});

Основные свойства:

{
  specifier,
  dependency,
  options,
  logger
}

specifier

Строка импорта.

import x from "./module.js";

Значение:

"./module.js"

dependency

Объект зависимости.

Содержит дополнительную информацию:

dependency.specifier
dependency.priority
dependency.env
dependency.meta

Пример:

console.log(dependency.specifier);

options

Глобальные настройки Parcel.

Например:

console.log(options.projectRoot);

Результат:

/home/project

logger

Инструмент для вывода сообщений.

logger.info({
  message: "Resolver started"
});

Логи отображаются во время сборки.


Возвращаемые значения

Resolver может вернуть несколько различных типов результатов.

Успешное разрешение

return {
  filePath: "/project/src/index.js"
};

Parcel продолжит обработку найденного файла.


Игнорирование зависимости

return {
  isExcluded: true
};

Полезно для специальных импортов.


Передача управления следующему Resolver

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-плагином, который генерирует содержимое виртуального модуля.


Использование пользовательских схем URL

В крупных системах часто применяются специальные схемы:

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

Parcel предоставляет абстракцию над файловой системой.

Получение доступа:

const fs = options.inputFS;

Чтение файла:

const content =
  await options.inputFS.readFile(
    filePath,
    "utf8"
  );

Проверка существования:

const exists =
  await options.inputFS.exists(filePath);

Такой подход обеспечивает совместимость с различными окружениями.


Resolver на основе конфигурационного файла

Пусть существует файл:

{
  "@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"
  );
}

Resolver для корпоративного реестра модулей

Пример импорта:

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 может:

  • обращаться к внутреннему API;
  • скачивать пакеты;
  • синхронизировать локальный кэш;
  • контролировать версии зависимостей.

Подключение Resolver-плагина

После публикации пакета:

npm install parcel-resolver-custom

Настройка в .parcelrc:

{
  "extends": "@parcel/config-default",
  "resolvers": [
    "parcel-resolver-custom",
    "..."
  ]
}

Символ:

"..."

означает сохранение остальных Resolver из стандартной конфигурации Parcel.


Порядок выполнения Resolver-плагинов

Parcel вызывает Resolver-плагины последовательно.

Пример:

{
  "resolvers": [
    "resolver-a",
    "resolver-b",
    "resolver-c"
  ]
}

Сценарий работы:

  1. Вызывается resolver-a.
  2. Если возвращён результат — цепочка завершается.
  3. Если возвращён null — вызывается resolver-b.
  4. Далее аналогично для остальных Resolver.

Поэтому специализированные Resolver обычно располагаются выше стандартных.


Практические рекомендации

Минимизировать обращения к файловой системе

Плохо:

await fs.readFile(...)

при каждом вызове Resolver.

Лучше:

const config = loadOnce();

с последующим использованием кэша.


Возвращать null для неподдерживаемых импортов

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);
  }
});

Это упрощает тестирование и сопровождение кода.


Тестирование Resolver-плагинов

Часто основная логика выносится в отдельную функцию.

function resolveAlias(specifier) {
  if (specifier === "@config") {
    return "/project/config.js";
  }

  return null;
}

Тест:

test("config alias", () => {
  expect(
    resolveAlias("@config")
  ).toBe("/project/config.js");
});

Такой подход позволяет проверять корректность работы без запуска полной сборки Parcel.


Взаимодействие Resolver с другими типами плагинов

Resolver является частью общей цепочки обработки ресурсов.

Типичный процесс выглядит следующим образом:

Dependency
    ↓
Resolver
    ↓
Transformer
    ↓
Bundler
    ↓
Packager
    ↓
Optimizer

Resolver определяет местоположение ресурса.

Transformer преобразует содержимое.

Bundler формирует граф зависимостей.

Packager создаёт итоговые файлы.

Optimizer выполняет дополнительную оптимизацию.

Поэтому качество реализации Resolver напрямую влияет на корректность всей системы сборки и определяет, каким образом Parcel будет находить и подключать зависимости внутри проекта.