Изучение Livewire 3, Volt и Folio на примере создания плеера подкастов
Недавно команда Laravel выпустила Laravel Folio — мощный страничный маршрутизатор, предназначенный для упрощения маршрутизации в приложениях Laravel. Следом они выпустили Volt — элегантно выполненный функциональный API для Livewire, позволяющий PHP-логике компонента и шаблонам Blade сосуществовать в одном файле с минимальным количеством шаблонов.
Хотя их можно использовать по отдельности, я считаю, что их совместное применение — это новый, невероятно продуктивный способ создания приложений на Laravel.
В этой статье я расскажу как создать простое приложение, в котором перечисляются эпизоды подкаста Laravel News и пользователи могут их воспроизводить, причём плеер может плавно продолжать воспроизведение при загрузке страницы.
Установка Livewire, Volt и Folio
Для начала нам необходимо создать новое приложение Laravel и установить Livewire, Volt, Folio и Sushi (для создания фиктивных данных).
laravel new
composer require livewire/livewire:^3.0@beta livewire/volt:^1.0@beta laravel/folio:^1.0@beta calebporzio/sushi
Livewire v3, Volt и Folio все ещё находятся в бета-версии. Они должны быть достаточно стабильными, но использовать их следует на свой страх и риск.
После установки пакетов необходимо выполнить php artisan volt:install
и php artisan folio:install
. В результате будут созданы папки и провайдеры, необходимые Volt и Folio.
Модель Episode
Для фиктивных данных я создам модель Sushi. Sushi — это пакет, написанный Калебом Позио, который позволяет создавать модели Eloquent, запрашивающие данные из массива, записанного непосредственно в файле модели. Это отлично подходит для создания примеров приложений или данных, которые не должны часто меняться.
Создайте модель, затем удалите трейт HasFactory
и замените его на трейт Sushi
. В качестве данных для этого примера я добавил сведения о четырёх последних эпизодах подкаста Laravel News.
Не буду вдаваться в детали того, как все это работает, поскольку это не является целью статьи, а при создании собственного плеера подкастов вы, скорее всего, будете использовать реальную модель Eloquent.
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
use Sushi\Sushi;
class Episode extends Model
{
use Sushi;
protected $casts = [
'released_at' => 'datetime',
];
protected $rows = [
[
'number' => 195,
'title' => 'Queries, GPT, and sinking downloads',
'notes' => '...',
'audio' => 'https://media.transistor.fm/c28ad926/93e5fe7d.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2579,
'released_at' => '2023-07-06 10:00:00',
],
[
'number' => 194,
'title' => 'Squeezing lemons, punching cards, and bellowing forges',
'notes' => '...',
'audio' => 'https://media.transistor.fm/6d2d53fe/f70d9278.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2219,
'released_at' => '2023-06-21 10:00:00',
],
[
'number' => 193,
'title' => 'Precognition, faking Stripe, and debugging Blade',
'notes' => '...',
'audio' => 'https://media.transistor.fm/d434305e/975fbb28.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 2146,
'released_at' => '2023-06-06 10:00:00',
],
[
'number' => 192,
'title' => 'High octane, sleepy code, and Aaron Francis',
'notes' => '...',
'audio' => 'https://media.transistor.fm/b5f81577/c58c90c8.mp3',
'image' => 'https://images.transistor.fm/file/transistor/images/show/6405/full_1646972621-artwork.jpg',
'duration_in_seconds' => 1865,
'released_at' => '2023-05-24 10:00:00',
],
// ...
];
}
Представление макета
Нам понадобится файл макета для загрузки Tailwind, добавления логотипа и основных стилей. Поскольку Livewire и Alpine теперь автоматически внедряют свои скрипты и стили, нам даже не нужно загружать их в макет! Мы создадим макет как анонимный компонент Blade по адресу resources/views/components/layout.blade.php
.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laravel News Podcast Player</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
</head>
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>
<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>
<span>Laravel News Podcast</span>
</a>
<div class="py-10">{{ $slot }}</div>
</div>
</body>
</html>
Страница списка эпизодов
Во-первых, нам нужна страница, на которой будут отображаться все эпизоды подкаста.
Используя Folio, мы можем легко создать новую страницу в каталоге resources/views/pages
, и Laravel автоматически создаст маршрут для этой страницы. Мы хотим, чтобы наш маршрут был /episodes
, поэтому выполним php artisan make:folio episodes/index
. Это создаст пустое представление по адресу resources/views/pages/episodes/index.blade.php
.
На этой странице мы вставим компонент layout
, а затем в цикле просмотрим все эпизоды подкаста. Volt предоставляет функции с пространством имён для большинства функций Livewire. Здесь мы откроем обычные теги <?php ?>
для открытия и закрытия. Внутри них с помощью функции computed
создадим переменную $episodes
, выполняющую запрос для получения всех моделей эпизодов ($episodes = computed(fn () => Episode::get());
). Мы можем получить доступ к свойству computed
в шаблоне с помощью $this->episodes
.
Я также создал переменную $formatDuration
, которая представляет собой функцию для форматирования свойства duration_in_seconds
каждого эпизода в удобочитаемый формат. Мы можем вызвать эту функцию в шаблоне с помощью $this->formatDuration($episode->duration_in_seconds)
.
Нам также необходимо обернуть динамическую функциональность на странице директивой @volt
, чтобы зарегистрировать её как анонимный компонент Livewire
на странице Folio.
<?php
use App\Models\Episode;
use Illuminate\Support\Stringable;
use function Livewire\Volt\computed;
use function Livewire\Volt\state;
$episodes = computed(fn () => Episode::get());
$formatDuration = function ($seconds) { ...
?>
<x-layout>
@volt
<div class="rounded-xl border border-gray-200 bg-white shadow">
<ul class="divide-y divide-gray-100">
@foreach ($this->episodes as $episode)
<li
wire:key="{{ $episode->number }}"
class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<h2>
No. {{ $episode->number }} - {{ $episode->title }}
</h2>
<div
class="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500"
>
<p>
Released:
{{ $episode->released_at->format('M j, Y') }}
</p>
·
<p>
Duration:
{{ $this->formatDuration($episode->duration_in_seconds) }}
</p>
</div>
</div>
<button
type="button"
class="flex shrink-0 items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
>
<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>
<span>Play</span>
</button>
</li>
@endforeach
</ul>
</div>
@endvolt
</x-layout>
Плеер эпизодов
Далее нам нужно добавить интерактивность. Я хочу добавить проигрыватель эпизодов, чтобы мы могли прослушивать эпизоды из списка эпизодов. Это может быть обычный компонент Blade, который мы отображаем в файле макета.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Laravel News Podcast Player</title>
<script src="https://cdn.tailwindcss.com?plugins=typography"></script>
</head>
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>
<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>
<span>Laravel News Podcast</span>
</a>
<div class="py-10">{{ $slot }}</div>
<x-episode-player />
</div>
</body>
</html>
Мы можем создать этот компонент, добавив файл resources/views/components/episode-player.blade.php
. Внутри компонента мы добавим элемент <audio>
с некоторым кодом Alpine для хранения активного эпизода и функцию, которая обновляет активный эпизод и запускает аудио. Мы будем показывать плеер только в том случае, если активный эпизод установлен, и добавим в обёртку красивый затухающий переход.
<div
x-data="{
activeEpisode: null,
play(episode) {
this.activeEpisode = episode
this.$nextTick(() => {
this.$refs.audio.play()
})
},
}"
x-show="activeEpisode"
x-transition.opacity.duration.500ms
class="fixed inset-x-0 bottom-0 w-full border-t border-gray-200 bg-white"
style="display: none"
>
<div class="mx-auto max-w-xl p-6">
<h3
x-text="`Playing: No. ${activeEpisode?.number} - ${activeEpisode?.title}`"
class="text-center text-sm font-medium text-gray-600"
></h3>
<audio
x-ref="audio"
class="mx-auto mt-3"
:src="activeEpisode?.audio"
controls
></audio>
</div>
</div>
Если мы перезагрузим страницу, то не увидим никаких изменений. Это связано с тем, что мы не добавили способ воспроизведения эпизодов. Мы будем использовать события для передачи данных от наших компонентов Livewire к плееру. Сначала в плеере мы добавим x-on:play-episode.window="play($event.detail)"
, чтобы прослушать событие play-episode
в окне и вызвать функцию play
.
<div
x-data="{
activeEpisode: null,
play(episode) {
this.activeEpisode = episode
this.$nextTick(() => {
this.$refs.audio.play()
})
},
}"
x-on:play-episode.window="play($event.detail)"
...
>
<!-- ... -->
</div>
Далее на странице episodes/index
мы добавим слушателя кликов на кнопки воспроизведения для каждого эпизода. Кнопки будут отправлять событие play-episode
, которое будет получено и обработано плеером эпизода.
<button
x-data
x-on:click="$dispatch('play-episode', @js($episode))"
...
>
<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>
<span>Play</span>
</button>
Страница с подробной информацией об эпизоде
Далее я хочу добавить страницу с информацией об эпизоде, на которой будут отображаться заметки о каждом эпизоде и другие подробности.
В Folio существуют довольно удобные соглашения для привязки моделей маршрутов к именам файлов. Чтобы создать эквивалентный маршрут для /episodes/{episode:id}
, создайте страницу по адресу resources/views/pages/episodes/[Episode].blade.php
. Чтобы использовать параметр маршрута, отличный от первичного ключа, можно использовать синтаксис [Model:some_other_key].blade.php
в имени файла. Я хочу использовать номер эпизода в URL, поэтому мы создадим файл по адресу resources/views/pages/episodes/[Episode:number].blade.php
.
Folio автоматически запросит модели Episode
для эпизода с номером, который мы передадим в URL, и сделает его доступным в виде переменной $episode
в нашем коде <?php ?>
. Затем мы можем преобразовать её в свойство Livewire с помощью функции состояния Volt.
Мы также разместим на этой странице кнопку воспроизведения, чтобы пользователи могли прослушать эпизод, просматривая его детали.
<?php
use Illuminate\Support\Stringable;
use function Livewire\Volt\state;
state(['episode' => fn () => $episode]);
$formatDuration = function ($seconds) { ...
?>
<x-layout>
@volt
<div class="overflow-hidden rounded-xl border border-gray-200 bg-white shadow">
<div class="p-6">
<div class="flex items-center justify-between gap-8">
<div>
<h2 class="text-xl font-medium">
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
<div
class="mt-1 flex items-center gap-3 text-sm text-gray-500"
>
<p>
Released:
{{ $episode->released_at->format('M j, Y') }}
</p>
·
<p>
Duration:
{{ $this->formatDuration($episode->duration_in_seconds) }}
</p>
</div>
</div>
<button
x-on:click="$dispatch('play-episode', @js($episode))"
type="button"
class="flex items-center gap-1 text-sm font-medium text-[#FF2D20] transition hover:opacity-60"
>
<img
src="/images/play.svg"
alt="Play"
class="h-8 w-8 transition hover:opacity-60"
/>
<span>Play</span>
</button>
</div>
<div class="prose prose-sm mt-4">
{!! $episode->notes !!}
</div>
</div>
<div class="bg-gray-50 px-6 py-4">
<a
href="/episodes"
class="text-sm font-medium text-gray-600"
>
← Back to episodes
</a>
</div>
</div>
@endvolt
</x-layout>
Теперь нам нужно сделать ссылку на страницу подробностей с индексной страницы. Вернувшись на страницу episodes/index
, обернём <h2>
каждого эпизода в тег ссылки.
@foreach ($this->episodes as $episode)
<li
wire:key="{{ $episode->number }}"
class="flex flex-col items-start gap-x-6 gap-y-3 px-6 py-4 sm:flex-row sm:items-center sm:justify-between"
>
<div>
<a
href="/episodes/{{ $episode->number }}"
class="transition hover:text-[#FF2D20]"
>
<h2>
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
</a>
</div>
{{-- ... --}}
</li>
@endforeach
Режим SPA
Мы почти у цели. Приложение выглядит неплохо и работает хорошо, но есть одна проблема. Если пользователь прослушивает эпизод и переходит на другую страницу, то проигрыватель эпизодов теряет состояние активного эпизода и исчезает.
К счастью, в Livewire есть директива wire:navigate
и @persist
, которые теперь помогают решить эти проблемы!
В нашем файле макета давайте обернём логотип и проигрыватель эпизодов в блоки @persist
. Livewire определит это и не будет перерисовывать эти блоки при смене страницы.
<!DOCTYPE html>
<html lang="en">
...
<body class="min-h-screen bg-gray-50 font-sans text-black antialiased">
<div class="mx-auto max-w-2xl px-6 py-24">
@persist('logo')
<a
href="/episodes"
class="mx-auto flex max-w-max items-center gap-3 font-bold text-[#FF2D20] transition hover:opacity-80"
>
<img
src="/images/logo.svg"
alt="Laravel News"
class="mx-auto w-12"
/>
<span>Laravel News Podcast</span>
</a>
@endpersist
<div class="py-10">{{ $slot }}</div>
@persist('player')
<x-episode-player />
@endpersist
</div>
</body>
</html>
Наконец, необходимо добавить атрибут wire:navigate
ко всем ссылкам в приложении. Например:
<a
href="/episodes/{{ $episode->number }}"
class="transition hover:text-[#FF2D20]"
wire:navigate
>
<h2>
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
</a>
Когда используете атрибут wire:navigate
, за кулисами Livewire получает содержимое новой страницы с помощью AJAX, а затем волшебным образом меняет его местами в браузере без полной перезагрузки страницы. Благодаря этому загрузка страницы становится невероятно быстрой, а такие функции, как persist
, могут работать! Это позволяет реализовать функции, которые раньше можно было реализовать только с помощью SPA.
Заключение
Это было очень забавное демонстрационное приложение, которое мы создали в процессе изучения Volt и Folio. Я выложил демонстрационное приложение здесь, а @bosunski, если хотите увидеть полный исходный код или попробовать его самостоятельно!
Что думаете? Является ли Livewire v3 + Volt + Folio самым простым стеком для создания приложений на Laravel? Я думаю, что это действительно здорово и может показаться более привычным для тех, кто привык создавать приложения на JavaScript-фреймворках, таких, как Next.js и Nuxt.js. Также приятно, что весь код страницы расположен в одном месте — стилизация (через Tailwind), JS (через Alpine) и код бэкенда в одном файле. Присылайте свои соображения в Twitter!