Вдарим вольтом по лайвваеру
На днях начал работу над новым проектом, и выдался хороший случай попробовать Livewire и Volt с ним, с последней версией Laravel. Про Livewire в последних версиях Ларавела я раньше слышал и читал, но руки как-то не доходили. А теперь дошли и сейчас мы познакомимся с ним поближе.
Что такое Livewire
Что вообще такое Livewire? Это фреймворк, позволяющий разрабатывать динамичные интерфейсы прямо на PHP. Как гласит главная страница фреймворка:
Powerful, dynamic, front-end UIs without leaving PHP.
То есть «мощные, динамичные фронт-энд интерфейсы, не покидая PHP». Ничего не понятно, но очень интересно. Ну, я немного разобрался (прочитал документацию!) Этот фреймворк позволяет нам, путём вставки специальных директив типа wire:model.live="filter" прямо в blade-шаблон, в html какого-нибудь инпута, сделать этот инпут динамическим. Или, как раньше говорили, его аяксифицировать.
То есть заставить элемент отправлять запросы на сервер без перезагрузки страницы и обновлять соответствующий view по данным в ответе на запрос.
И это — без необходимости писать JavaScript-код, вообще. И хотя мы в примере ниже немного JS-кода напишем, это, строго говоря, действительно необязательно.
Что такое Volt
А что такое Volt? Это API для Livewire, позволяющий разрабатывать однофайловые компоненты, делающие всё то же самое. Внешне это очень походит на компоненты Vue.js, но только — никакого JS. То есть в компоненте содержится серверная логика на PHP и blade-шаблон. Всё в одном файле. Можно наделать динамических компонентов и втыкать их везде.
Описание Вольта гласит, что это функциональный API, то есть PHP-часть будет состоять из функций, в стиле функционального программирования. Но на самом деле для любителей ООП есть возможность реализовать серверную часть в виде класса, и получится то же самое. Но мы не будем.
Вот пример такого компонента из документации:
Это современный стандарт примеров: мы нажимаем на кнопочку + и счётчик в h1 увеличивается. И если мы вставим такой компонент на любую страницу с подключенным Livewire, он преобразуется в динамическую кнопочку. А вставка происходит так: (если файл компонента — counter.blade.php).
На самом деле, меня такие классические примеры немного бесят. Да, они иллюстрируют базовые возможности, они лаконичны, но это блин сферическая кнопка со счетчиком в вакууме. Нафига она нужна? И дальше в документации примеры такие же короткие. Читаешь и все время остается вопрос — а как вот это прикрутить, а где про это прочитать?.. И это одна из причин написать данную статью: разобрать что-то более живое и приближенное к реальности.
К слову, мне кажется, что моя любимая методика TDD сильно страдает из-за таких примитивных примеров в литературе. Типа возьмем функцию, складывающую два числа, и напишем тест, проверяющий, что она их действительно складывает. И чего, кому это надо, делать в два раза больше работы ради такого выдающегося результата?!
Практика
Итак, на этом с прелюдией закончим и попробуем решить практическую задачу, с Livewire и Volt. Также с нами будет CSS-фреймворк Tailwind, поскольку все три перечисленных сущности входят в Laravel Livewire Starter Kit, то есть устанавливаются из коробки при выборе этого стартового набора. Что мы будем делать. Мы возьмем вот такую таблицу:
В ней мы видим некие акции, у них есть названия, даты действия, тип, регионы действия, и какой-то рандомный промокод. Также акции делятся на категории, этого в таблице не видно. И теперь мы сделаем на Вольте компонент, в котором будет заключена эта таблица, с пагинацией, с возможностью фильтровать по категории, типу, регионам, а также искать по названию.
При этом мы хотим чтобы все фильтры попадали в url, чтобы можно было например обновить страницу, или отправить кому-то ссылку.
Приступим. Сделаем файл promotable.blade.php. Вся работа, как вы уже поняли, будет происходить в нём. Я рассмотрю фрагменты файла, а в конце приложу его целиком.
Мы объявили динамические переменные, соответствующие нашему фильтру. Лайвваер будет отслеживать их состояние, обновлять когда из фронта придут соответствующие запросы. Также вызовом ->url() мы указали, что значения этих переменных должны попадать в query-строку url. Также мы инициализировали постраничный вывод, который используем в дальнейшем.
Теперь мы хотим вывести категории в виде кнопок, нажатие на каждую будет фильтровать нашу таблицу, то есть выводить только акции из данной категории.
Вызов computed() позволяет нам объявить вычисляемое свойство $categories, которое вычислится один раз и закэшируется на время жизненного цикла Livewire-запроса. В документации что такое «жизненный цикл Livewire-запроса» нормально не объясняют. В интернетах встречаются намеки, что это мол как генерация страницы, так и последующие обновления пользователем компонента (через выбор разных категорий, например). Однако когда я выбирал разные категории и менял другие фильтры, глядя в debugbar, запрос на выборку всех категорий из БД всегда присутствовал, и ничего закэшировано не было. Впрочем, значение computed-свойства мы можем и сами закэшировать вызовом computed()->persist() на заданное время.
В общем мы здесь в computed-свойство $categories кладем все имеющиеся у нас категории.
Здесь и далее я скрываю tailwind-классы для оформления, чтобы читалось лучше. В полной версии файла они будут.
Далее в части blade-шаблона мы выводим все категории — их названия и иконки — в виде «кнопок», вот так:
Обратите внимание на директиву wire:click="$set('category', {{ $category->id }})". Здесь мы говорим Livewire, что когда на кнопку нажмут, нужно положить в динамическую переменную category (см. state() выше) значение id выбранной категории. Что приведет к отправке соответствующего запроса на сервер, обновлению компонента и появлению ?category=id в url. Вот так, мы уже сделали первый динамический элемент.
Да, по-хорошему вместо этих псевдокнопок стоит вывести категории как радио-кнопки или чекбоксы, с тем же оформлением. Тогда можно будет указать category просто как модель для этого контрола с помощью директивы wire:model. Но тогда мы бы не узнали о волшебном действии $set(), позволяющем отправить запрос на изменение значения по какому-либо событию. А wire:model мы рассмотрим дальше.
Теперь сделаем поиск по названию. Тут всё предельно просто, этот пример можно и в документации увидеть, добавим вот такой инпут:
Здесь мы создаем инпут и говорим что моделью для него является наша динамическая переменная search. Обратите внимание на модификатор .live — он нужен, чтобы по изменению данной переменной сразу происходило обновление компонента. Без этого модификатора Livewire будет ждать пока произойдет «действие» — например, сабмит формы, вызов $set() из примера выше, или взаимодействие с другим динамическим контролом, у которого модификатор .live есть.
Также полезно знать, что у таких .live-моделей есть модификатор .debounce, позволяющий контролировать время задержки между последним нажатием на кнопку и обновлением компонента (чтобы не перегружать компонент слишком часто). По умолчанию дебаунс стоит в 150 миллисекунд, можно поменять его указав wire:model.live.debounce.250ms.
Сделаем фильтр по регионам. Мы хотим чтобы можно было выбрать несколько регионов, чтобы можно было удалять регионы из выбора, и чтобы работал поиск по фрагменту имени региона, потому что регионов в России очень много. Так что дефолтный muptiple select нам точно не подойдет. Поэтому я для этого проекта взял Tom Select — легковесный контрол, не требующий ничего лишнего.
Но его пришлось инициализировать — это и есть тот момент, когда мы напишем немного JavaScript, хотя Livewire и Volt тут ни при чем. Выполним npm i tom-select и добавим в app.js проекта код для инициализации:
Это весь JS, который нам понадобится. Как я уже говорил, это необязательно. Ведь для Livewire есть готовая библиотека компонентов Flux, которая тоже входит в starter kit. Среди компонентов есть и мультиселект, и поиск, и таблица. Выглядит всё очень прилично, однако когда начинаешь подбирать компоненты, быстро выясняется, что всё клевое и заслуживающее внимания — в Pro-версии, которая стоит 150 баксов за проект, или 300 за бесконечную лицензию. Мне покупать не захотелось, тут в общем-то можно и без флакса обойтись.
Теперь вернемся к нашему компоненту и сделаем сам фильтр по регионам:
Регионы реализованы через отношения Many to Many с моделями промоакций. Здесь мы загружаем их все, берем только название и id — больше нам ничего не надо. И выводим в качестве опций обычного множественного селекта, который при загрузке будет преобразован в Tom Select.
Обратите внимание на wire:ignore. Директива нужна, чтобы запретить Livewire перезаписывать этот кусочек HTML-кода при обновлении компонента. Иначе нам пришлось бы писать код, переинициализирующий Tom Select после каждого обновления. А так — не придётся.
Фильтр по типу совсем простой, у него даже серверной части нет, этой разметки с директивой wire:model.live достаточно:
Здесь тоже используется Tom Select, просто потому что он уже есть, ну и для единообразия. И, соответственно, тоже используется wire:ignore.
Всё, мы описали динамические свойства нашего фильтра и теперь можем собственно перейти к выводу данных с учетом значений фильтра. Добавим следующий код:
Это для получения отфильтрованной и пагинированой выборки промоакций.
Первое, что бросается в глаза лично мне — это длина функции загрузки промоакций, и обилие деталей загрузки в ней. Нетрудно представить, что при дальнейшем развитии компонента, он разрастется еще сильнее и превратится в нагромождение запросов к БД и условных операторов. Что усложнит поддержку компонента, и в целом приведет нас к размышлениям — а нафига нам такой Вольт вообще. Не проще ли спрятать это хотя бы в контроллер. И это правильные мысли, я считаю — при создании таких компонентов нужно обязательно делать рефакторинг, и использовать репозиторий, или хотя бы скоупы. Ну, типа
или вообще
Но здесь для наглядности и простоты я оставил как есть.
Также заметьте как используется переменная $all_regions_region в коде. Дело в том, что регион «Все регионы» означает, что акция действует везде. Соответственно, мы такую акцию показываем независимо от того, какой регион выбран в фильтре. Поэтому так.
А вот разметка самой таблицы:
В самом конце разметки вы увидите {{ $this->promos->links() }} — это вывод пагинатора, про который целая отдельная страница в документации есть.
Полный код примера
Как обещал, код всего компонента целиком, со всеми стилями:
А вот короткое видео с демонстрацией результата:
Практические выводы
1. Как мы видим, модели, функции типа computed() и т.п. действительно очень напоминают компоненты Vue.js. В документации можно найти и mount(), да и сами директивы с модификаторами на Vue очень похожи. И мне это нравится, поскольку мне нравится Vue.
2. При разработке компонентов Volt нужно внимательно рефакторить, использовать репозитории или хотя бы скоупы, иначе компонент легко превратится в спагетти из запросов в БД, условий, функций и прочего, и поддерживать его будет непросто.
3. На первый взгляд кажется, что такое решение на длинной дистанции должно стать немножко деревянным: у нас обязательно возникнут какие-нибудь краевые случаи, которые фреймворк не осилит и придётся подставлять костыли. Но в краевых случаях можно и JavaScript написать, никто не запрещает. Да и глядя на документацию фреймворка кажется, костылей не должно быть слишком много.
4. Фреймворк Tailwind мне кстати очень понравился. Да, я с ним особо не работал, сталкивался конечно, но в проектах «с нуля» обычно для простоты и быстроты брал бутстрап. Теперь буду брать Тейлвинд тоже, просто потому что вайб от него очень приятный при работе, и сайт круто сделан. И иишечка неплохо понимает стили тейлвинда, на лету украшает таблицы и генерирует различные гриды.
5. Кстати, ИИ в работе с Livewire тоже уже наблатыкался, и неплохо помогает. Но не на 100%, пару раз он мне компонент поломал так, что пришлось откатывать промптов пять. Да, вот этот компонент, из примера. Но во всяком случае он понимает директивы Livewire и структура компонента Volt его в ступор не вводит, предложения на таб-таб-таб поступают исправно.
6. В дальнейшем имеет смысл разделить фильтр и таблицу на два компонента, как раз чтобы не перегружать каждый раз список регионов и категорий, поскольку он не меняется. Для этого есть дочерние компоненты, но с вашего позволения мы посмотрим на реактивность и отношения между компонентами в другой раз.
7. Главное: когда нам нужен какой-то веб-интерактив, мы пишем контроллер на сервере, разметку для фронта и JS для реализации динамики на фронте. Livewire и Volt действительно позволяют упростить этот процесс, полностью исключив третью часть: JS для динамики писать больше не надо. И это очень неплохо.
Пара слов о тестировании
Laravel как известно очень дружелюбный к любителям тестов язык. И Вольт тут не обошли стороной, хотя документация по тестированию компонентов умещается в один экран (у Livewire побольше конечно). Тем не менее, можно тестировать отдельный компонент, можно проверять, есть ли компонент в ответе на get()-запрос, можно обращаться к функциям компонента и делать ассерты относительно результата.
Но. Лично я стараюсь не тестировать в бэкенде то, что является:
а) функционалом библиотеки — поскольку этот функционал уже оттестирован в составе собственно библиотеки;
б) визуальной частью — поскольку она очень изменчива и тесты для нее сложно поддерживать, проще проверить глазами.
А компоненты Livewire и Volt это по сути и то, и другое. Поэтому в первом приближении, применительно к разработанной выше таблице, для меня достаточно протестировать, что таблица выводится и каждый из фильтров работает. Что можно сделать без участия тестового API Livewire и Volt, обычными GET-запросами. Поскольку каждый фильтр у нас взаимодействует с url, мы можем тестировать вот так:
и будет работать как если бы за роутом скрывался обычный контроллер. А стоит или нет использовать Livewire::test() и тестировать саму реактивность — вопрос дискуссионный.
Заключение
Если нужно полномасштабное SPA (single page application, одностраничное приложение), чтобы был state management типа vuex, асинхронные запросы и всё такое, я бы уходить в Livewire не стал.
У меня есть пара проектов с фронтендом полностью на Vue, где на страницах может быть с пяток или больше независимых динамических компонентов, и управление состоянием, и echo, и всякие draggable… Мне пока сложно представить это всё в компонентах Volt и блейд-темплейтах с Livewire. И навскидку кажется что с увеличением сложности приложения компоненты для Livewire будут усложняться, и нужно будет всё больше бороться с фреймворком, пока в конце концов отдельный фронт на Vue не покажется привлекательнее.
Тем не менее, если мы не делаем сложное SPA, если у нас несколько отдельных компонентов, которым нужен интерактив, если мы просто хотим отправлять формы динамически, и всё — Livewire+Volt вполне живой, быстрый и главное удобный вариант. Cкажем, вариант представляется удачным для обычного сайта-каталога, где нужен отдельный интерактивный калькулятор, или динамический подбор товаров по фильтрам.
В общем, я этот фремворк обязательно ещё раз использую, как только появится следующая возможность.