Два пути к SVG-спрайту в Iconly
11 Июл 2026
После 3.0 у iconly появился довольно понятный контракт загрузки: createIconly() проверяет кэш, при необходимости получает внешний SVG-файл и вставляет его в DOM. Но в этой схеме всё ещё зашито одно предположение: источник иконок — обязательно готовый файл со спрайтом.
Для части проектов это удобно. Для другой части иконки уже живут как ESM-модули рядом с остальным кодом, и собирать из них отдельный файл только затем, чтобы iconly снова его загрузил, выглядит лишним кругом. Поэтому в 4.1.0 я добавил второй публичный путь — iconly/sprite. Он принимает уже импортированные объекты иконок, собирает из них SVG и при необходимости вставляет его в тот же DOM-контейнер.
Через несколько дней, в 4.2.0, я отдельно занялся другой общей границей: любая строка SVG, которую библиотека собирается добавить в DOM страницы, должна проходить хотя бы базовую защитную обработку. При этом встроенную очистку я сознательно не называю полноценной санитизацией и оставляю хук для специализированной обработки.
Перед вторым входом в пакет
В 4.0.0 iconly заодно переехал на общий для портфолио стек сборки и проверки: tsdown, ESM/CJS, smoke-тесты и проверка tarball, публикация через Trusted Publishing. Подробно описывать этот инфраструктурный переход здесь не буду. Для следующего изменения важна одна вещь: пакет уже проверяет не только корневой импорт, поэтому второй публичный подпуть можно добавить как полноценную часть exports, типов и smoke-тестов.
Внешний файл больше не единственный источник
До 4.1 обычный сценарий остаётся таким:
import { createIconly } from 'iconly';
const iconLoader = createIconly({
file: './sprite.svg',
version: '1.0',
storage: 'indexeddb',
});
const result = await iconLoader.init();
Здесь iconly занимается всем жизненным циклом внешнего ресурса: кэшем, загрузкой, разбором SVG и вставкой в DOM.
Но если в приложении иконки уже представлены модулями, мне нужен другой уровень API. Не загрузчик, а небольшой сборщик, которому можно передать только нужные иконки:
import { createSprite } from 'iconly/sprite';
import { search, user, trash } from './icons';
const sprite = createSprite({
icons: [search, user, trash],
container: '#app',
});
const result = sprite.render();
if (!result.ok) {
console.error(result.error);
}
Формат одной иконки намеренно маленький:
export interface IconlyIcon {
name: string;
viewBox: string;
body: string;
}
name становится id у <symbol>, viewBox переносится в одноимённый SVG-атрибут, body остаётся содержимым <symbol>.
Новый путь вообще не использует сеть и хранилище. Он работает с данными, которые приложение уже импортировало.
Отдельный подпуть вместо расширения основного импорта
Я не добавляю сборщик спрайта в корневой импорт. Для него появляется отдельный подпуть:
import { createSprite, buildSpriteString } from 'iconly/sprite';
В package.json это отдельный экспорт:
{
"exports": {
"./sprite": {
"import": {
"types": "./dist/sprite.d.ts",
"default": "./dist/sprite.js"
},
"require": {
"types": "./dist/sprite.d.cts",
"default": "./dist/sprite.cjs"
}
}
}
}
А tsdown получает вторую точку входа:
export default defineLibrary({
platform: 'browser',
entry: {
index: 'src/index.ts',
sprite: 'src/sprite.ts',
},
});
Это позволяет не смешивать две модели использования. iconly остаётся API загрузки внешнего файла. iconly/sprite — API сборки спрайта из уже имеющихся объектов иконок.
Заодно smoke-тесты проверяют четыре новых артефакта: ESM, CJS и оба файла типов. После работы в 4.0 добавление нового подпути уже не заканчивается на “локально импортируется”.
buildSpriteString не требует DOM
Второй API внутри подпути ещё проще:
import { buildSpriteString } from 'iconly/sprite';
const svg = buildSpriteString([search, user, trash]);
Реализация почти буквально собирает <symbol>:
export const buildSpriteString = (icons: IconlyIcon[]): string => {
const symbols = icons
.map(
(icon) =>
`<symbol id="${escapeAttr(icon.name)}" viewBox="${escapeAttr(icon.viewBox)}">${icon.body}</symbol>`,
)
.join('');
return `<svg xmlns="http://www.w3.org/2000/svg">${symbols}</svg>`;
};
Здесь нет обращения к document, сети или хранилищу. Поэтому функцию можно использовать там, где нужен только результат строкой, например в SSR или в тесте.
Пустой массив тоже имеет определённый результат:
<svg xmlns="http://www.w3.org/2000/svg"></svg>
Для name и viewBox сборщик экранирует специальные символы. body при этом не превращается в текст, потому что это и есть SVG-разметка конкретной иконки.
Tree-shaking начинается до iconly
Одна из причин держать этот путь на уровне объектов иконок — он естественно сочетается с ESM-импортами:
import { search, user } from './icons';
buildSpriteString([search, user]);
Но здесь важно точно разделять ответственность. Сам iconly не запускает tree-shaking и не анализирует каталог иконок. Он получает уже выбранный массив.
Удалить неиспользованные экспорты может сборщик приложения, если пакет иконок организован подходящим образом. Задача iconly/sprite гораздо проще: не заставлять потребителя сначала собирать общий внешний файл спрайта, если нужные объекты иконок уже находятся в графе модулей.
Два пути используют одну вставку в DOM
При добавлении createSprite() я не хочу получить вторую реализацию вставки SVG.
В 4.1 resolveContainer() переезжает из core.ts в dom.ts, теперь его используют оба сценария. Сам createSprite() заканчивается тем же insertSvg(), что и загрузчик:
return insertSvg(
containerResult.value,
buildSpriteString(icons),
);
Получаются две разные цепочки до DOM:
createIconly()
кэш -> fetch -> строка SVG
|
v
insertSvg()
createSprite()
объекты иконок -> buildSpriteString()
|
v
insertSvg()
Для меня это важнее, чем просто вынести ещё пару функций в отдельный файл. Источник данных может быть разным, но правила выбора контейнера, разбора SVG и вставки не должны расходиться без причины.
Перед вставкой SVG нужна отдельная граница
В 2.0 я уже ушёл от insertAdjacentHTML к DOMParser, но разбор сам по себе не делает входной SVG безопасным. К 4.2.0 я добавил отдельную обработку перед тем, как разобранный элемент попадёт в DOM страницы.
Теперь insertSvg() принимает необязательные параметры:
export interface InsertSvgOptions {
sanitize?: SvgSanitizer;
}
Порядок такой:
SVG string
-> пользовательская функция sanitize, если задана
-> DOMParser
-> встроенная защитная обработка SVG
-> document.importNode()
-> DOM страницы
Один и тот же путь используется и для внешнего файла, и для createSprite().render().
Публично sanitize подключается одинаково:
const iconLoader = createIconly({
file: './sprite.svg',
sanitize,
});
const sprite = createSprite({
icons: [search, user],
sanitize,
});
Пользовательская функция вызывается до разбора SVG. Это оставляет возможность подключить специализированный санитайзер или собственные правила обработки строки, не зашивая стороннюю зависимость в iconly.
Что делает встроенная защитная обработка
После разбора библиотека рекурсивно проходит дерево SVG.
В текущей версии удаляются:
<script><foreignObject>- атрибуты вида
onerror,onclickи другие обработчикиon* hrefиxlink:hrefсо схемамиjavascript:иdata:text/html- элементы SMIL-анимации, которые пытаются менять
hrefилиxlink:href.
Последний пункт сделан не как запрет всей SVG-анимации. Например, animate для opacity остаётся допустимым. Тесты отдельно фиксируют это различие.
Есть и интеграционные тесты для обоих публичных сценариев. Внешний SVG с onerror проходит через createIconly(), body иконки с тем же атрибутом — через createSprite(). В обоих случаях обработчик должен исчезнуть до вставки.
То есть защитная обработка привязана не к тому, откуда пришёл SVG, а к общей операции “сейчас эта структура попадёт в DOM”.
Это не полноценный санитайзер
Здесь я не хочу “переобещать”.
Встроенный проход закрывает несколько очевидных опасных конструкций, но не пытается реализовать полноценный санитайзер для всего пространства SVG/HTML. В README я прямо оставил примеры того, что он не покрывает полностью: CSS-векторы внутри <style> и внешние ссылки в <use> или <image>.
Поэтому для недоверенного содержимого есть хук sanitize. Например, туда можно подключить специализированную библиотеку, а встроенный проход оставить дополнительным слоем перед вставкой.
Есть ещё одна граница: buildSpriteString() возвращает строку как есть и вообще не касается DOM. Поэтому встроенная защитная обработка там не выполняется.
const svg = buildSpriteString(untrustedIcons);
Если такую строку дальше использовать самостоятельно, ответственность за очистку остаётся у вызывающего кода. В 4.2 сборщик дополнительно экранирует > в name и viewBox, но icon.body по-прежнему остаётся SVG-разметкой.