Студия разработки сайтов и приложений

Netspark.ru

Скрипт для типографики

Typofilter.js

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

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

2-table.png

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

При этом мы хотим чтобы все фильтры попадали в 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-шаблона мы выводим все категории — их названия и иконки — в виде «кнопок», вот так:

3-categories.png

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

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

Обсуждение

Чтобы обсудить заметку, написать комментарий, или просто связаться, заходите в Телеграм-канал. У нас весело и всем рады!

Также меня можно найти в Хвиттере, VC.ru, Дзене, или Тенчате.