Дорабатываю Iconly изнутри
10 Мар 2025
В 2.0.0 способ использования Iconly почти не изменился. Я пересмотрел внутреннюю реализацию: поиск контейнера, вставку SVG в DOM, обработку ошибок и сборку пакета для разных систем модулей. Через несколько дней выпустил 2.0.1, чтобы поправить exports пакета.
Снаружи почти ничего нового
Использование 2.0.1 выглядит почти так же, как до мажорного релиза:
import Iconly from 'iconly';
const iconly = new Iconly({
file: './sprite.svg',
version: '1.0',
debug: true,
});
await iconly.init();
Я хотел сохранить привычный способ загрузки иконок и переработать внутреннюю реализацию.
TypeScript появился ещё в 1.4.x. В 2.0 я начал последовательнее использовать типизацию там, где код работал со значениями разных типов.
Проверка контейнера в конструкторе
До 2.0 опция container могла быть CSS-селектором или HTMLElement. Контейнер по селектору определялся примерно так:
container: typeof options.container === 'string'
? document.querySelector(options.container) ?? defaultOptions.container
: options.container ?? defaultOptions.container
Если по селектору ничего не находилось, iconly использовал контейнер по умолчанию и продолжал работать.
Но в этом же и была проблема. Я передавал конкретный селектор, библиотека не находила по нему элемент и начинала работать с другим DOM-узлом. Ошибка конфигурации превращалась в незаметный переход к значению по умолчанию.
В 2.0 я ищу и проверяю контейнер в конструкторе:
let containerEl: HTMLElement;
if (typeof merged.container === 'string') {
const found = document.querySelector(merged.container);
if (!found || !(found instanceof HTMLElement)) {
throw new Error(`Invalid container selector: "${merged.container}"`);
}
containerEl = found;
} else {
containerEl = merged.container;
}
this.container = containerEl;
После выполнения конструктора контейнер хранится как HTMLElement, а не string | HTMLElement.
Здесь я сознательно поменял поведение. Невалидный селектор теперь не означает “ладно, положим в body”. Он означает ошибку конфигурации.
Мне хотелось, чтобы остальной код работал с готовым DOM-элементом и не проверял тип контейнера заново.
SVG сначала становится документом
В 1.5.1 спрайт вставлялся напрямую:
iconSetDiv.innerHTML = data;
data — строка, полученная из SVG-файла. Браузер разбирает её уже в момент присваивания innerHTML.
В 2.0 я разделил эти операции. Сначала строка явно разбирается как SVG-документ:
const parser = new DOMParser();
const svgDoc = parser.parseFromString(data, 'image/svg+xml');
const parserError = svgDoc.querySelector('parsererror');
if (parserError) {
this.logError('SVG parsing error:', parserError.textContent ?? '');
return;
}
После этого корневой SVG переносится в текущий документ:
iconSetDiv.innerHTML = '';
const svgEl = svgDoc.documentElement;
if (svgEl) {
const imported = document.importNode(svgEl, true);
iconSetDiv.appendChild(imported);
} else {
this.logError('No valid SVG content found to insert.');
}
Теперь SVG разбирается на отдельном этапе, и ошибку парсинга можно обнаружить до вставки в основной документ.
При этом DOMParser не стоит путать с санитайзером. Этот код не делает недоверенный SVG безопасным и не решает все возможные проблемы содержимого. В этом релизе задача более узкая: работать с SVG как с SVG-документом, а не только как со строкой HTML.
Сообщения об ошибках стали конкретнее
До рефакторинга при ошибках IndexedDB код часто просто передавал дальше request.error или tx.error. В 2.0 я добавил функцию, которая возвращает сообщение ошибки или запасной текст:
private createErrorMessage(err: unknown, fallback: string): string {
if (!err) {
return fallback;
}
if (err instanceof DOMException || err instanceof Error) {
return err.message || fallback;
}
return fallback;
}
Заодно сообщения привязаны к конкретной стадии:
'Failed to fetch icons from "..."'
'Error getting record from store'
'Error putting record into store'
'Transaction error'
'Transaction aborted'
В исходниках это полезнее общего Network response was not ok или сырого объекта ошибки: сообщения показывают, на каком этапе возникла проблема. Но в опубликованной сборке 2.0.1 Terser удаляет вызовы console, поэтому до пользователя npm-пакета этот вывод не доходит.
Но контракт обработки ошибок init() я в этом релизе не менял. Метод всё ещё возвращает Promise<void> и ловит ошибки выполнения внутри:
public async init(): Promise<void> {
try {
// fetch, IndexedDB, insert
} catch (err: unknown) {
const e = err instanceof Error ? err : new Error(String(err));
this.logError('Error initializing Iconly:', e.message);
}
}
Ошибки, перехваченные внутри init(), не попадают во внешний try/catch. Структурированного результата метод тоже не возвращает. Пока я оставлю эту модель как есть. Цель 2.0 — сделать саму реализацию понятнее, не перепроектировать весь публичный API одновременно.
Сборка — часть библиотеки
До 2.0 пакет собирался с помощью Parcel. В мажорном релизе я перевёл сборку библиотеки на Vite и явно задал три формата:
lib: {
entry: 'src/index.ts',
name: 'Iconly',
formats: ['es', 'cjs', 'umd'],
fileName: (format) => `index.${format}.js`,
},
Декларации типов собираются отдельно через vite-plugin-dts, а package.json указывает пути к собранным файлам:
{
"main": "dist/index.cjs.js",
"module": "dist/index.es.js",
"browser": "./dist/index.umd.js",
"types": "dist/index.d.ts"
}
В 2.0.0 сразу добавил и условные exports для require, import и запасной вариант на UMD.
В 2.0.1 я уточнил структуру exports: корневой экспорт оформлен явно через ".", а dist открыт отдельным подпутём:
{
"exports": {
".": {
"require": "./dist/index.cjs.js",
"import": "./dist/index.es.js",
"default": "./dist/index.umd.js"
},
"./dist/*": "./dist/*"
}
}