Компактные Vue компоненты из самописных SVG иконок

Kate

Administrator
Команда форума

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


В этой статье пойдёт речь о том, как я пришёл к тому, чтобы взяться писать плагин, создающий на лету 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 />

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


e89e8ebd220b9131b12af5259eb960ab.png



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


То, с чем я сталкивался прежде, например во фреймворке 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.

 
Сверху