Fáze požadavku: 9. Blade & Livewire prezentace
Modul 5 · Lekce 1

Jak funguje Livewire 3: Stav na serveru, wire:click a reaktivní DOM

💡 Shrnutí lekce

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):

  1. Laravel zavolá metodu mount() komponenty, kde můžete inicializovat proměnné nebo načíst data.
  2. Následně proběhne metoda render(), která vygeneruje běžné HTML.
  3. 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)"):

  1. Klientská knihovna Livewire zachytí událost a odešle lehký AJAX POST požadavek na interní endpoint /livewire/update.
  2. Požadavek obsahuje payload se snapshotem původního stavu a informaci o volané metodě.
  3. Server ověří integritu snapshotu, zrekonstruuje stav komponenty do PHP objektu (hydratace), provede volanou PHP metodu a znovu spustí render().
  4. 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.remove a wire:loading wire:target="submitQuiz" umožňují vyměnit text tlačítka (např. „Vyhodnotit kvíz“ za „Vyhodnocuji...“).
💻 Klíčová konstrukce z praxe
// 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.

Otázka 1 z 3

Co se stane na síťové a serverové úrovni při spuštění direktivy wire:click="toggleComplete"?

Otázka 2 z 3

Jak Livewire 3 řeší Dependency Injection u metod spouštěných přes wire:click (např. submitQuiz(StudentProgressService $progressService))?

Otázka 3 z 3

Které vlastnosti třídy Livewire komponenty tvoří její klientský stav a jsou přenášeny v dehydratovaném snapshotu?

Zodpovězeno: 0 / 3
🔬 Živá anatomie v této aplikaci
2 soubory
Na co se v kódu zaměřit: V souboru app/Livewire/LessonQuiz.php si všimněte řádků 45–79. Metody selectOption() a submitQuiz() mění veřejný stav komponenty. V šabloně resources/views/livewire/lesson-quiz.blade.php prozkoumejte direktivy wire:click a wire:loading.attr="disabled" na řádcích 24–37 a 109–128.
cesta: app/Livewire/LessonQuiz.php
107 řádků · 3.1 KB
Reaktivní Livewire 3 komponenta pro interaktivní testování znalostí s okamžitým vyhodnocením a záznamem skóre.
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
cesta: resources/views/livewire/lesson-quiz.blade.php
198 řádků · 11.5 KB
Soubor aplikační architektury v modulu Blade View.
1 <div class="rounded-2xl bg-slate-900 border border-slate-800 p-6 space-y-6 shadow-xl">
2
3 {{-- Horní stavová lišta dokončení lekce a skóre --}}
4 <div class="flex flex-wrap items-center justify-between gap-4 pb-4 border-b border-slate-800">
5 <div>
6 <h3 class="text-base font-bold text-white flex items-center gap-2">
7 <span>🎯</span>
8 <span>Ověření znalostí z lekce</span>
9 </h3>
10 <p class="text-xs text-slate-400 mt-0.5">
11 Kvíz otestuje pochopení architektury a reálného kódu probíraného v této lekci.
12 </p>
13 </div>
14
15 <div class="flex items-center gap-3">
16 @if($bestScore !== null)
17 <div class="px-3 py-1 rounded-lg bg-amber-500/10 border border-amber-500/20 text-xs font-mono text-amber-300">
18 Nejlepší skóre: <strong class="font-bold">{{ $bestScore }}%</strong>
19 </div>
20 @endif
21
22 {{-- Tlačítko pro ruční označení / odznačení lekce --}}
23 <button type="button"
24 wire:click="toggleComplete"
25 wire:loading.attr="disabled"
26 class="inline-flex items-center gap-2 px-3.5 py-1.5 rounded-lg text-xs font-semibold transition focus:outline-none focus:ring-2 focus:ring-indigo-500 {{ $isCompleted ? 'bg-emerald-600 hover:bg-emerald-500 text-white shadow-sm' : 'bg-slate-800 hover:bg-slate-700 text-slate-200 border border-slate-700' }}">
27 <span wire:loading.remove wire:target="toggleComplete">
28 @if($isCompleted)
29 <span>✓ Lekce dokončena</span>
30 @else
31 <span>Označit jako dokončené</span>
32 @endif
33 </span>
34 <span wire:loading wire:target="toggleComplete" class="text-xs">
35 Ukládám...
36 </span>
37 </button>
38 </div>
39 </div>
40
41 {{-- Kvízové otázky --}}
42 @if($lesson->quizQuestions->isEmpty())
43 <div class="p-6 text-center rounded-xl bg-slate-950 border border-slate-800 space-y-2">
44 <p class="text-xs text-slate-400">
45 Tato lekce neobsahuje samostatný kvíz. Můžete ji rovnou označit jako dokončenou tlačítkem výše.
46 </p>
47 </div>
48 @else
49 <div class="space-y-6">
50 @foreach($lesson->quizQuestions as $qIndex => $question)
51 @php
52 $userAnswer = $selectedAnswers[$question->id] ?? null;
53 $isCorrect = $submitted && $userAnswer !== null && (int) $userAnswer === (int) $question->correct_option;
54 $isIncorrect = $submitted && $userAnswer !== null && (int) $userAnswer !== (int) $question->correct_option;
55 @endphp
56 <div class="rounded-xl bg-slate-950 border {{ $submitted ? ($isCorrect ? 'border-emerald-800/80 bg-emerald-950/20' : ($isIncorrect ? 'border-rose-800/80 bg-rose-950/20' : 'border-slate-800')) : 'border-slate-800' }} p-5 space-y-4">
57 {{-- Text otázky --}}
58 <div class="flex items-start justify-between gap-4">
59 <div class="space-y-1">
60 <span class="text-[11px] font-mono font-bold text-indigo-400 uppercase tracking-wider">
61 Otázka {{ $qIndex + 1 }} z {{ $lesson->quizQuestions->count() }}
62 </span>
63 <h4 class="text-sm font-semibold text-white">
64 {{ $question->question }}
65 </h4>
66 </div>
67
68 @if($submitted)
69 @if($isCorrect)
70 <span class="shrink-0 px-2 py-0.5 rounded text-[10px] font-bold bg-emerald-500/20 text-emerald-300 border border-emerald-500/30">
71 ✓ Správně
72 </span>
73 @elseif($isIncorrect)
74 <span class="shrink-0 px-2 py-0.5 rounded text-[10px] font-bold bg-rose-500/20 text-rose-300 border border-rose-500/30">
75 ✗ Chybně
76 </span>
77 @else
78 <span class="shrink-0 px-2 py-0.5 rounded text-[10px] font-bold bg-slate-800 text-slate-400">
79 Nezodpovězeno
80 </span>
81 @endif
82 @endif
83 </div>
84
85 {{-- Možnosti odpovědí --}}
86 <div class="space-y-2">
87 @foreach($question->options as $optIndex => $optionText)
88 @php
89 $isSelected = $userAnswer !== null && (int) $userAnswer === (int) $optIndex;
90 $isThisCorrect = (int) $optIndex === (int) $question->correct_option;
91
92 if ($submitted) {
93 if ($isThisCorrect) {
94 $btnClass = 'bg-emerald-950/60 border-emerald-500 text-emerald-200 ring-1 ring-emerald-500/40 font-medium';
95 } elseif ($isSelected && ! $isThisCorrect) {
96 $btnClass = 'bg-rose-950/60 border-rose-500 text-rose-200 ring-1 ring-rose-500/40';
97 } else {
98 $btnClass = 'bg-slate-900/40 border-slate-800 text-slate-500 opacity-60 cursor-not-allowed';
99 }
100 } else {
101 if ($isSelected) {
102 $btnClass = 'bg-indigo-950/80 border-indigo-500 text-white font-medium ring-1 ring-indigo-500/50';
103 } else {
104 $btnClass = 'bg-slate-900 border-slate-800 hover:border-slate-700 text-slate-300 hover:text-white';
105 }
106 }
107 @endphp
108
109 <button type="button"
110 wire:click="selectOption({{ $question->id }}, {{ $optIndex }})"
111 @if($submitted) disabled @endif
112 class="w-full text-left p-3 rounded-lg border text-xs transition flex items-center justify-between gap-3 {{ $btnClass }}">
113 <div class="flex items-center gap-3">
114 <span class="w-5 h-5 rounded-full flex items-center justify-center font-mono text-[10px] shrink-0 {{ $isSelected ? 'bg-indigo-500 text-white font-bold' : 'bg-slate-800 text-slate-400' }}">
115 {{ chr(65 + $optIndex) }}
116 </span>
117 <span>{{ $optionText }}</span>
118 </div>
119
120 @if($submitted)
121 @if($isThisCorrect)
122 <span class="text-emerald-400 font-bold shrink-0">✓</span>
123 @elseif($isSelected && ! $isThisCorrect)
124 <span class="text-rose-400 font-bold shrink-0">✗</span>
125 @endif
126 @endif
127 </button>
128 @endforeach
129 </div>
130
131 {{-- Vysvětlení správné odpovědi po odeslání --}}
132 @if($submitted && !empty($question->explanation))
133 <div class="mt-3 p-3 rounded-lg bg-slate-900 border border-slate-800 text-xs text-slate-300 space-y-1">
134 <span class="text-[11px] font-semibold text-amber-400 block font-mono">
135 💡 Architektonické vysvětlení:
136 </span>
137 <p class="leading-relaxed text-slate-300">
138 {{ $question->explanation }}
139 </p>
140 </div>
141 @endif
142 </div>
143 @endforeach
144 </div>
145
146 {{-- Akce kvízu: Vyhodnotit / Výsledky / Zkusit znovu --}}
147 <div class="pt-4 border-t border-slate-800">
148 @if(! $submitted)
149 <div class="flex flex-wrap items-center justify-between gap-4">
150 <span class="text-xs text-slate-400 font-mono">
151 Zodpovězeno: {{ count($selectedAnswers) }} / {{ $lesson->quizQuestions->count() }}
152 </span>
153
154 <button type="button"
155 wire:click="submitQuiz"
156 wire:loading.attr="disabled"
157 @if(count($selectedAnswers) === 0) disabled @endif
158 class="px-5 py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 disabled:bg-slate-800 disabled:text-slate-500 disabled:cursor-not-allowed text-white font-semibold text-xs transition shadow-sm flex items-center gap-2">
159 <span wire:loading.remove wire:target="submitQuiz">Vyhodnotit kvíz</span>
160 <span wire:loading wire:target="submitQuiz">Vyhodnocuji...</span>
161 <span>&rarr;</span>
162 </button>
163 </div>
164 @else
165 <div class="rounded-xl bg-slate-950 border border-slate-800 p-5 space-y-4">
166 <div class="flex flex-wrap items-center justify-between gap-4">
167 <div>
168 <div class="text-xs text-slate-400">Výsledek kvízu:</div>
169 <div class="text-2xl font-bold font-mono {{ $scorePercent >= 70 ? 'text-emerald-400' : 'text-amber-400' }}">
170 {{ $scorePercent }}%
171 <span class="text-xs text-slate-400 font-normal ml-2">
172 ({{ $correctCount }} ze {{ $lesson->quizQuestions->count() }} správně)
173 </span>
174 </div>
175 </div>
176
177 <button type="button"
178 wire:click="resetQuiz"
179 class="px-4 py-2 rounded-lg bg-slate-800 hover:bg-slate-700 text-xs font-semibold text-slate-200 transition">
180 🔄 Zkusit kvíz znovu
181 </button>
182 </div>
183
184 <p class="text-xs text-slate-300 leading-relaxed">
185 @if($scorePercent === 100)
186 🎉 <strong class="text-white">Perfektní práce!</strong> Všechny otázky máte správně. Architekturu v této lekci ovládáte na výbornou.
187 @elseif($scorePercent >= 70)
188 👍 <strong class="text-white">Skvělý výsledek!</strong> Kvíz jste úspěšně zvládli. Pokud chcete dosáhnout 100 %, projděte si vysvětlení výše a zkuste kvíz znovu.
189 @else
190 📖 <strong class="text-amber-300">Doporučujeme zopakovat.</strong> Prohlédněte si zdrojové kódy v pravém panelu anatomie a zkuste kvíz znovu.
191 @endif
192 </p>
193 </div>
194 @endif
195 </div>
196 @endif
197
198 </div>
199
Zdroj: skutečné soubory této aplikace Plný průzkumník anatomie →