Дорабатываю 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/*"
  }
}

Материалы