На днях начал работу над новым проектом, и выдался хороший случай попробовать 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, он преобразуется в динамическую кнопочку. А вставка происходит так: <livewire:counter />
(если файл компонента — 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>
А вот короткое видео с демонстрацией результата:
Практические выводы
-
Как мы видим, модели, функции типа
computed()
и т.п. действительно очень напоминают компоненты Vue.js. В документации можно найти иmount()
, да и сами директивы с модификаторами на Vue очень похожи. И мне это нравится, поскольку мне нравится Vue. -
При разработке компонентов Volt нужно внимательно рефакторить, использовать репозитории или хотя бы скоупы, иначе компонент легко превратится в спагетти из запросов в БД, условий, функций и прочего, и поддерживать его будет непросто.
-
На первый взгляд кажется, что такое решение на длинной дистанции должно стать немножко деревянным: у нас обязательно возникнут какие-нибудь краевые случаи, которые фреймворк не осилит и придётся подставлять костыли. Но в краевых случаях можно и JavaScript написать, никто не запрещает. Да и глядя на документацию фреймворка кажется, костылей не должно быть слишком много.
-
Фреймворк Tailwind мне кстати очень понравился. Да, я с ним особо не работал, сталкивался конечно, но в проектах «с нуля» обычно для простоты и быстроты брал бутстрап. Теперь буду брать Тейлвинд тоже, просто потому что вайб от него очень приятный при работе, и сайт круто сделан. И иишечка неплохо понимает стили тейлвинда, на лету украшает таблицы и генерирует различные гриды.
-
Кстати, ИИ в работе с Livewire тоже уже наблатыкался, и неплохо помогает. Но не на 100%, пару раз он мне компонент поломал так, что пришлось откатывать промптов пять. Да, вот этот компонент, из примера. Но во всяком случае он понимает директивы Livewire и структура компонента Volt его в ступор не вводит, предложения на таб-таб-таб поступают исправно.
-
В дальнейшем имеет смысл разделить фильтр и таблицу на два компонента, как раз чтобы не перегружать каждый раз список регионов и категорий, поскольку он не меняется. Для этого есть дочерние компоненты, но с вашего позволения мы посмотрим на реактивность и отношения между компонентами в другой раз.
-
Главное: когда нам нужен какой-то веб-интерактив, мы пишем контроллер на сервере, разметку для фронта и 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кажем, вариант представляется удачным для обычного сайта-каталога, где нужен отдельный интерактивный калькулятор, или динамический подбор товаров по фильтрам.
В общем, я этот фремворк обязательно ещё раз использую, как только появится следующая возможность.