Еще пару дней назад интерфейс нашего сервиса «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.
