Дорабатываю Iconly изнутри

10 Мар 2025

В 2.0.0 внешний сценарий почти не изменился, я пересмотрел несколько границ внутри библиотеки: как разрешается container, как строка 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();

И здесь это принципиально. Задача 2.0 не в том, чтобы придумать новый способ загружать иконки. Я хотел сохранить знакомый вход в библиотеку и пересобрать то, что находится за ним.

TypeScript тоже не относится к нововведениям. 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 я сначала полностью разрешаю container:

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;

После конструктора внутри класса больше нет состояния string | HTMLElement. Есть только HTMLElement.

Здесь я сознательно поменял поведение. Невалидный селектор теперь не означает “ладно, положим в body”. Он означает ошибку конфигурации.

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

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 больше не проходит через код как непрозрачная строка до самого innerHTML. У него появляется отдельный этап разбора как image/svg+xml.

Во-вторых, появляется конкретная точка, где можно обнаружить ошибку парсинга до вставки элемента в основной документ.

При этом 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 или сырого объекта ошибки. Когда операция проходит через fetch, IndexedDB и DOM, хотя бы понятно, на какой границе она остановилась.

Но публичный контракт ошибок я в этом релизе не менял. 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 корневой экспорт оформлен явно через ".", а dist открыт отдельным подпутём:

{
  "exports": {
    ".": {
      "require": "./dist/index.cjs.js",
      "import": "./dist/index.es.js",
      "default": "./dist/index.umd.js"
    },
    "./dist/*": "./dist/*"
  }
}

Материалы