Jak funguje Livewire 3: Stav na serveru, wire:click a reaktivní DOM
Livewire 3 přináší reaktivitu jednorázových Single Page Aplikací přímo do Laravelu bez psaní JavaScriptu. Naučte se, jak funguje stavový automat komponenty, životní cyklus požadavku wire:click a morfing DOMu.
Architektura Livewire 3: Žádné REST API ani manuální JSON synchronizace
Tradiční tvorba dynamických webových aplikací donedávna vyžadovala rozdělení projektu na dva světy: backendové REST/GraphQL API a klientskou SPA aplikaci (Vue, React, Angular). Tento přístup přináší obrovskou režii – duplikaci validačních pravidel, nutnost psát stavový management na klientovi (Pinia, Redux) a řešení tokenové autentizace.
Livewire 3 tento problém řeší revolučním způsobem: umožňuje psát plně reaktivní komponenty v čistém PHP a Blade šablonách. Komponenta běží na serveru, ale uživatel v prohlížeči zažívá okamžitou, plynulou interakci bez problikávání a reloadu stránky.
1. Stav komponenty a proces hydratace / dehydratace
Základem každé Livewire komponenty je třída dědící z Livewire\Component. Veřejné vlastnosti (public) této třídy představují stav komponenty:
class LessonQuiz extends Component
{
public CourseLesson $lesson;
public array $selectedAnswers = [];
public bool $submitted = false;
public int $scorePercent = 0;
// ...
}
Když se stránka poprvé načte (initial GET request):
- Laravel zavolá metodu
mount()komponenty, kde můžete inicializovat proměnné nebo načíst data. - Následně proběhne metoda
render(), která vygeneruje běžné HTML. - Livewire k tomuto HTML přibalí kryptograficky podepsaný JSON snapshot obsahující aktuální hodnoty veřejných vlastností (
dehydratace).
Jakmile uživatel v prohlížeči provede akci (například klikne na tlačítko označené wire:click="selectOption(1, 2)"):
- Klientská knihovna Livewire zachytí událost a odešle lehký AJAX POST požadavek na interní endpoint
/livewire/update. - Požadavek obsahuje payload se snapshotem původního stavu a informaci o volané metodě.
- Server ověří integritu snapshotu, zrekonstruuje stav komponenty do PHP objektu (
hydratace), provede volanou PHP metodu a znovu spustírender(). - Místo celé stránky vrátí server pouze inteligentní rozdílový balíček (diff).
2. Algoritmus DOM Morphing (Morphdom)
Když klientský JavaScript v prohlížeči obdrží odpověď ze serveru, nenahrazuje celý DOM element. Místo toho využívá integrovaný balíček Alpine Morph (vycházející z algoritmu Morphdom):
- Projde stávající strom DOM a porovná jej s nově vyrenderovaným HTML ze serveru.
- Změní výhradně ty textové uzly a atributy (např. CSS třídy pro barevné označení správné odpovědi), které se skutečně liší.
- Zachovává stav formulářových prvků, focus kurzoru i běžící CSS přechody (transitions).
3. Autowiring a Dependency Injection přímo v akcích
Všimněte si metody submitQuiz v našem souboru app/Livewire/LessonQuiz.php:
public function submitQuiz(StudentProgressService $progressService): void
{
// Livewire automaticky injektuje instanci StudentProgressService ze Service Containeru!
$progressService->recordQuizScore($profile, $this->lesson->id, $this->scorePercent);
}
V Livewire 3 nemusíte závislosti předávat přes konstruktor ani používat statické fasády. Laravel Service Container provede automatický autowiring typovaných parametrů přímo při zavolání akce z frontendu.
4. Indikátory načítání: wire:loading
Protože akce probíhá přes síť, je klíčové poskytnout uživateli okamžitou zpětnou vazbu. K tomu slouží direktivy wire:loading:
wire:loading.attr="disabled"zabrání vícenásobnému kliknutí během probíhajícího požadavku.wire:loading.removeawire:loading wire:target="submitQuiz"umožňují vyměnit text tlačítka (např. „Vyhodnotit kvíz“ za „Vyhodnocuji...“).
// app/Livewire/LessonQuiz.php
public function selectOption(int $questionId, int $optionIndex): void
{
// Změna veřejné vlastnosti vyvolá přerenderování komponenty a DOM morphing
$this->selectedAnswers[$questionId] = $optionIndex;
}
// Dependency Injection přímo do metody volané z wire:click="submitQuiz"
public function submitQuiz(StudentProgressService $progressService): void
{
$questions = $this->lesson->quizQuestions;
$totalQuestions = $questions->count();
if ($totalQuestions === 0) {
return;
}
$correct = 0;
foreach ($questions as $question) {
$selected = $this->selectedAnswers[$question->id] ?? null;
if ($selected !== null && (int) $selected === (int) $question->correct_option) {
$correct++;
}
}
$this->correctCount = $correct;
$this->scorePercent = (int) round(($correct / $totalQuestions) * 100);
$token = request()->cookie(StudentProgressService::COOKIE_NAME);
$profile = $progressService->resolveProfile(is_string($token) ? $token : null);
// Doménová logika uložení výsledku
$progressService->recordQuizScore($profile, $this->lesson->id, $this->scorePercent);
$this->submitted = true;
$this->isCompleted = $profile->hasCompletedLesson($this->lesson->id);
$this->bestScore = $profile->getQuizScore($this->lesson->id);
}
🎯 Ověření znalostí z lekce
Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
Co se stane na síťové a serverové úrovni při spuštění direktivy wire:click="toggleComplete"?
Jak Livewire 3 řeší Dependency Injection u metod spouštěných přes wire:click (např. submitQuiz(StudentProgressService $progressService))?
Které vlastnosti třídy Livewire komponenty tvoří její klientský stav a jsou přenášeny v dehydratovaném snapshotu?
| 1 | <?php |
| 2 | |
| 3 | declare(strict_types=1); |
| 4 | |
| 5 | namespace App\Livewire; |
| 6 | |
| 7 | use App\Models\CourseLesson; |
| 8 | use App\Models\QuizQuestion; |
| 9 | use App\Services\StudentProgressService; |
| 10 | use Illuminate\Contracts\View\View; |
| 11 | use Livewire\Component; |
| 12 | |
| 13 | class LessonQuiz extends Component |
| 14 | { |
| 15 | public CourseLesson $lesson; |
| 16 | |
| 17 | /** |
| 18 | * Map of question ID => selected option index. |
| 19 | * |
| 20 | * @var array<int, int> |
| 21 | */ |
| 22 | public array $selectedAnswers = []; |
| 23 | |
| 24 | public bool $submitted = false; |
| 25 | |
| 26 | public int $scorePercent = 0; |
| 27 | |
| 28 | public int $correctCount = 0; |
| 29 | |
| 30 | public bool $isCompleted = false; |
| 31 | |
| 32 | public ?int $bestScore = null; |
| 33 | |
| 34 | public function mount(CourseLesson $lesson, StudentProgressService $progressService): void |
| 35 | { |
| 36 | $this->lesson = $lesson->loadMissing('quizQuestions'); |
| 37 | |
| 38 | $token = request()->cookie(StudentProgressService::COOKIE_NAME); |
| 39 | $profile = $progressService->resolveProfile(is_string($token) ? $token : null); |
| 40 | |
| 41 | $this->isCompleted = $profile->hasCompletedLesson($this->lesson->id); |
| 42 | $this->bestScore = $profile->getQuizScore($this->lesson->id); |
| 43 | } |
| 44 | |
| 45 | public function selectOption(int $questionId, int $optionIndex): void |
| 46 | { |
| 47 | $this->selectedAnswers[$questionId] = $optionIndex; |
| 48 | } |
| 49 | |
| 50 | public function submitQuiz(StudentProgressService $progressService): void |
| 51 | { |
| 52 | $questions = $this->lesson->quizQuestions; |
| 53 | $totalQuestions = $questions->count(); |
| 54 | |
| 55 | if ($totalQuestions === 0) { |
| 56 | return; |
| 57 | } |
| 58 | |
| 59 | $correct = 0; |
| 60 | /** @var QuizQuestion $question */ |
| 61 | foreach ($questions as $question) { |
| 62 | $selected = $this->selectedAnswers[$question->id] ?? null; |
| 63 | if ($selected !== null && (int) $selected === (int) $question->correct_option) { |
| 64 | $correct++; |
| 65 | } |
| 66 | } |
| 67 | |
| 68 | $this->correctCount = $correct; |
| 69 | $this->scorePercent = (int) round(($correct / $totalQuestions) * 100); |
| 70 | |
| 71 | $token = request()->cookie(StudentProgressService::COOKIE_NAME); |
| 72 | $profile = $progressService->resolveProfile(is_string($token) ? $token : null); |
| 73 | |
| 74 | $progressService->recordQuizScore($profile, $this->lesson->id, $this->scorePercent); |
| 75 | |
| 76 | $this->submitted = true; |
| 77 | $this->isCompleted = $profile->hasCompletedLesson($this->lesson->id); |
| 78 | $this->bestScore = $profile->getQuizScore($this->lesson->id); |
| 79 | } |
| 80 | |
| 81 | public function toggleComplete(StudentProgressService $progressService): void |
| 82 | { |
| 83 | $token = request()->cookie(StudentProgressService::COOKIE_NAME); |
| 84 | $profile = $progressService->resolveProfile(is_string($token) ? $token : null); |
| 85 | |
| 86 | $result = $progressService->toggleLessonCompleted($profile, $this->lesson->id); |
| 87 | |
| 88 | if (is_bool($result)) { |
| 89 | $this->isCompleted = $result; |
| 90 | } else { |
| 91 | $this->isCompleted = $profile->hasCompletedLesson($this->lesson->id); |
| 92 | } |
| 93 | } |
| 94 | |
| 95 | public function resetQuiz(): void |
| 96 | { |
| 97 | $this->selectedAnswers = []; |
| 98 | $this->submitted = false; |
| 99 | $this->scorePercent = 0; |
| 100 | $this->correctCount = 0; |
| 101 | } |
| 102 | |
| 103 | public function render(): View |
| 104 | { |
| 105 | return view('livewire.lesson-quiz'); |
| 106 | } |
| 107 | } |
| 108 | |