Делаем API Iconly предсказуемым
25 Янв 2026
В 2.0 в основном приводил в порядок внутреннее устройство iconly, сохраняя прежний способ использования. Для следующего мажорного релиза задача другая. Теперь я хочу пересобрать сам публичный контракт так, чтобы по нему было проще понять, что произошло во время инициализации, подменить отдельные части и нормально тестировать библиотеку.
Заодно это часть более общей работы по унификации моих библиотек. Мне нужна не просто работающая утилита с собственными привычками, а более одинаковый подход к API: явные результаты операций, небольшая публичная поверхность и заменяемые зависимости там, где это действительно полезно.
В 3.0.0 поменялось сразу несколько вещей. new Iconly() уступил место createIconly(), init() возвращает Result, хранилище становится отдельным интерфейсом, а монолитный исходник разбивается на ядро, работу с DOM, загрузку, ошибки и адаптеры хранилища.
Одного Promise<void> уже мало
В 2.0.1 вызов выглядит просто:
import Iconly from 'iconly';
const iconly = new Iconly({
file: './sprite.svg',
version: '1.0',
});
await iconly.init();
Но из самого результата вызова невозможно понять, загрузился ли спрайт. init() возвращает Promise<void>, а ошибки выполнения обрабатываются внутри библиотеки. Для небольшой утилиты этого достаточно, но такой контракт плохо масштабируется. Я хочу явно различать ошибку контейнера, загрузки, хранилища или разбора SVG и при этом не заставлять пользователя угадывать поведение по выводу в консоль.
В 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().
Это позволяет ядру собирать одну последовательность операций без смеси исключений, null, логических флагов и скрытого логирования.
В README контракт init() сформулирован прямо: штатные ошибки операции не выбрасываются исключением, а возвращаются через Result, который нужно проверить.
Логирование больше не единственный канал ошибок
В предыдущей версии отладочный вывод был частью внутреннего поведения класса. Теперь диагностику тоже можно явно подключить к приложению:
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. В новой версии потребителю важны только доступные операции экземпляра.
Это лучше укладывается в стиль публичных API, к которому я привожу свои библиотеки: меньше обязательной формы реализации и больше явных контрактов через типы.
Второй метод, abort(), нужен для активного fetch. Внутри createIconly() хранится AbortController, а отменённый запрос превращается в обычный результат с кодом fetch_aborted:
const iconLoader = createIconly({ file: './sprite.svg' });
const pending = iconLoader.init();
iconLoader.abort();
const result = await pending;
То есть отмена тоже получает место в том же контракте ошибок, а не отдельную случайную ветку поведения.
IndexedDB больше не должен быть самим ядром
В ранних версиях 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, можно тестировать эту стратегию отдельно в контролируемой среде.
Хранилище заодно исправляет порядок работы кэша
До 3.0 IndexedDB уже использовался, но сама последовательность была неидеальной: iconly сначала выполнял fetch(), а потом читал сохранённую запись. Получалось, что кэш не мог убрать сетевой запрос.
После разделения хранилища порядок становится естественнее:
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,
});
}
Сначала читается хранилище. Только если записи нет, выполняется 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);
Для предыдущей версии я не мог бы честно описать IndexedDB как способ избежать повторного сетевого запроса. Начиная с 3.0, это уже соответствует коду и тесту.
Тесты становятся частью самой архитектуры
В 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 и проверяет чтение из кэша.
Именно здесь понятно, зачем мне понадобилось разрезать библиотеку по границам. Это не абстракции ради количества файлов. Возможность выбрать хранилище в памяти позволяет тестировать основной сценарий без IndexedDB, а контракт Result позволяет проверять исход операции напрямую.
В 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
}
Поменялся и DOM-якорь. Вместо глобального #iconset обёртка создаётся внутри выбранного контейнера:
<div data-iconly="iconset" aria-hidden="true">...</div>
Это тоже часть нового контракта. Внутренний селектор больше не опирается на глобальный id, а обёртка явно помечена как служебная и скрыта от дерева доступности.