История вопроса


В этой статье пойдёт речь о том, как я пришёл к тому, чтобы взяться писать плагин, создающий на лету vue компоненты из самописных svg иконок во время сборки проекта, о том, как я это делал, и о том, что в итоге получилось.


Мне уже давно и прочно нравится мир Vue. Особенно завораживает скорость, с которой в нём рождаются новые возможности писать код более лёгким и понятным. Недавно появились Composition API, VueUse, Vite… По ходу освоения этих новых инструментов я нашёл шаблон Vitesse, буквально насыщенный удобными средствами — и для управления макетами (layout), и для маршрутизации, и для локализации и ещё для много чего… Возможно, есть смысл написать отдельный обзор этого арсенала по русски (чего в Интернете пока ещё нет). Но сейчас речь не об этом.


Среди интересных вещей из Vitesse есть плагин unplugin-icons, который позволяет вставлять на страницу, в качестве vue компонентов, иконки из популярных наборов, таких как Font Awesome, Material Design Icons и других. Он работает через сервис Iconify, объединяющий более 90 наборов с 100 000+ иконками. Этот плагин помогает создать компонент, имя которого образует название набора + название иконки. Например такой:


<mdi-dog-side />

Регистрировать его не нужно. Просто в правильном месте пишем в угловых скобках правильное имя и получаем на странице желаемую картинку




Всю прочую работу по внедрению нужной пиктограммы из Интернета в нашу сборку плагин выполняет сам.


То, с чем я сталкивался прежде, например во фреймворке Quasar, выглядело не так элегантно:


<q-icon name="mdiDogSide" />

Но эти мысли приходят уже после знакомства с unplugin-icons. Раньше это как-то не осознавалось. Просто не встречался подход, при котором формирование компонента картинки переносится с этапа монтирования Vue приложения на этап его сборки. Именно во время сборки этот плагин подготавливает под капотом и связывание, и регистрацию, опущенные в коде.


Кому-то может быть покажется, что этим нарушается идеология компонентного подхода. Это правда. Код начинает жёстко зависеть от сборки. Но, с другой стороны, файловые компоненты Vue так или иначе должны обрабатываться сборщиком.


Что же касается удобства работы с кодом, то можно сказать, что явно прописанные импорты и регистрация компонентов удобны для анализа связей, особенно в чужом коде, когда с помощью средств IDE мы можем легко переходить к определению какой-либо сущности.


Но это обычно критично для компонентов, которые разрабатываются внутри нашей кодовой базы. Код компонентов, которые пришли со стороны и которые мы заведомо модифицировать не будем, можно рассматривать, как чёрный ящик, происхождение и функциональность которого понятны, но содержимое которого нас не интересует. В этом случае конструкции импорта и регистрации только усложняют чтение кода, не добавляя в него полезного содержания.


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


В общем, unplugin-icons я для себя оценил. Одно плохо: не всегда даже среди 100 000+ иконок можно найти нужную, если приложение касается какой-то специфичной предметной области, или предполагает нестандартный набор действий пользователя. Допустим, нужна пиктограмма, обозначающая заключение договора. Такую готовую можно найти без проблем. Но если вы работаете с разными типами договоров, и каждый тип хотелось бы представить своей графикой, то вам не обойтись без собственных художеств. Придётся рисовать / заказывать дизайнеру свои самописные иконки. А с ними-то этот плагин и не работает.


Идея


Я стал разбираться с тем, что у unplugin-icons под капотом, надеясь найти способ как-то всё-таки "подсунуть" ему самописные иконки, и выяснил при этом интересную вещь. Плагин unplugin-icons для создания компонента иконки использует другой инструмент — плагин unplugin-vue-components. Этот инструмент в процессе сборки перехватывает имена всех незарегистрированных компонентов и пытается с ними разобраться. Для этого он использует внешние функции-резолверы. Резолверы регистрируются в этом плагине в конфигурационном файле сборщика.


Получается что у unplugin-vue-components есть массив, в котором прописаны все функции-резолверы, задача которых — каждый раз распознавать и перехватывать из набора незарегистрированных компонентов те, за которые отвечает именно этот резолвер, и после возвращать ссылку на файл с кодом, который соответствует перехваченному имени компонента. Если имя компонента не распознаётся данным резолвером, он возвращает null. Плагин в процессе работы последовательно скармливает резолверам имена незарегистрированных компонентов, и ждёт от них ссылки на файл, чтобы дописать в код импорт и регистрацию и отдать для дальнейшей обычной сборки. Если резолвер возвращает null, плагин отдаёт это имя следующему резолверу. Если все резолверы вернули null, плагин никаких действий не предпринимает, т.к., возможно это имя веб компонента, который зарегистрирован глобально.


Я понял, что для моей задачи это находка. Нужно просто написать резолвер, который будет принимать имена и проверять, не попалось ли среди них имя, которое соответствует какой-либо самописной svg иконке. Если попалось, нужно вытащить из кода этой иконки тег svg со всем содержимым (кроме svg тега там могут быть и заголовки вида <DOCTYPE ... > и/или <?xml ... ?> — в inline svg они не вписываются), обернуть тегом <template> и сохранить в файл с именем, соответствующим имени компонента и с расширением .vue в служебной папке, после чего вернуть ссылку на этот файл.


То есть, я решил налету с помощью резолвера создавать из svg иконок vue компоненты, состоящие из одного только шаблона без скрипта и стилей.
Такой компонент не может иметь своих свойств, но может управляться с помощью css, о чём подробнее будет сказано ниже.


Я решил оформить резолвер в виде npm пакета, который должен содержать package.json, js файл с функцией-резолвером, файл деклараций TypeScript, readme и папку с примерами под разные варианты сборки для Vue 2 и для Vue 3. Резолвер не зависит от версии Vue, но плагин unplugin-vue-components в конфигурационном файле для разных версий подтягивается по-разному.


Также с самого начала было понятно, что функцию-резолвер, чтобы быть спокойным и не пропустить какой-нибудь досадный ляп, придётся тестировать под разные варианты сборки, и нужны автотесты, которые будут собирать контрольные примеры и проверять, подтянулись ли туда компоненты из svg иконок, или нет.


Набор контрольных тестов должен охватывать критичные с точки зрения возможных багов сочетания параметров резолвера и типов записи имён компонентов и иконок (PascalCase, camelCase или kebab-case).


Контрольные примеры для тестов я поначалу решил сделать по совместительству и демонстрационными. Такое решение, как мне стало ясно только потом, оказалось ошибочным. В итоге кроликов на витрине и их подопытных собратьев пришлось разделить, о чём расскажу ниже.


У моего резолвера два параметра — путь к папке, где нужно искать иконки (в перспективе здесь можно будет указывать и массив), и префикс в имени компонента. Мне представилось логичным, чтобы использование префикса было таким же, как и у плагина unplugin-icons, то есть по умолчанию его значение равно i, а если префикс не используется, то нужно явным образом для его значения указать пустую строку.


Общая картина его работы следующая:


Vue custom component-icon examples


С самого начала я делал модуль только под сборщик Vite, поскольку когда я приступил к работе, модуль unplugin-vue-components звался vite-plugin-components (сейчас этот пакет удалён из npm хранилища и переименован на github) и умел он работать только с Vite. Но в процессе написания я вдруг обнаружил, что этот модуль не только переименован, но и стал кроме Vite поддерживать и Rollup, и Webpack, и Nuxt, и Vue CLI.


Так оказалось, что стоя на плечах Anthony Fu, разработчика Vitesse и всех его плюшек, я тоже оказываюсь молодцом, и пишу пакет, который можно использовать под любой из этих систем сборки, поскольку кроме unplugin-vue-components, который теперь встраивается почти куда угодно, ему ничего другого не требуется.


Воплощение


Я решил, что функция резолвер слишком проста и мала, чтобы затевать сборку самого модуля. Поэтому отказался и от Typescript, и от каких-либо сторонних зависимостей в ней. Из вспомогательных алгоритмов требуется лишь перевод имен из PascalCase в camelCase и kebab-case, и из kebab-case обратно в PascalCase. С этим хорошо справилась бы библиотека change-case, но я решил, что три самописных функции по десятку строк кода — не слишком дорогая цена за то, чтобы отказаться от сборки.


Настройка тестирования оказалась на порядок более трудоёмким делом, чем написание резолвера. Поскольку проверяется работа сборки, включающей тестируемый резолвер, необходимо перед тестами каждый раз возиться с переустановкой зависимостей для каждого тестируемого проекта. Далее, необходимо каждый проект собрать. И уже только после этого можно проверять, правильно ли он собрался.


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


Вся подготовка для теста — установка зависимостей и сборка была реализована в виде bash скрипта. Этот скрипт читает список папок каталога, где лежат тестируемые проекты, в цикле последовательно заходит в каждую из этих папок и делает там свою работу.


В качестве среды тестирования я использовал Jest, а для монтирования страницы JSDOM. Код теста, также как и код bash скрипта, сканирует папку с тестируемыми проектами, и в каждом найденном проекте ищет итоговый html. В нём в теге <script> подтягивается результат сборки. У меня сложилось впечатление, что JSDOM не умеет подтягивать скрипты, если они объявлены, как модули. По крайней мере, я не нашёл, как это сделать. Поэтому в ходе выполнения теста из html файла приходится удалять атрибут type="module" а также заодно бесполезный в тестовых примерах crossorigin из тега <script>.


То есть заменять


<script type="module" crossorigin src="/bundle.js"></script>

на


<script src="./bundle.js" defer></script>

На деле тест просто полностью переписывает html файл из заготовленного шаблона, благо этот файл у всех тестовых проектов получается одинаковый.


После этого подопытный html скармливается JSDOM. Я не придумал и не нашёл хук, по которому можно отследить завершение монтирования, поэтому поставил простой setTimeout() на 100 мс. Это время намного превышает время реального монтирования, которое на моём компьютере составляет единицы миллисекунд. Здесь конечно происходит некоторое неоправданное торможение, но оно несопоставимо меньше времени, которое уходит на сборку (около 30 с для каждого тестового проекта).


И наконец, производится простая проверка на наличие в документе ровно двух элементов svg. Разумеется, сами тестовые проекты должны отвечать условию — содержать ровно две иконки, возможно, подключаемые разным путём. Мне показалось, что этого вполне достаточно, чтобы определить, попали иконки в сборку, или нет.


Чтобы подготовка и запуск тестов производились одной стандартной командой, запуск тестирования я добавил в конец bash скрипта. А в начало этого скрипта я добавил запуск линтинга всего пакета.


Что же касается использования демонстрационных примеров в качестве тестируемых, то в таком решении кроется неувязка. Чтобы оперативно тестировать изменения в коде, в файле package.json тестового примера разрабатываемый пакет должен подтягиваться из рабочего каталога — корневой папки всего проекта:


  "devDependencies": {
    "custom-icons-resolver": "file:../../..",
    ...
  }

Но такую запись нельзя приводить в качестве примера использования пакета. В примере пакет должен подтягиваться из хранилища:


  "devDependencies": {
    "custom-icons-resolver": "^1.0.10",
    ...
  }

Поэтому я в итоге перенёс все тестовые проекты в отдельную папку, а в папке с примерами оставил два проекта на Vue 2 и на Vue 3 под Vite.


Что получилось


Получился модуль, выполняющий свою функцию, окружённый инфраструктурой с системой тестирования, линтинга, и примерами использования. Выкладывать целиком всю эту кучу ненужного на пользовательском проекте кода в npm хранилище было бы большим злодейством. Поэтому в корневом package.json определено поле files


  "files": [
    "index.js",
    "index.d.ts",
    "LICENSE"
  ],

Из всей массы кода пользователь в свой проект подтянет только необходимое — то, что перечислено в списке, и ещё package.json и readme.md, которые всегда подтягиваются по умолчанию.


Подготовка иконок


Поскольку речь идёт о самописных иконках, мы можем, не вмешиваясь в графику, модифицировать их техническую часть так, чтобы оптимизировать размер и, если нужно, заставить отрабатывать CSS стилизацию.


Размер SVG документы часто бывает неоправданно большим. Это получается из-за того, что редакторы векторной графики явно прописывают в них все заданные по умолчанию свойства каждого элемента. Для каждого элемента прописывается id, который нигде не используется. Создаются блоки с метаинформацией, которая нужна редактору, но совсем не нужна для inline SVG.


Удалять вручную весь этот мусор — дело рутинное и долгое. Лучше воспользоваться специальным инструментом, таким, например, как svgo.


Если мы хотим показать картинку как есть, с ней можно больше ничего не делать, но если нам нужно играть размером / цветом, то нам нужно её немного доработать.


Чтобы у нас появилась возможность управлять размером, нужно значение атрибутов width и height в тэге svg установить равным 1em.


<svg xmlns="http://www.w3.org/2000/svg" ... width="1em" height="1em" ...>

Размер иконки после этого будет управляться свойством font-size.


Для обеспечения управления цветом монохромных иконок (рецепт только для монохромных), нужно выполнить следующее:


  1. Избавится от линий ненулевой толщины. Для этого необходимо преобразовать их в фигуры (shape). Это проще всего сделать в каком-либо редакторе векторной графики, например, в Inkscape.
  2. Удалить из документа все указания на заливку, как в атрибутах fill (вместе с этими атрибутами), так и внутри атрибутов style. В большинстве случаев style также можно спокойно удалить целиком (вряд ли в иконке потребуется информация о шрифтах и т.п.).
  3. В элементе svg задать атрибут fill="currentColor":

<svg xmlns="http://www.w3.org/2000/svg" ... fill="currentColor" ...>

После этих преобразований можно управлять цветом компонента иконки с помощью свойства color.


Выводы


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


Надеюсь, что статья кому-то принесёт пользу и как повод для знакомства с новыми событиями в мире Vue, и как обзор по написанию резолвера под unplugin-vue-components, что может потребоваться для каких-то других целей. И, наконец, надеюсь, что кто-то с подачи этой статьи станет использовать в деле и сам плод моего труд — пакет custom-icons-resolver.


Немного личного


Это моя первая серьёзная публикация на Хабре. Буду признателен всем местным жителям за конструктивную критику и советы.


И ещё. Я уже писал, что часть моего сердца принадлежит миру Vue. Хочу найти работу в команде, где тоже любят мир Vue, и бережно за ним ухаживают. Мой уровень отчасти виден по данной работе. Думаю, что могу быть полезным игроком в команде.


Мои контакты можно найти в моём профиле.

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


  1. Rsa97
    10.09.2021 15:23
    +2

    <q-icon :name="mdiDogSide" />

    С виду, вроде почти то же самое, но нужно обратить внимание на то, что :name — это реактивное свойство, и потому оно будет кушать ресурсы.
    То, что вы написали — это иконка с именем из переменной mdiDogSide.
    Просто иконка с именем mdiDogSide записывается как
    <q-icon name="mdiDogSide" />
    .


    1. YuriyBakutin Автор
      10.09.2021 15:52

      Всё верно. Спасибо. Убрал этот абзац.


  1. gmtd
    12.09.2021 09:12

    Много текста, но так и не понял, чем подход кастомных иконок лучше <q-icon name="mdiDogSide" /> (в моем случае, самописный <base-icon> с font-size, color и другими няшками вроде умения работать со спрайтами)

    Зачем плодить лишние сущности компоненты?

    Придет новый программер
    Увидит компонент
    Начнет искать его и увидит сложную генерацию
    Тихо заматерится, на "прозрачный и красивый код"


    1. YuriyBakutin Автор
      12.09.2021 10:23

      Основная идея в том, что программер компонент искать не будет. Зачем программеру искать компонент svg-картинку, просто статически встраиваемую в html? Этого компонента в исходном коде просто нет. Он создаётся на этапе сборки.

      Эта идея работает не только для картинок и принадлежит Anony Fu, мастеру, входящему в ядро разработчиков Vue. Она позволяет делать код более лаконичным, опуская очевидные импорты и регистрации. Советую посмотреть шаблон https://github.com/antfu/vitesse. Он претендует стать легковесной заменой Nuxt. В нём много чего реализовано с помощью такого подхода.

      Согласен, что это подход на любителя, и многим он может не понравится. Но думаю, что найдётся и много его сторонников.


      1. gmtd
        12.09.2021 10:31

        У вас в шаблоне новый тэг

        Это и есть новый web component

        Пришедший программер начнет его искать и не найдет

        Свой base-icon - 30 строчек ясного кода и переиспользуемый! для производительности при надобности Vue компонент

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


        1. YuriyBakutin Автор
          12.09.2021 10:55

          Если программер знаком с тем, что на данном проекте не нужно искать компоненты с определённым префиксом, то он и не будет их искать.

          >Ваш подход - необоснованное закулисывание очевидной логики, усложнение билда, усложнение читаемости кода

          Понимаю, что для кого-то это выглядит именно так. На мой взгляд, это вопрос предпочтений. Тот факт, что серьёзные люди в ядре разработчиков Vue продвигают этот подход говорит о том, что есть и те, для кого такой подход выглядит логичным и красивым. Не вижу смысл вести холивар по этому вопросу.


          1. gmtd
            12.09.2021 12:50

            Это два подхода, но намного более важные, чем кажется

            Первый отражает концепции абстракции и инкапсуляции ООП

            base-icon - это класс. С определенными свойствами и методами. Иконка - свойство.

            Вы же вместо создания объектов одного класса создаете множество классов

            Классы, абстрагирование, инкапсуляция нужны на уровне программиста, а не компилятора или сборщика. Именно чтобы код был "прозрачный и красивый"

            И Vue.js возможно более других фронтэнд фреймворков старается придерживаться ООП парадигмы


            1. YuriyBakutin Автор
              12.09.2021 13:31

              Я Вас понимаю, но не согласен. Ещё раз хочу сказать, что это не я реализую такой подход. Его реализуют люди из ядра разработчиков Vue при поддержке Эвана Ю. Мне лишь понравился такой подход. Спорить смысла не вижу. Ваша точка зрения имеет право на жизнь.


            1. YuriyBakutin Автор
              12.09.2021 14:59

              При классическом подходе кодовая база самодостаточна и не зависит от сборки. При новом подходе (Unload) это не так - получается что сборка определяет, как код должен интерпретироваться в каждом конкретном проекте, и эти соглашения практически выносятся за рамки основной кодовой базы. Для кого-то такой подход выглядит неприемлемым. Мне видится, что здесь достаточно дополнительных соглашений.

              Код — это некий язык описания поведения. В обычном языке есть какие-то вещи, которые разумеются сами собой, и мы можем их опускать в речи, потому что есть некие договорённости за рамками этой речи. То же самое реализует подход Unload. При анализе кода необходимо знать соглашения за рамками основной кодовой базы, реализованные в сборке.

              Если же говорить о классах, то когда у нас в шаблоне компонента нет переменных, мы получаем вырожденный случай, в котором в нашем классе нет никаких свойств, а есть только неявно вызываемый метод render(), который просто напрямую встроит шаблон, в DOM, как html элемент. И высокие слова о инкапсуляции, свойствах и методах оказываются здесь формальностью.

              По поводу множества классов — непонятно, чем это так плохо. В случае, когда класс один, и он простой без дополнительных, кроме name свойств, в итоге в DOM попадёт тот же элемент svg, только при монтировании будет искаться соответствующий код, и уже на его основе формироваться шаблон. При Unload подходе эти движения не нужны, так как выполнены на этапе сборки, и шаблон уже готов. Мне видится, что это более оптимальный вариант с точки зрения времени выполнения.

              С точки зрения удобства чтения кода, для плагина unplugin-icons есть расширение под VS Code https://marketplace.visualstudio.com/items?itemName=antfu.iconify, которое прямо в коде рисует иконку, которую внедряет данный компонент. Думаю, после этого программеру не составит труда разобраться, к чему тут этот фрагмент кода.

              Под мой плагин пока такого расширения нет. Я только приступил к его написанию.


  1. Exclipt
    12.09.2021 23:18

    Относительно реализации решения, описанного в статье, выскажусь - выглядит довольно красиво. Относительно продакшен кода - будьте готовы, что у вас будут гореть уши. У меня прямо сейчас есть два проекта, где фронтэнд на Vue не имеет документации, и моей команде приходится его до/перерабатывать, как думаете, каким словом вспомнят вас, если в третьем проекте такого плана появятся ваши иконки?


    1. YuriyBakutin Автор
      13.09.2021 12:49

      Я задумывал этот инструмент в качестве органического дополнения в шаблон https://github.com/antfu/vitesse. Для тех, кто этот шаблон уже использует (разве что в стартапе, поскольку этот шаблон ещё совсем тёпленький) подключить его в работу сложности не составит, поскольку он работает по аналогии с уже имеющимся в шаблоне инструментом https://github.com/antfu/unplugin-icons. В отрыве от упомянутого шаблона использование моего инструмента, мне представляется нелогичным.