Học Livewire 3, Volt, và Folio Bằng cách xây dựng một podcast player
Hôm qua, đội ngũ phát triển Laravel đã phát hành Laravel Folio – một bộ định tuyến mạnh mẽ dựa trên trang, được thiết kế để đơn giản hóa việc định tuyến trong các ứng dụng Laravel. Hôm nay, họ đã phát hành Volt – một API chức năng tinh tế cho Livewire, cho phép logic PHP và các mẫu Blade của một thành phần tồn tại trong cùng một tệp với giảm thiểu boilerplate.
Mặc dù chúng có thể sử dụng riêng lẻ, tôi nghĩ việc sử dụng cả hai cùng nhau là một cách tiếp cận mới, cực kỳ hiệu quả để xây dựng các ứng dụng Laravel.
Trong bài viết này, tôi sẽ hướng dẫn bạn cách xây dựng một ứng dụng đơn giản cho danh sách các tập podcast Laravel News và cho phép người dùng phát chúng, với một trình phát có thể tiếp tục phát ngay cả khi chuyển trang.
Cài đặt Livewire, Volt, và Folio
Để bắt đầu, chúng ta cần tạo một ứng dụng Laravel mới và cài đặt Livewire, Volt, Folio và Sushi (để tạo dữ liệu giả).
laravel new
composer require livewire/livewire:^3.0@beta livewire/volt:^1.0@beta laravel/folio:^1.0@beta calebporzio/sushi
Livewire v3, Volt và Folio vẫn đang ở phiên bản beta. Chúng nên khá ổn định, nhưng hãy sử dụng chúng tự chịu trách nhiệm với mức độ rủi ro riêng của bạn.
Sau khi yêu cầu các gói, chúng ta cần chạy lệnh php artisan volt:install và php artisan folio:install. Điều này sẽ tạo các thư mục và nhà cung cấp dịch vụ mà Volt và Folio cần sử dụng.
The Episode
model
Để tạo dữ liệu giả, tôi sẽ tạo một mô hình Sushi. Sushi là một gói do Caleb Pozio viết, cho phép bạn tạo các mô hình Eloquent truy vấn dữ liệu của chúng từ một mảng được viết trực tiếp trong tệp mô hình. Điều này hoạt động tốt khi bạn xây dựng các ứng dụng ví dụ hoặc có dữ liệu không cần thay đổi thường xuyên.
Tạo một mô hình, sau đó xóa thuộc tính HasFactory và thay thế nó bằng thuộc tính Sushi. Tôi đã thêm chi tiết của 4 tập mới nhất của podcast Laravel News làm dữ liệu cho ví dụ này.
Tôi sẽ không đi vào chi tiết về cách thức hoạt động của tất cả điều này vì điều này không phải là điểm chính trong bài viết và nếu bạn tự xây dựng trình phát podcast của riêng mình, bạn sẽ có thể sử dụng một mô hình Eloquent thực sự.
<?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',
],
// ...
];
}
The layout view
Chúng ta sẽ cần một tệp bố cục để tải Tailwind, thêm logo và thêm một số kiểu cơ bản. Vì Livewire và Alpine hiện tự động chèn mã script và kiểu dáng của chúng, chúng ta không cần phải tải chúng vào bố cục! Chúng ta sẽ tạo bố cục như một thành phần Blade vô danh trong tệp 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>
The episode list page
Trước tiên, chúng ta cần một trang để hiển thị tất cả các tập podcast.
Sử dụng Folio, chúng ta có thể dễ dàng tạo một trang mới trong thư mục resources/views/pages, và Laravel sẽ tự động tạo một route cho trang đó. Chúng ta muốn route của mình là /episodes, vì vậy chúng ta có thể chạy lệnh php artisan make:folio episodes/index. Điều đó sẽ tạo ra một view trống tại resources/views/pages/episodes/index.blade.php.
Trên trang này, chúng ta sẽ chèn thành phần bố cục, sau đó lặp qua tất cả các tập podcast. Volt cung cấp các hàm có namespace cho hầu hết các tính năng của Livewire. Ở đây, chúng ta sẽ mở và đóng các thẻ <?php ?> thông thường. Bên trong chúng, chúng ta sẽ sử dụng hàm computed để tạo một biến $episodes chạy một truy vấn để lấy tất cả các mô hình Episode ($episodes = computed(fn () => Episode::get());). Chúng ta có thể truy cập thuộc tính computed trong mẫu bằng cách sử dụng $this->episodes.
Tôi cũng đã tạo một biến $formatDuration là một hàm để định dạng thuộc tính duration_in_seconds của mỗi tập podcast thành định dạng dễ đọc. Chúng ta có thể gọi hàm đó trong mẫu bằng cách sử dụng $this->formatDuration($episode->duration_in_seconds).
Chúng ta cũng cần bao gói các chức năng động trên trang trong chỉ thị @volt để đăng ký chúng như một “anonymous Livewire component” trong trang 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>
The episode player
Từ đó, chúng ta cần thêm một số tính năng tương tác. Tôi muốn thêm một trình phát tập để chúng ta có thể nghe các tập từ danh sách các tập. Điều này có thể là một thành phần Blade thông thường chúng ta hiển thị trong tệp bố cục.
<!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>
Chúng ta có thể tạo thành phần đó bằng cách thêm một tệp resources/views/components/episode-player.blade.php. Bên trong thành phần, chúng ta sẽ thêm một phần tử <audio> với một số mã Alpine để lưu tập đang hoạt động và một hàm cập nhật tập đang hoạt động và bắt đầu âm thanh. Chúng ta chỉ hiển thị trình phát nếu đã đặt tập đang hoạt động và chúng ta sẽ thêm một hiệu ứng mờ đẹp vào bao bọc.
<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>
Nếu chúng ta tải lại trang, chúng ta sẽ không thấy bất kỳ thay đổi nào. Điều đó là do chúng ta chưa thêm cách để phát các tập. Chúng ta sẽ sử dụng các sự kiện để truyền thông từ các thành phần Livewire đến trình phát. Trước tiên, trong trình phát, chúng ta sẽ thêm x-on:play-episode.window=”play($event.detail)” để lắng nghe sự kiện play-episode trên cửa sổ, sau đó gọi hàm 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>
Tiếp theo, quay trở lại trang episodes/index, chúng ta sẽ thêm một lắng nghe sự kiện nhấp chuột vào các nút phát cho mỗi tập podcast. Các nút sẽ phát ra sự kiện play-episode, sự kiện này sẽ được nhận bởi trình phát tập podcast và được xử lý ở đó.
<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>
The episode details page
Tiếp theo, tôi muốn thêm một trang chi tiết tập để hiển thị các ghi chú của từng tập và các chi tiết khác.
Folio có một số quy ước thú vị cho việc ràng buộc mô hình route trong tên tệp. Để tạo một route tương đương cho /episodes/{episode:id}, hãy tạo một trang tại resources/views/pages/episodes/[Episode].blade.php. Để sử dụng một tham số route khác ngoài khóa chính, bạn có thể sử dụng cú pháp [Model:some_other_key].blade.php trong tên tệp. Tôi muốn sử dụng số tập trong URL, vì vậy chúng ta sẽ tạo một tệp tại resources/views/pages/episodes/[Episode:number].blade.php.
Folio sẽ tự động truy vấn các mô hình Episode cho một tập với số tập chúng ta truyền trong URL và sẽ làm cho nó có sẵn như một biến $episode trong mã <?php ?> của chúng ta. Sau đó, chúng ta có thể chuyển đổi nó thành một thuộc tính Livewire bằng cách sử dụng hàm state của Volt.
Chúng ta cũng sẽ bao gồm một nút phát trên trang này để người dùng có thể phát một tập podcast trong khi xem các chi tiết của nó.
<?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>
Bây giờ, chúng ta cần tạo liên kết đến trang chi tiết từ trang index. Trong trang episodes/index, hãy bao mỗi thẻ <h2> của từng tập podcast trong một thẻ anchor (anchor tag).
@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-mode
Chúng ta gần hoàn thành. Ứng dụng trông khá tốt và hoạt động tốt, nhưng có một vấn đề. Nếu người dùng đang nghe một tập podcast và điều hướng đến một trang khác, trình phát tập sẽ mất trạng thái tập đang hoạt động và biến mất.
May mắn thay, Livewire có các chỉ thị wire:navigate và @persist để giúp giải quyết những vấn đề này ngay bây giờ!
Trong tệp bố cục của chúng ta, hãy bọc logo và trình phát tập trong các khối @persist. Livewire sẽ phát hiện điều này và bỏ qua việc render lại những khối đó khi chúng ta chuyển trang.
<!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>
Cuối cùng, chúng ta cần thêm thuộc tính wire:navigate vào tất cả các liên kết trong ứng dụng. Ví dụ:
<a
href="/episodes/{{ $episode->number }}"
class="transition hover:text-[#FF2D20]"
wire:navigate
>
<h2>
No. {{ $episode->number }} -
{{ $episode->title }}
</h2>
</a>
Khi bạn sử dụng thuộc tính wire:navigate, Livewire sẽ lấy nội dung của trang mới bằng AJAX, sau đó tự động thay thế nội dung trong trình duyệt của bạn mà không cần tải lại toàn bộ trang. Điều này giúp việc tải trang trở nên vô cùng nhanh chóng và cho phép các tính năng như persist hoạt động! Nó cho phép các tính năng mà trước đây bạn chỉ có thể thực hiện bằng cách xây dựng một ứng dụng đơn trang (SPA).
Kết luận
Đây là một ứng dụng demo thú vị để xây dựng trong quá trình tìm hiểu Volt và Folio. Tôi đã tải ứng dụng demo lên đây và @bosunski đã tạo một phpsandbox nếu bạn muốn xem toàn bộ mã nguồn hoặc thử nó bằng chính tay của mình!
Bạn nghĩ sao về điều này? Liệu Livewire v3 + Volt + Folio có phải là ngăn xếp đơn giản nhất để xây dựng ứng dụng Laravel hiện nay? Tôi nghĩ nó thực sự tuyệt vời và có thể trở nên quen thuộc hơn với những người đã quen xây dựng ứng dụng trong các khung làm việc JavaScript như Next.js và Nuxt.js. Điều đó cũng thú vị khi tất cả mã của một trang đều được chung địa điểm – kiểu dáng (qua Tailwind), JS (qua Alpine), và mã backend – tất cả trong một tệp. Hãy gửi ý kiến của bạn cho tôi trên Twitter!