Делаем API Iconly предсказуемым

25 Янв 2026

В 2.0 в основном приводил в порядок внутреннее устройство iconly, сохраняя прежний способ использования. В 3.0 занялся публичным API. Я хотел, чтобы по результату инициализации было понятно, что произошло, а отдельные части библиотеки можно было подменять и тестировать.

Заодно это часть общей работы по унификации моих библиотек. Мне нужен единый подход к API: явные результаты операций, небольшой публичный интерфейс и заменяемые зависимости там, где это действительно полезно.

В 3.0.0 new Iconly() уступил место createIconly(), init() начал возвращать Result, а хранилище получило собственный интерфейс. Основную реализацию я разделил на несколько модулей: ядро, работа с DOM, загрузка, ошибки и адаптеры хранилища.

Явный результат инициализации

В 2.0.1 вызов выглядел просто:

import Iconly from 'iconly';

const iconly = new Iconly({
  file: './sprite.svg',
  version: '1.0',
});

await iconly.init();

Но из результата вызова было невозможно понять, загрузился ли спрайт. init() возвращал Promise<void>, а ошибки выполнения обрабатывались внутри библиотеки. Для небольшой утилиты этого достаточно. Я хотел явно различать ошибки контейнера, загрузки, хранилища и разбора SVG, чтобы пользователю не приходилось угадывать результат по состоянию DOM.

В 3.0 результат стал частью API:

import { createIconly } from 'iconly';

const iconLoader = createIconly({
  file: './sprite.svg',
  version: '1.0',
});

const result = await iconLoader.init();

if (!result.ok) {
  console.error(result.error);
}

Сам тип очень небольшой:

export type Result<T> =
  | { ok: true; value: T }
  | { ok: false; error: IconlyError };

Для init() это фактически Promise<Result<void>>. У вызывающего кода теперь есть два явных состояния, и TypeScript различает их по ok.

Ошибка тоже становится данными

Одного ok: false мало, если дальше всё равно приходится разбирать текст сообщения. Поэтому у ошибки появилась собственная форма:

export interface IconlyError {
  code: IconlyErrorCode;
  message: string;
  cause?: unknown;
}

В текущем релизе используются такие коды:

type IconlyErrorCode =
  | 'container_invalid'
  | 'fetch_aborted'
  | 'fetch_failed'
  | 'indexeddb_not_supported'
  | 'indexeddb_open_failed'
  | 'indexeddb_request_failed'
  | 'parse_error'
  | 'storage_read_failed'
  | 'storage_unavailable'
  | 'storage_write_failed';

Теперь вызывающий код может реагировать на категорию ошибки, а не на формулировку сообщения:

const result = await iconLoader.init();

if (!result.ok && result.error.code === 'fetch_aborted') {
  // запрос был отменён
}

Тот же Result проходит через внутренние части библиотеки. fetchSvg() возвращает Result<string>, вставка SVG возвращает Result<void>, адаптеры хранилища возвращают Result из get() и set().

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

Диагностику можно подключить к приложению

Диагностику теперь тоже можно явно подключить к приложению:

const iconLoader = createIconly({
  onError: (error) => reportError(error),
  onDebug: (...messages) => debugLog(...messages),
  logger: {
    debug: (...messages) => console.debug(...messages),
    error: (...messages) => console.error(...messages),
  },
});

При этом колбэки не заменяют Result. Ошибка передаётся в onError и logger.error, но init() всё равно возвращает тот же объект ошибки вызывающему коду.

debug управляет только отладочными сообщениями. Ошибки остаются отдельным каналом и не исчезают из-за debug: false.

Для меня это важное разделение. Результат операции нужен программе. logger и колбэки нужны для диагностики.

Фабричная функция вместо публичного класса

Вместе с новым контрактом ошибок я убрал публичный конструктор:

const iconLoader = createIconly(config);

Фабричная функция возвращает небольшой объект:

export interface IconlyInstance {
  init: () => Promise<Result<void>>;
  abort: () => void;
}

Мне важна не сама замена синтаксиса new на функцию. Я хочу, чтобы публичный API описывал доступные операции, а не устройство класса.

Второй метод, abort(), отменяет уже начавшийся fetch. Внутри createIconly() хранится AbortController, а отменённый запрос превращается в обычный результат с кодом fetch_aborted. Пока init() читает кэш, запрос может ещё не начаться, поэтому немедленный вызов abort() после init() не гарантирует отмену.

Например, отмену активного запроса можно привязать к кнопке:

<button id="cancel-icons" type="button">Отменить загрузку</button>
import { createIconly } from 'iconly';

const iconLoader = createIconly({ file: './sprite.svg' });
const cancelButton = document.querySelector<HTMLButtonElement>('#cancel-icons')!;

cancelButton.addEventListener('click', () => iconLoader.abort());

const result = await iconLoader.init();

Хранилище можно заменить

В ранних версиях iconly хранилище и основная логика были почти одним целым. В 3.0 я отделил работу с хранилищем от ядра, оставив IndexedDB вариантом по умолчанию.

Для этого появился публичный интерфейс:

export interface IconStorage {
  get(version: string): Promise<Result<IconRecord | undefined>>;
  set(record: IconRecord): Promise<Result<void>>;
}

А опция storage принимает несколько стратегий:

export type StorageStrategy =
  | 'indexeddb'
  | 'memory'
  | 'session'
  | IconStorage;

Встроенных вариантов три:

createIconly({ storage: 'indexeddb' });
createIconly({ storage: 'memory' });
createIconly({ storage: 'session' });

Четвёртый вариант — собственная реализация IconStorage.

Для меня это особенно важно при тестировании. Ядро работает через get() и set(), а детали хранилища остаются в его реализации. Для теста основного сценария можно выбрать хранилище в памяти и обойтись без IndexedDB. Сам адаптер IndexedDB можно проверить отдельно в контролируемой среде.

Чтение кэша перед загрузкой SVG

До 3.0 IndexedDB уже использовался, но сама последовательность была неидеальной: iconly сначала выполнял fetch(), а потом читал сохранённую запись. Получалось, что кэш не мог убрать сетевой запрос.

После разделения хранилища я изменил порядок операций. Сокращённая схема, без проверок Result и обработки ошибок:

const cacheResult = await storage.get(resolved.version);
let data = cacheResult.value?.data;

if (!data) {
  const fetchResult = await fetchSvg(resolved.file, controller.signal);
  data = fetchResult.value;

  await storage.set({
    version: resolved.version,
    data,
  });
}

Сначала читается хранилище. Если сохранённых SVG-данных нет, выполняется fetch.

Повторная инициализация с той же версией теперь может обойтись без сети.

Закрепил это тестом. Первый экземпляр загружает /sprite.svg и записывает данные в IndexedDB. Второй использует ту же базу и ту же версию. Тест проверяет, что оба init() завершаются успешно, а подменённый fetch вызывается только один раз.

expect(firstResult.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);

const secondResult = await second.init();

expect(secondResult.ok).toBe(true);
expect(fetchMock).toHaveBeenCalledTimes(1);

Проверка основных сценариев

В 3.0 у пакета появился набор тестов на Vitest, jsdom и fake-indexeddb.

Простой DOM-сценарий можно проверить через хранилище в памяти:

const iconly = createIconly({
  storage: 'memory',
  file: '/sprite.svg',
  version: '1.0',
  container,
});

const result = await iconly.init();

expect(result.ok).toBe(true);
expect(container.querySelector('[data-iconly="iconset"] svg')).not.toBeNull();

Для IndexedDB отдельный тест использует fake-indexeddb и проверяет чтение из кэша.

Контракт Result позволяет проверять исход операции напрямую, а сменное хранилище — тестировать основной сценарий без IndexedDB.

В package.json заодно появилась общая команда проверки:

{
  "verify": "yarn lint && yarn typecheck && yarn test"
}

Разделение на модули

До этого основная реализация жила в src/index.ts. В 3.0 структура стала такой:

src/
  core.ts
  dom.ts
  errors.ts
  fetcher.ts
  index.ts
  result.ts
  storage/
    index.ts
    indexeddb.ts
    memory.ts
    session.ts
  types.ts

index.ts теперь в основном задаёт публичные экспорты. core.ts управляет последовательностью операций. Вставка в DOM, загрузка, вспомогательные функции Result и разные реализации хранилища находятся в отдельных модулях.

Для меня важно, что модули соответствуют контрактам в коде: хранилище можно заменить, загрузка и вставка в DOM возвращают Result, а публичные типы экспортируются из одной точки. Такую структуру проще проверять и менять.

Мажорный релиз всё равно требует миграции

Предсказуемый API не означает полностью совместимый API. В 3.0 есть несколько намеренных несовместимых изменений.

Главное — перейти с конструктора на фабричную функцию и проверять результат init():

// 2.x
const iconly = new Iconly(options);
await iconly.init();

// 3.x
const iconly = createIconly(options);
const result = await iconly.init();

if (!result.ok) {
  // обработать result.error
}

Поменялся и селектор обёртки спрайта. В выбранном контейнере создаётся элемент с data-iconly вместо прежнего #iconset:

<div data-iconly="iconset" aria-hidden="true">...</div>

Внутренний селектор больше не опирается на глобальный id. Атрибут aria-hidden="true" исключает служебную обёртку из дерева доступности.

Материалы