Делаем 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" исключает служебную обёртку из дерева доступности.