Вдарим вольтом по лайвваеру

Вдарим вольтом по лайвваеру

На днях начал работу над новым проектом, и выдался хороший случай попробовать 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-часть будет состоять из функций, в стиле функционального программирования. Но на самом деле для любителей ООП есть возможность реализовать серверную часть в виде класса, и получится то же самое. Но мы не будем.

Вот пример такого компонента из документации:

<?php use function Livewire\Volt\{state}; state(['count' => 0]); $increment = fn () => $this->count++; ?> <div> <h1>{{ $count }}</h1> <button wire:click="increment">+</button> </div>

Это современный стандарт примеров: мы нажимаем на кнопочку + и счётчик в h1 увеличивается. И если мы вставим такой компонент на любую страницу с подключенным Livewire, он преобразуется в динамическую кнопочку. А вставка происходит так: (если файл компонента — counter.blade.php).

На самом деле, меня такие классические примеры немного бесят. Да, они иллюстрируют базовые возможности, они лаконичны, но это блин сферическая кнопка со счетчиком в вакууме. Нафига она нужна? И дальше в документации примеры такие же короткие. Читаешь и все время остается вопрос — а как вот это прикрутить, а где про это прочитать?.. И это одна из причин написать данную статью: разобрать что-то более живое и приближенное к реальности.

К слову, мне кажется, что моя любимая методика TDD сильно страдает из-за таких примитивных примеров в литературе. Типа возьмем функцию, складывающую два числа, и напишем тест, проверяющий, что она их действительно складывает. И чего, кому это надо, делать в два раза больше работы ради такого выдающегося результата?!

Практика

Итак, на этом с прелюдией закончим и попробуем решить практическую задачу, с Livewire и Volt. Также с нами будет CSS-фреймворк Tailwind, поскольку все три перечисленных сущности входят в Laravel Livewire Starter Kit, то есть устанавливаются из коробки при выборе этого стартового набора. Что мы будем делать. Мы возьмем вот такую таблицу:

Вдарим вольтом по лайвваеру

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

При этом мы хотим чтобы все фильтры попадали в url, чтобы можно было например обновить страницу, или отправить кому-то ссылку.

Приступим. Сделаем файл promotable.blade.php. Вся работа, как вы уже поняли, будет происходить в нём. Я рассмотрю фрагменты файла, а в конце приложу его целиком.

<?php use function Livewire\Volt\{state, computed, usesPagination}; usesPagination(); state(['category', 'regions', 'type', 'search'])->url();

Мы объявили динамические переменные, соответствующие нашему фильтру. Лайвваер будет отслеживать их состояние, обновлять когда из фронта придут соответствующие запросы. Также вызовом ->url() мы указали, что значения этих переменных должны попадать в query-строку url. Также мы инициализировали постраничный вывод, который используем в дальнейшем.

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

// ... $categories = computed(function () { return \App\Models\Category::all(); }); //... ?> <div class="grid grid-cols-4 gap-4 mb-12"> @foreach ($this->categories as $category) <div wire:click="$set('category', {{ $category->id }})" class="... {{ $this->category == $category->id ? 'ring-2 ring-blue-500' : '' }}" > <i class="{{ $category->icon }}"></i> {{ $category->title }} </div> @endforeach </div>

Вызов 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 мы рассмотрим дальше.

Теперь сделаем поиск по названию. Тут всё предельно просто, этот пример можно и в документации увидеть, добавим вот такой инпут:

<input wire:model.live="search" type="text" placeholder="Поиск по названию" class="w-full" />

Здесь мы создаем инпут и говорим что моделью для него является наша динамическая переменная 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 проекта код для инициализации:

import 'tom-select/dist/css/tom-select.default.css'; import TomSelect from 'tom-select'; // Initializing Tom Select for all select tags having .tom-select document.addEventListener('DOMContentLoaded', function() { const selects = document.querySelectorAll('.tom-select'); selects.forEach(select => { new TomSelect(select, { plugins: ['remove_button'] }); }); });

Это весь JS, который нам понадобится. Как я уже говорил, это необязательно. Ведь для Livewire есть готовая библиотека компонентов Flux, которая тоже входит в starter kit. Среди компонентов есть и мультиселект, и поиск, и таблица. Выглядит всё очень прилично, однако когда начинаешь подбирать компоненты, быстро выясняется, что всё клевое и заслуживающее внимания — в Pro-версии, которая стоит 150 баксов за проект, или 300 за бесконечную лицензию. Мне покупать не захотелось, тут в общем-то можно и без флакса обойтись.

Теперь вернемся к нашему компоненту и сделаем сам фильтр по регионам:

// ... $regions_list = computed(function () { return Region::all()->pluck('name', 'id'); }); // ... ?> <div wire:ignore class="flex-3"> <select wire:model.live="regions" multiple class="tom-select w-full"> <option value="">Выберите регионы</option> @foreach ($this->regions_list as $id => $region) <option value="{{ $id }}">{{ $region }}</option> @endforeach </select> </div>

Регионы реализованы через отношения Many to Many с моделями промоакций. Здесь мы загружаем их все, берем только название и id — больше нам ничего не надо. И выводим в качестве опций обычного множественного селекта, который при загрузке будет преобразован в Tom Select.

Обратите внимание на wire:ignore. Директива нужна, чтобы запретить Livewire перезаписывать этот кусочек HTML-кода при обновлении компонента. Иначе нам пришлось бы писать код, переинициализирующий Tom Select после каждого обновления. А так — не придётся.

Фильтр по типу совсем простой, у него даже серверной части нет, этой разметки с директивой wire:model.live достаточно:

<div wire:ignore class="flex-2"> <select wire:model.live="type" class="tom-select w-full"> <option value="">Выберите тип</option> @foreach (Promo::getTypeOptions() as $key => $type) <option value="{{ $key }}">{{ $type }}</option> @endforeach </select> </div>

Здесь тоже используется Tom Select, просто потому что он уже есть, ну и для единообразия. И, соответственно, тоже используется wire:ignore.

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

$promos = computed(function () { $query = Promo::query(); if ($this->category) { $query->where('category_id', $this->category); } if ($this->regions) { $all_regions_region = Region::getAllRegionsRegion(); $query->whereHas('regions', function ($regionQuery) use ($all_regions_region) { $regionQuery->whereIn('id', [...$this->regions, $all_regions_region->id]); }); } if ($this->type) { $query->where('type', $this->type); } if ($this->search) { $query->where('title', 'like', '%' . $this->search . '%'); } return $query ->where('status', true) ->with('regions') ->paginate(20); });

Это для получения отфильтрованной и пагинированой выборки промоакций.

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

Promo::category($this->category) ->regions(this->regions) ->type($this->type) ->titleLike($this->title) ->active() ->with('regions) ->paginate(20);

или вообще

Promo::filter($this->filter)->active()->with('regions')->paginate(20); // убрав отдельные динамические свойства в общий массив filter

Но здесь для наглядности и простоты я оставил как есть.

Также заметьте как используется переменная $all_regions_region в коде. Дело в том, что регион «Все регионы» означает, что акция действует везде. Соответственно, мы такую акцию показываем независимо от того, какой регион выбран в фильтре. Поэтому так.

А вот разметка самой таблицы:

<div class="overflow-x-auto rounded-lg border border-gray-200"> <table class="table-auto table-fixed w-full divide-y divide-gray-200"> <thead class="bg-gray-50"> <tr> <th scope="col" class="...">Акция</th> <th scope="col" class="...">Даты</th> <th scope="col" class="...">Тип</th> <th scope="col" class="...">Регионы</th> <th scope="col" class="...">Промокод</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @foreach($this->promos as $promo) <tr class="hover:bg-gray-50"> <td class="...">{{ $promo->title }}</td> <td class="...">{{ $promo->valid_from_date->format('d.m.Y') }} – {{ $promo->valid_to_date->format('d.m.Y') }}</td> <td class="...">{{ $promo->type }}</td> <td class="..."> @foreach($promo->regions as $region) <span class="..."> {{ $region->name }} </span> @endforeach </td> <td class="...">{{ $promo->code }}</td> </tr> @endforeach </tbody> </table> </div> <div class="mt-4"> {{ $this->promos->links() }} </div>

В самом конце разметки вы увидите {{ $this->promos->links() }} — это вывод пагинатора, про который целая отдельная страница в документации есть.

Полный код примера

Как обещал, код всего компонента целиком, со всеми стилями:

<?php use function Livewire\Volt\{state, computed, usesPagination}; use App\Models\Promo; use App\Models\Region; usesPagination(); state(['category', 'regions', 'type', 'search'])->url(); $promos = computed(function () { $query = Promo::query(); if ($this->category) { $query->where('category_id', $this->category); } if ($this->regions) { $all_regions_region = Region::getAllRegionsRegion(); $query->whereHas('regions', function ($regionQuery) use ($all_regions_region) { $regionQuery->whereIn('id', [...$this->regions, $all_regions_region->id]); }); } if ($this->type) { $query->where('type', $this->type); } if ($this->search) { $query->where('title', 'like', '%' . $this->search . '%'); } return $query ->where('status', true) ->with('regions') ->paginate(20); }); $categories = computed(function () { return \App\Models\Category::all(); }); $regions_list = computed(function () { return Region::all()->pluck('name', 'id'); }); ?> <div> <div class="grid grid-cols-4 gap-4 mb-12"> @foreach ($this->categories as $category) <div wire:click="$set('category', {{ $category->id }})" class="p-4 bg-white rounded-lg shadow hover:shadow-md transition-shadow text-center cursor-pointer {{ $this->category == $category->id ? 'ring-2 ring-blue-500' : '' }}" > <i class="{{ $category->icon }}"></i> {{ $category->title }} </div> @endforeach </div> <div class="mb-4 flex gap-4"> <div class="flex-2"> <div class="ts-control"> <input wire:model.live="search" type="text" placeholder="Поиск по названию" class="w-full" /> </div> </div> <div wire:ignore class="flex-2"> <select wire:model.live="type" class="tom-select w-full"> <option value="">Выберите тип</option> @foreach (Promo::getTypeOptions() as $key => $type) <option value="{{ $key }}">{{ $type }}</option> @endforeach </select> </div> <div wire:ignore class="flex-3"> <select wire:model.live="regions" multiple class="tom-select w-full"> <option value="">Выберите регионы</option> @foreach ($this->regions_list as $id => $region) <option value="{{ $id }}">{{ $region }}</option> @endforeach </select> </div> </div> <div class="overflow-x-auto rounded-lg border border-gray-200"> <table class="table-auto table-fixed w-full divide-y divide-gray-200"> <thead class="bg-gray-50"> <tr> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Акция</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Даты</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Тип</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Регионы</th> <th scope="col" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Промокод</th> </tr> </thead> <tbody class="bg-white divide-y divide-gray-200"> @foreach($this->promos as $promo) <tr class="hover:bg-gray-50"> <td class="px-6 py-4 text-sm text-gray-900">{{ $promo->title }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $promo->valid_from_date->format('d.m.Y') }} – {{ $promo->valid_to_date->format('d.m.Y') }}</td> <td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500">{{ $promo->type }}</td> <td class="px-6 py-4 text-sm text-gray-500"> @foreach($promo->regions as $region) <span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-800 mr-1 mb-1"> {{ $region->name }} </span> @endforeach </td> <td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-blue-600">{{ $promo->code }}</td> </tr> @endforeach </tbody> </table> </div> <div class="mt-4"> {{ $this->promos->links() }} </div> </div>

А вот короткое видео с демонстрацией результата:

Практические выводы

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, мы можем тестировать вот так:

public function test_user_can_filter_promos_by_category(): void { $category1 = \App\Models\Category::factory()->create(); $category2 = \App\Models\Category::factory()->create(); $promo1 = Promo::factory()->create(['category_id' => $category1->id]); $promo2 = Promo::factory()->create(['category_id' => $category2->id]); $response = $this->get('/?category=' . $category1->id); $response->assertStatus(200); $response->assertSeeText($promo1->title); $response->assertDontSeeText($promo2->title); }

и будет работать как если бы за роутом скрывался обычный контроллер. А стоит или нет использовать Livewire::test() и тестировать саму реактивность — вопрос дискуссионный.

Заключение

Если нужно полномасштабное SPA (single page application, одностраничное приложение), чтобы был state management типа vuex, асинхронные запросы и всё такое, я бы уходить в Livewire не стал.

У меня есть пара проектов с фронтендом полностью на Vue, где на страницах может быть с пяток или больше независимых динамических компонентов, и управление состоянием, и echo, и всякие draggable… Мне пока сложно представить это всё в компонентах Volt и блейд-темплейтах с Livewire. И навскидку кажется что с увеличением сложности приложения компоненты для Livewire будут усложняться, и нужно будет всё больше бороться с фреймворком, пока в конце концов отдельный фронт на Vue не покажется привлекательнее.

Тем не менее, если мы не делаем сложное SPA, если у нас несколько отдельных компонентов, которым нужен интерактив, если мы просто хотим отправлять формы динамически, и всё — Livewire+Volt вполне живой, быстрый и главное удобный вариант. Cкажем, вариант представляется удачным для обычного сайта-каталога, где нужен отдельный интерактивный калькулятор, или динамический подбор товаров по фильтрам.

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

Начать дискуссию