Всем привет! Сегодня мы рассмотрим один из вариантов интеграции svg иконок в наш фронтенд проект используя веб-компоненты. Основная идея компонента заключается в том, чтобы лениво подгружать в SVG спрайт иконки и переиспользовать уже загруженные иконки при необходимости. Сами иконки будем вставлять в разметке в виде <svg-icon name="arrow-angle-down"> нам понадобится всего сотня строк кода! Кому интересна реализация, прошу под кат!


Для тех, кому лень читать и хочется сразу посмотреть на весь листинг кода - прошу в репозиторий svg-icon. Для начала просто проговорим логику работы веб-компонента:

  1. Получаем атрибут name у компонента и проверяем его наличие в уже существующем спрайте иконок на странице

  2. Если такой иконки нету, то проверяем не загружается ли эта иконка в данный момент

  3. Если такая иконка не загружается, загружаем ее и добавляем в спрайт.

Все достаточно просто. Для начала добавим в наш шаблон проекта пустой спрайт

<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 строк кода с комментариями в гите) Какие плюсы мы имеем в итоге?

  1. Веб-компонент не привязан к сборщику, можно просто складировать иконки в папке проекта

  2. Веб-компонент не привязан к фреймворку, будет одинаково хорошо дружить с vue\angular\svelte\react\etc.. любых версий

  3. Из-за первых двух пунктов наш сборщик фронта работает чуточку быстрее.

  4. Веб-компонент не имеет внешних зависимостей в целом.

Буду рад конструктивной критике, код ревью и предложениям по улучшению и оптимизации веб-компонента.

Комментарии (21)


  1. SuperCat911
    06.06.2024 08:53
    +1

    Спасибо, на самом деле очень интересно! Нравится мне native-way.

    Можете разъяснить: по сути ваша реализация позволяет заменить <img src="images/image_name.svg"> на <svg-icon name="image_name">? Какие-то еще фишки можно сделать?


    1. strokoff Автор
      06.06.2024 08:53

      Да, все верно, можем заменить img src=icon_name на svg-icon name=icon-name по поводу фишек - какие вы бы хотели видеть? пишите, я подумаю чем смогу помочь и можно ли это реализовать.


      1. SuperCat911
        06.06.2024 08:53

        Честно говоря я справляюсь теми инструментами, что использую. Но поговорим об идеях :)

        Вот попадались такие кейсы как особое отображение svg-иконки, если темная тема. Можно динамически менять тело svg или стили при смене цветового режима.

        Еще одну идею дам. При описании в верстке svg-иконок приходится указывать атрибуты width, height, а также viewbox. Это бойлерплейт. Через веб-компоненты можно решить это неудобство.


  1. kellas
    06.06.2024 08:53
    +1

    Смотрите, как бы это парадоксально не казалось на первый взгляд, но inline вставка svg работает шустрее чем использование спрайтов - https://cloudfour.com/thinks/svg-icon-stress-test/

    Еще к минусам svg-спрайтов можно отнести, то что через css не получается цвета svg менять

    Так что можно добавить атрибут который указывает использовать спрайт или вставлять inline


    1. strokoff Автор
      06.06.2024 08:53

      Работает быстрее, только есть и минусы у подхода к инклудами

      1. Вы привязаны к шаблонизатору и необходимо использовать доп. инструменты сборки

      2. Время сборки проекта становится чуть медленее из-на инклудов

      3. Общий вес страницы становится больше т.к. иконки не переиспользуются. На большом количестве иконок разница в размере страницы будет очень существенной

      Не тот кейс, где можно как-то просесть в производительности, если только вы не пишите сайт сток для свг иконок

      Так что можно добавить атрибут который указывает использовать спрайт или вставлять inline

      Здравая идея, возможно обновлю контент немного позже.

      Еще к минусам svg-спрайтов можно отнести, то что через css не получается цвета svg менять

      Какое невежство таких вещей не знать, но все работает отлично. Попробуйте стиль в CSS применить svg-icon svg { fill: ff00ff; } и лично убедиться, что все работает и цвет меняется отлично.


      1. kellas
        06.06.2024 08:53

        Какое невежство таких вещей не знать, но все работает отлично. Попробуйте стиль в CSS применить svg-icon svg { fill: ff00ff; } и лично убедиться

        Попробовал и лично убедился что не работает - потому что спрайт - потому что там тег use в котором уже shadow dom(к которому доступа нет из css ) и только потом целевая svg


        поэтому нужно inline вставлять а не через спрайт


        1. strokoff Автор
          06.06.2024 08:53

          я для вас демку на гитхабе сделал, запустите и проверьте

          Как видите все перекрасилось


          1. kellas
            06.06.2024 08:53

            Спасибо! Интересное поведение, да, цвета меняются. Но вот если в самом SVG атрибут fill у path прописан то его не получается переопределить через css в случае если SVG вставлено через спрайт, то есть картинку надо ещё немного подготовить после экспорта из фигмы например, стереть все fill из SVG.

            При этом если вставлять inline - то не важно что внутри картинки задан атрибут fill , css его переопределяет


    1. SuperCat911
      06.06.2024 08:53

      Ссылка на материал годная. Однако, мы видимо по-разному считываем графики. Чем меньше диаграмма, тем лучше, разве нет? Длина столбца - это время загрузки svg.

      В этом случае inline-svg - худшее решение, потому что столбец самый длинный. А юзать тег img - лучшее.


      1. kellas
        06.06.2024 08:53

        В статье ссылка на страничку с тестом https://svg-icon-stress-test.netlify.app/

        там есть и другие методы вставки картинок можете изучить


        я так понимаю в случае с inline браузер сам что-то оптимизирует лучше спрайта , тут вот честно хз как это работает в деталях , сам удивился когда узнал


        1. strokoff Автор
          06.06.2024 08:53

          Я бы сказал, что результаты примерно идентичны и можно получить и обратные значения. На телефоне аналогично, иногда вариант symbol sprite срабатывает побыстрее чем предыдущий тест с inline svg. Спрайт будет выигрывать по общему весу страницы, а также за счет того, что иконка загружается по хуку connectionCallback т.е. уже после отображения первичного контента страницы, что в теории тоже на какие-то копейки улучшает FCP параметр. Но это все крохоборство в целом, хотя на тесте в 100000 иконок разница в пользу инлайна более ощутима, но таких кейсов в жизни на 100к иконок я не встречал


          1. SuperCat911
            06.06.2024 08:53

            А почему так скромно? Надо по 100_000 ставить. И самое смешное, что Inline SVG и External Image примерно одинаковые. Походу исследование уже не очень актуально :)


          1. kellas
            06.06.2024 08:53

            без спрайта достаточно будет только тега веб-компонента - и не вставлять этот div еще отдельный


            1. strokoff Автор
              06.06.2024 08:53

              так основной смысл данного подхода из статьи как раз в спрайте и отсутствии повторных запросов по сети. Если вам это не нравится или не подходит, можно просто использовать img тег) ну или инклудить средствами сборщика\шаблонизатора свгшки


          1. kellas
            06.06.2024 08:53

            вы можете выложить рабочий код компонента куда-нибудь типа codepen?

            он так просто не пашет ... вот это else кажется лишнее - https://github.com/webislife/svg-icon/blob/main/svg-icon.ts#L65


            1. strokoff Автор
              06.06.2024 08:53

              на гитхаб же дал ссылку в статье, или вам нужна javascript версия?


              1. kellas
                06.06.2024 08:53

                нужна была рабочая версия ) в общем я уже поправил и проверил

                https://replit.com/join/hkvuzlpdcd-alexstep2


                1. strokoff Автор
                  06.06.2024 08:53
                  +1

                  https://github.com/webislife/svg-icon добавил vite и демку, пофиксил, а также покрасил иконки через CSS разные цвета специально для вас


  1. kellas
    06.06.2024 08:53
    +2

    А вообще вам бы пойти поучить современные фреймворки, а не заниматься велосипедостроением, там эта проблема давно решена :D


    1. strokoff Автор
      06.06.2024 08:53

      SVG иконки на проекте не являются какой-то проблемой, а также сами веб-компоненты вполне современный подход на который все больше обращают внимание крупные компании. Как раз в плюсах у компонента то, что он способен пережить множество версий фреймворков и уже это успешно делают. У одного только реакта за время существования веб-компонентов прошло десяток релизов, а этот веб-компонент в целом оставался бы неизменным последние 5 лет точно и спокойно переживал изменения сборщиков webpack > rollup > vite


    1. SuperCat911
      06.06.2024 08:53

      Это решается бандлерами в первую очередь. Фреймворки тут рядом просто.