Всем привет! Сегодня мы рассмотрим один из вариантов интеграции svg иконок в наш фронтенд проект используя веб-компоненты. Основная идея компонента заключается в том, чтобы лениво подгружать в SVG спрайт иконки и переиспользовать уже загруженные иконки при необходимости. Сами иконки будем вставлять в разметке в виде <svg-icon name="arrow-angle-down">
нам понадобится всего сотня строк кода! Кому интересна реализация, прошу под кат!
Для тех, кому лень читать и хочется сразу посмотреть на весь листинг кода - прошу в репозиторий svg-icon. Для начала просто проговорим логику работы веб-компонента:
Получаем атрибут name у компонента и проверяем его наличие в уже существующем спрайте иконок на странице
Если такой иконки нету, то проверяем не загружается ли эта иконка в данный момент
Если такая иконка не загружается, загружаем ее и добавляем в спрайт.
Все достаточно просто. Для начала добавим в наш шаблон проекта пустой спрайт
<div id="SVG_SPRITE" style="display: none;">
<svg><defs></defs></svg>
</div>
На этом этапе, можно сделать ремарку, что для еще большей оптимизации процесса, мы могли бы сюда отрендерить в defs сразу наши иконки, что позволит нам не подгружать их по сети уже на клиенте. Реализация этого остается на плечах разработчика, как пример могу предложить использовать vite vite-plugin-svg-prite.
Теперь рассмотрим саму реализацию по частям
class SVGIcon extends HTMLElement {
//ID спрайт элемента
#SPRITE_ID = 'SVG_SPRITE';
//Публичный путь по которому будем загружать иконки
#ICONS_PATH = '/icons';
constructor() {
super();
}
//Проверяем наличие атрибута name
connectedCallback() {
const iconName = this.getAttribute('name');
if(iconName) {
this.#loadIcon(iconName);
} else {
console.error('svg-icon undefined attr name');
}
}
...
Здесь все очень просто, мы выносим в приватные свойства ID элемента для спрайта и публичный путь к иконкам на сервере, при срабатывании хука connectedCallback
проверяем атбрут name и запускаем приватный метод #loadIcon
.
Теперь перейдем непосредственно к логике загрузки
...
/**
* Загружаем или переиспользуем иконку
* @param iconName name of icon
*/
#loadIcon(iconName:string) {
//Сохраняем ссылку на спрайт для сокращения обращений к DOM
const spriteEl = document.getElementById(this.#SPRITE_ID);
//Проверяем есть ли спрайт
if(spriteEl === null) {
return console.error('svg-icon undefined sprite element');
}
//Пытаемся выбрать иконку из спрайта
let icon = spriteEl.querySelector(`[id="${iconName}.svg"]`);
//Если иконки нету, загружаем ее
if(!icon) {
//Проверяем наличие кеш объекта для промисов
if(!window[this.#SPRITE_ID]) {
window[this.#SPRITE_ID] = {};
}
//Проверяем есть ли уже промис на загрузку иконки
if(window[this.#SPRITE_ID][iconName]) {
//Если есть, ожидаем его выполнения
window[this.#SPRITE_ID][iconName].then((iconSvg:string) => {
if(iconSvg) {
this.#addIconInSprite(iconSvg, iconName, spriteEl);
icon = spriteEl.querySelector(`[id="${iconName}.svg"]`);
} else {
console.error(`svg-icon ${iconName} response undefined`);
}
});
} else {
//Если промиса нету, запускаем fetch иконки
window[this.#SPRITE_ID][iconName] = fetch(`${this.#ICONS_PATH}/${iconName}.svg`).then( async (response) => {
const iconSvg = await response?.text();
if(iconSvg) {
this.#addIconInSprite(iconSvg, iconName, spriteEl);
icon = spriteEl.querySelector(`[id="${iconName}.svg"]`);
} else {
console.error(`svg-icon ${iconName} response undefined`);
}
return iconSvg;
}).catch(err => console.error('svg-icon fetch err', err));
}
//Если иконка есть, создаем свг елемент с ее содержимым
}
const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'),
useElement = document.createElementNS('http://www.w3.org/2000/svg', 'use');
useElement.setAttributeNS('http://www.w3.org/1999/xlink', 'xlink:href', `#${iconName}.svg`);
svg.append(useElement);
this.appendChild(svg);
}
...
Логика загрузки иконки тоже весьма тривиальная, мы проверяем ее наличие в SVG спрайте, если иконка есть, дублируем ее в компонент, а если нет, то проверяем наличие промиса загрузки иконки чтобы не дублировать сетевые запросы, если промиса нету, то все-таки загружаем иконки по сети. Осталось рассмотреть последний метод, добавления иконки в спрайт
...
/**
* Добавляем иконку в svg спрайт
* @param svgContent svg text content from response
* @param iconName name of icon
*/
#addIconInSprite(svgContent:string, iconName:string, spriteEl:HTMLElement) {
//Создаем template и добавляем в него полученный свг контент
const tmp = document.createElement('template');
tmp.innerHTML = svgContent;
//Выделяем только svg елемент из полученного контента
const tmpSvg = tmp.content.querySelector('svg'),
symbol = document.createElementNS('http://www.w3.org/2000/svg', 'symbol');
if(tmpSvg) {
//Копируем аттрибуты из оригинального свг
symbol.setAttribute('id', iconName + '.svg');
if(null !== tmpSvg.getAttribute('viewBox')) {
symbol.setAttribute('viewBox', tmpSvg.getAttribute('viewBox'));
}
if(null !== tmpSvg.getAttribute('fill')) {
symbol.setAttribute('fill', tmpSvg.getAttribute('fill'));
}
symbol.innerHTML = tmpSvg.innerHTML;
} else {
console.error(`svg-icon not found svg content for ${iconName}`);
}
const spriteDefs = spriteEl.querySelector('defs');
if(spriteDefs) {
spriteDefs.append(symbol);
}
}
Некоторые могут спросить, зачем создавать template, выбирать из него SVG ведь мы и так загружаем SVG - ответ прост, SVG контент может содержать комментарии, а нас интересует только конкретно SVG элемент. Также ради оптимизации обращений к DOM функция принимает в аргументах ссылку на SVG спрайт который мы нашли в предыдущем методе #loadIcon
. Также мы копируем атрибуты viewBox
и fill
для сохранения размера и цвета оригинальной иконки. Дальнейшее перекрашивание иконок и изменение размера нам доступно через CSS. Осталось лишь объявить компонент свг иконок
customElements.define('svg-icon', SVGIcon);
Вот и все, в оригинале всего 113 строк кода с комментариями в гите) Какие плюсы мы имеем в итоге?
Веб-компонент не привязан к сборщику, можно просто складировать иконки в папке проекта
Веб-компонент не привязан к фреймворку, будет одинаково хорошо дружить с vue\angular\svelte\react\etc.. любых версий
Из-за первых двух пунктов наш сборщик фронта работает чуточку быстрее.
Веб-компонент не имеет внешних зависимостей в целом.
Буду рад конструктивной критике, код ревью и предложениям по улучшению и оптимизации веб-компонента.