Два пути к SVG-спрайту в Iconly
11 Июл 2026
В 3.0 у iconly появился довольно понятный контракт загрузки: при вызове init() загрузчик проверяет кэш, при необходимости получает внешний 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();
Загрузчик работает с кэшем, получает внешний файл при необходимости, разбирает SVG и вставляет его в DOM.
Для импортированных иконок мне нужен небольшой сборщик, которому можно передать только нужные объекты:
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
Если нужна только SVG-строка, есть buildSpriteString():
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, сети или хранилищу. Поэтому функцию можно использовать там, где нужен SVG в виде строки, например в 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. Теперь его используют оба сценария. Метод render() созданного спрайта использует тот же insertSvg(), что и загрузчик:
return insertSvg(
containerResult.value,
buildSpriteString(icons),
);
Получаются две разные цепочки до DOM:
createIconly()
кэш -> fetch -> строка SVG
|
v
insertSvg()
createSprite()
объекты иконок -> buildSpriteString()
|
v
insertSvg()
Источник данных может быть разным, но правила выбора контейнера, разбора SVG и вставки не должны расходиться без причины.
Защитная обработка перед вставкой SVG
В 2.0 я добавил отдельный разбор SVG через 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-разметкой.