Еще пару дней назад интерфейс нашего сервиса «Oh Dear» работал на веб-сокетах. А сейчас мы заменили их на компоненты Livewire.
В этой статье я объясню, почему и как мы это сделали.
Предположу, что вы уже знаете, что такое Livewire. Если нет, рекомендую почитать документации. Там даже есть небольшой видеокурс. Кратко говоря, Livewire позволяет создавать динамические пользовательские интерфейсы с рендерингом на сервере.
Почему мы отказались от веб-сокетов?
Существуют сотни сервисов проверки аптайма и большинство из них выглядят довольно убого. Когда мы, с моим приятелем Маттиасом, создавали «Oh Dear», то поставили себе цель создать красивый сервис, которым легко пользоваться. Наш сервис не обязательно должен быть уникальным, но имея прекрасные дизайн, UX и документацию, можно опередить большую часть конкурентов.
Одна из вещей, о которой мы сразу договорились, заключалась в том, что интерфейс должен быть рилтаймовый (real-time UI). Мы не хотели, что бы пользователю приходилось обновлять страницу для того, чтобы узнать результаты последних проверок.
Вот как это выглядит в «Oh Dear». После регистрации пользователи заполняют эту форму. При нажатии на ввод сайт добавляется в список, и результаты отображаются сразу же по мере их поступления.
Раньше это всё работало на веб-сокетах. Каждый раз, когда «Oh Dear» начинал и заканчивал проверку, он бродкастил события на фронтенд. Это делалось с использованием встроенной в Laravel функций широковещания и написанного нами пакета Laravel WebSockets. На фронтенде, для получения событий, мы использовали Laravel Echo и нескольких компонентов Vue.
Всё работало нормально, хотя и добавляло сложности в наш стек. Кроме того, в большинстве случаев, после того как пользователи настроят свои сайты, то они не будут так уж часто посещать наш сервис снова. А мы все равно продолжаем бродкастить события. Этого можно было бы избежать, отслеживая каким-то образом присутствие , но это еще более усложнит ситуацию.
Перейдя на Livewire, мы смогли полностью отказаться от всего вышеупомянутого. Для того чтобы обеспечить впечатления от показанного видеоролика, больше нет необходимости ни в сервере веб-сокетов, ни в Echo, ни в Vue. Фактически, в нём уже используется Livewire.
Давайте посмотрим на эти Livewire-компоненты.
Отображение списка сайтов
Вот так выглядит список сайтов.
А это blade-шаблон, который используется для отображения этого списка.
<x-app-layout title="Dashboard" :breadcrumbs="Breadcrumbs::render('sites')"> <div class="wrap"> <section class="card pb-8 cloak-fade" v-cloak> @include('app.sites.list.partials.notificationsNotConfigured') <livewire:site-list /> <div class="flex items-center mt-8" id="site-adder"> <livewire:site-adder /> </div> </section> </div> </x-app-layout>
Итак, у нас есть два Livewire-компонента: site-list
и site-adder
. В LivewireServiceProvider
вы можете увидеть:
namespace App\Providers; use App\Http\App\Livewire\Sites\SiteListComponent; use App\Http\App\Livewire\SiteSettings\SiteAdderComponent; use Illuminate\Support\ServiceProvider; use Livewire\Livewire; class LivewireServiceProvider extends ServiceProvider { public function boot() { Livewire::component('site-list', SiteListComponent::class); Livewire::component('site-adder', SiteAdderComponent::class); // другие компоненты } }
Далее рассмотрим класс SiteListComponent
и его базовый шаблон. Сейчас вам не нужно всё это понимать. После исходного кода я объясню некоторые моменты. но, я думаю, хорошо бы сначала увидеть компонент в целом, чтобы иметь некий контекст.
namespace App\Http\App\Livewire\Sites; use Livewire\Component; use Livewire\WithPagination; class SiteListComponent extends Component { use WithPagination; protected $listeners = ['siteAdded' => '$refresh']; public int $perPage = 30; public string $search = ''; public bool $onlySitesWithIssues = false; public bool $showSiteAdder = false; public function showAll() { $this->search = ''; $this->onlySitesWithIssues = false; } public function showOnlyWithIssues() { $this->search = ''; $this->onlySitesWithIssues = true; } public function showSiteAdder() { $this->showSiteAdder = true; } public function hideSiteAdder() { $this->showSiteAdder = false; } public function render() { return view('app.sites.list.components.siteList', [ 'sites' => $this->sites(), 'sitesWithIssuesCount' => $this->sitesWithIssuesCount(), ]); } protected function sites() { $query = currentTeam()->sites()->search($this->search); if ($this->onlySitesWithIssues) { $query = $query->hasIssues(); } return $query ->orderByRaw('(SUBSTRING(url, LOCATE("://", url)))') ->paginate($this->perPage); } protected function sitesWithIssuesCount(): int { return currentTeam()->sites()->hasIssues()->count(); } }
А это шаблон app.sites.list.components.siteList
, который который рендерится в функции render
:
<div> <div wire:poll.5000ms> <section class="flex items-center justify-between w-full mb-8"> <div class="text-xs"> <div class="switcher"> <button wire:click="showAll" class="switcher-button {{ ! $onlySitesWithIssues ? 'is-active' : '' }}">Display all sites </button> <button wire:click="showOnlyWithIssues" class="switcher-button {{ $onlySitesWithIssues ? 'is-active' : '' }}"> Display {{ $sitesWithIssuesCount }} {{ \Illuminate\Support\Str::plural('site', $sitesWithIssuesCount) }} with issues </button> </div> </div> <div class="flex items-center justify-end"> <a href="#site-adder" wire:click="$emit('showSiteAdder')" class="button is-secondary mr-4">Add another site</a> <input wire:model="search" class="form-input is-small w-48 focus:w-64" type="text" placeholder="Filter sites..."/> </div> </section> <table class="site-list-table w-full"> <thead> <th style="width: 27%;">Site</th> <th style="width: 14%;">Uptime</th> <th style="width: 14%;">Broken links</th> <th style="width: 14%;">Mixed Content</th> <th style="width: 14%;">Certificate Health</th> <th style="width: 17%;">Last check</th> </thead> <tbody> @forelse($sites as $site) <tr> <td class="pr-2"> <div class="flex"> <span class="w-6"><i class="fad text-gray-700 {{ $site->uses_https ? 'fa-lock' : '' }}"></i></span> <a href="{{ route('site', $site) }}" class="flex-1 underline truncate">{{ $site->label }}</a> </div> </td> <td class="pr-2"> <x-check-result :site="$site" check-type="uptime"/> </td> <td class="pr-2"> <x-check-result :site="$site" check-type="broken_links"/> </td> <td class="pr-2"> <x-check-result :site="$site" check-type="mixed_content"/> </td> <td class="pr-2"> <x-check-result :site="$site" check-type="certificate_health"/> </td> <td class="text-sm text-gray-700"> @if($site->latest_run_date) <a href="{{ route('site', $site) }}"> <time datetime="{{ $site->latest_run_date->format('Y-m-d H:i:s') }}" title="{{ $site->latest_run_date->format('Y-m-d H:i:s') }}">{{ $site->latest_run_date->diffInSeconds() < 60 ? 'Less than a minute ago' : $site->latest_run_date->diffForHumans() }}</time> </a> @else No runs yet @endif </td> </tr> @empty <tr><td class="text-center" colspan="6">There are no sites that match your search...</td></tr> @endforelse </tbody> </table> @if ($sites->total() > $perPage) <div class="flex justify-between mt-4"> <div class="flex-1 w-1/2 mt-4"> {{ $sites->links() }} </div> <div class="flex w-1/2 text-right text-muted text-sm text-gray-700 mt-4"> <div class="w-full block"> Showing {{ $sites->firstItem() }} to {{ $sites->lastItem() }} out of {{ $sites->total() }} sites </div> </div> </div> @endif </div> </div>
Вероятно вы заметили, что в функции render сам компонент отвечает за получение сайтов.
public function render() { return view('app.sites.list.components.siteList', [ 'sites' => $this->sites(), 'sitesWithIssuesCount' => $this->sitesWithIssuesCount(), ]); } protected function sites() { $query = currentTeam()->sites()->search($this->search); if ($this->onlySitesWithIssues) { $query = $query->hasIssues(); } return $query ->orderByRaw('(SUBSTRING(url, LOCATE("://", url)))') ->paginate($this->perPage); }
Фильтрация сайтов
Обратите внимание на проверку $this->onlySitesWithIssues
. Давайте посмотрим, как устанавливается эта переменная. Если вы посмотрите на скриншот списка сайтов, то увидите небольшой фильтр вверху «Display all sites» (Показать все сайты) и «Display sites with issues» (Показать сайты с проблемами).
Вот эта часть шаблона, которая отрисовывает это.
<div class="switcher"> <button wire:click="showAll" class="switcher-button {{ ! $onlySitesWithIssues ? 'is-active' : '' }}">Display all sites </button> <button wire:click="showOnlyWithIssues" class="switcher-button {{ $onlySitesWithIssues ? 'is-active' : '' }}"> Display {{ $sitesWithIssuesCount }} {{ \Illuminate\Support\Str::plural('site', $sitesWithIssuesCount) }} with issues </button> </div>
Обратите внимание на wire:click="showOnlyWithIssues"
. Когда пользователь кликнет на элемент с wire:click
, то выполнится метод, имя которого находится в значении атрибута, и компонент будет перерисован. В нашем случае выполняется showOnlyWithIssues
.
public function showOnlyWithIssues() { $this->search = ''; return $this->onlySitesWithIssues = true; }
Таким образом, здесь изменяется наша переменная onlySitesWithIssues
, что приводит к перерисовке нашего компонента. Поскольку onlySitesWithIssues
теперь имеет значение true
, то метод sites()
теперь будет отфильтровывать сайты с проблемами.
if ($this->onlySitesWithIssues) { $query = $query->hasIssues(); }
Когда компонент отрендерится, то покажутся только сайты с проблемами. Livewire, под капотом, заботится о многих вещах, в то числе о вызове метода компонента через wire:click
и о замене HTML-компонента на странице, при перерисовке.
Поиск сайтов
В правом верхнем углу списка сайтов есть строка поиска. Когда вы что-то там наберете, вы увидите только те сайты, чье имя попадает под ваш запрос. Вот как это работает.
Blade-шаблон строки поиска
<input wire:model="search" class="form-input is-small w-48 focus:w-64" type="text" placeholder="Filter sites..." />
Здесь используется еще одна Livewire-команда: wire:model
. Она гарантирует, что каждый раз, когда вы печатате что-либо в этом элементе, Livewire обновляет переменную экземпляра с тем же именем в компоненте и перерисовывает его. По дефолту используется поведение debounce, то есть при быстром наборе только один запрос будет выполняться каждые 150 мс.
Таким образом, поскольку переменная search
поменялась в компоненте, то он перерисуется. В первой строке метода sites()
используется это значение.
$query = currentTeam()->sites()->search($this->search);
Вот так и работает поиск сайтов. Это довольно удивительно, что можно получить такой результат всего лишь добавив livewire:model
в свой шаблон и использовать его значение в скоупе вашего запроса. Конечно, это можно сделать и на Vue, но тут это намного проще.
Для полноты картины, вот scopeSearch
из модели Site
.
public static function scopeSearch(Builder $query, string $searchFor): void { if ($query === '') { return; } $query->where('url', 'like', "%{$searchFor}%"); }
Пагинация сайтов
Laravel изначально имеет отличную поддержку разбивки результатов запроса на страницы. Вам нужно только вызвать paginate
в запросе, что мы и сделали в методе sites()
.
// в методе sites() return $query ->orderByRaw('(SUBSTRING(url, LOCATE("://", url)))') ->paginate($this->perPage);
Вызов $sites->links()
в шаблоне отобразит URL с параметром page
. Под капотом метода paginate()
этот параметр используется для получения результатов этой страницы. Так, вкратце, работает пагинация Laravel.
Добавить пагинации в Livewire-компонент очень просто. Нужно просто использовать трейт Livewire\WithPagination
. Он добавит немного магии и ссылки пагинации будут содержать не адрес страницы, а внутренние маршруты Livewire. Вот так выглядит вывод $sites()->links()
в шаблоне компонента Livewire.
<ul> <li class="page-item active" aria-current="page"><span class="page-link">1</span></li> <li class="page-item"><a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=2">2</a></li> <li class="page-item"><a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=3">3</a></li> <li class="page-item"><a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=4">4</a></li> <li class="page-item"> <a class="page-link" href="https://ohdear.app/livewire/message/site-list?page=2" rel="next" aria-label="Next »">›</a> </li> </ul>
Если кликнуть на такую ссылку, то Livewire установит параметр запроса page
в адресе и перерисует компонент. Очень элегантное решение и оно отлично вписывается в дефолтную пагинацию Laravel.
Вам, как пользователю Livewire, единственное, что нужно сделать, это применить трейт WithPagination
. Прекрасно!
Обновление списка
Как я и говорил ранее, нужно чтобы наш интерфейс отображал актуальную информацию, без необходимости ручного обновления страницы. Если один из сайтов списка упал, то список должен обновиться автоматически.
Решение простое: будем опрашивать изменения. Livewire поддерживает polling из коробки. В верхней части шаблона вы могли заметить строку:
<div wire:poll.5000ms>
Это заставит Livewire автоматически обновлять компонент каждые пять секунд. Для повторного рендеринга компонента в браузере используется morphdom. Сравнивается HTML-код компонента с HTML-кодом перерендеренной версии и в DOM обновятся только изменения, что позвляет сделать стоимость повторого рендеринга в браузере очень низкой.
Пока я это набираю этот текст, то уже слышу крики своих читателей: «Фрик, ты чё делаешь?!?! Это же охренеть как неэффективно, он же будет постоянно дергать базу и отправлять запросы», Да, так и есть.
Использование Livewire с пулингом — это компромисс. С одной стороны, да, у нас будет несколько запросов, но только тогда, когда пользователи просматривают свои списки сайтов. Но это будет происходить не так уж и часто. Чтобы узнать состояние своих сайтов, большинство пользователей полагаются на отправляемые нами оповещения (через Slack, Mail, Telegram, …) и они не будут постоянно смотреть своих список сайтов.
Также очень приятно, что Livewire использует пулинг, только если браузер показывает страницу. Когда вкладка браузера находится в фоновом режиме, опрос приостанавливается.
Всего лишь небольшой пулинг и нам уже не нужно настраивать веб-сокеты или Vue, и не нужно постоянно бродкастить события. Это значительно упрощает наш технический стек. Я думаю, что в данном случае стоит пойти на компромисс.
Заключение
Надеюсь, вам понравилась как мы реализовали наш список сайтов через компонент Livewire. Также мы сделали и в другом месте нашего интерфейса, где нужна была рилтайм информативность.
Работать с Livewire было очень приятно. Осмысленное API, хорошо написанная документация, возможность легко и просто создавать мощные компоненты за короткое время.
Автор: Freek Van der Herten
Перевод: Алексей Широков
Наш Телеграм-канал — следите за новостями о Laravel.