Senior: питання на співбесіді з теми «Події, Alpine і навігація»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
Livewire 4 замінив хуки commit і request з v3 на інтерсептори трьох рівнів.
| Рівень | Що це | API |
|---|---|---|
| дія | виклик одного методу (save, $refresh) |
$wire.intercept(), Livewire.interceptAction() |
| повідомлення | оновлення одного компонента (може містити кілька дій і змін властивостей) | $wire.interceptMessage(), Livewire.interceptMessage() |
| запит | HTTP-запит (може містити повідомлення кількох компонентів) | $wire.interceptRequest(), Livewire.interceptRequest() |
Кожен інтерсептор повертає функцію відписки.
Дія - найточніший рівень:
<script>
this.intercept('delete', ({ action, onSuccess, onError }) => {
if (! confirm('Видалити?')) {
action.cancel();
return;
}
onSuccess(() => showToast('Видалено'));
onError(({ preventDefault }) => {
preventDefault(); // не показувати модальне вікно помилки
showToast('Не вдалося видалити', 'error');
});
});
</script>
Колбеки: onSend, onCancel, onSuccess (з поверненим значенням методу), onError (відповідь з помилкою сервера), onFailure (мережева помилка), onFinish.
Повідомлення - доступ до корисного навантаження й етапів застосування відповіді: onSend({ payload }) (знімок, оновлення, виклики), onSuccess з вкладеними onSync, onEffect, onMorphed, onRender. Корисно, щоб ініціалізувати сторонні бібліотеки після морфінгу DOM.
Запит - глобальні речі на рівні HTTP:
Livewire.interceptRequest(({ onError }) => {
onError(({ response, preventDefault }) => {
if (response.status === 419) { // сесія чи CSRF-токен застаріли
preventDefault();
if (confirm('Сесія завершилася. Оновити сторінку?')) location.reload();
}
});
});
Також onRedirect (скасувати редирект), onDump (перехопити вивід dd()), onResponse, onStream.
Типові застосування:
- глобальна обробка помилок - 419 (сесія), 403, 500 у вигляді власного повідомлення замість типового модального вікна з HTML помилки;
- індикатори завантаження для складних випадків, де
wire:loadingзамало; - аналітика й моніторинг - час відповіді, помилки, назви дій;
- додаткові заголовки запиту на рівні застосунку.
Порядок для успішного повідомлення: onSuccess → onSync → onEffect → onMorph → onMorphed → onFinish → onRender (у наступному кадрі анімації). Код, що читає оновлений DOM, - в onMorphed, а не в onSuccess.
Що варто пам'ятати: інтерсептори - клієнтський код. Скасування дії в інтерсепторі (action.cancel()) - це UX, а не захист: дію можна викликати й без нього.
Звичайна дія Livewire повертає результат один раз, наприкінці. Для відповіді чат-бота, що генерується 20 секунд, користувач бачив би порожній екран усі 20 секунд. wire:stream дає змогу відправляти частини відповіді в браузер під час виконання дії.
new class extends Component {
public string $prompt = '';
public string $question = '';
public string $answer = '';
public function submitPrompt(): void
{
$this->question = $this->prompt;
$this->prompt = '';
$this->answer = '';
$this->js('$wire.ask()'); // друга дія - вже після того, як поле очистилося
}
public function ask(): void
{
// streamAnswer() - генератор над будь-яким клієнтом LLM зі стримінгом
foreach ($this->streamAnswer($this->question) as $chunk) {
$this->answer .= $chunk;
$this->stream(to: 'answer', content: $chunk);
}
}
};
<p wire:stream="answer">{{ $answer }}</p>
<form wire:submit="submitPrompt">
<input wire:model="prompt" placeholder="Ваше питання">
</form>
Як це працює: відповідь на запит ask() стає потоковою: кожен $this->stream() одразу відправляє шматок у браузер, і Livewire дописує його в елемент з wire:stream="answer". Коли метод завершується, приходить звичайна фінальна відповідь з повним станом компонента.
Параметри stream():
to:- назва цілі (значенняwire:streamв шаблоні);content:- текст;replace: true- замінити вміст замість дописування (для індикатора прогресу «Оброблено 40%»).
Чому дві дії (submitPrompt + ask): перша швидко повертає відповідь - поле введення очищується, питання з'являється на екрані. Друга вже стрімить відповідь.
Що враховувати в продакшені:
- процес PHP зайнятий весь час стримінгу. Двадцять секунд генерації - двадцять секунд зайнятого воркера PHP-FPM. При багатьох одночасних користувачах це вичерпує пул; доречні Octane/FrankenPHP чи винесення генерації в чергу з трансляцією результату через Reverb;
- буферизація по дорозі: проксі (Nginx
proxy_buffering, CDN), стиснення відповідей можуть накопичувати шматки й віддавати все наприкінці. Потрібно вимкнути буферизацію для цих запитів; - тайм-аути:
max_execution_time, тайм-аути проксі й балансувальника мають покривати найдовшу генерацію; - зберігати повний результат у властивості (
$this->answer .= $chunk) - інакше після фінальної відповіді та наступного рендеру текст зникне; - XSS: потоковий вміст вставляється як текст, але якщо ви рендерите відповідь як Markdown/HTML, санітизуйте її на сервері.
Альтернативи: response()->eventStream() з SSE у звичайному маршруті Laravel, якщо компонентна модель не потрібна.
З wire:navigate браузер фактично не залишає першу сторінку: замінюється вміст <body>, а JavaScript-оточення живе далі. Код, написаний для звичайних переходів, починає поводитися інакше.
1. DOMContentLoaded спрацьовує лише один раз. Ініціалізація на ньому не виконається на наступних сторінках:
// було
document.addEventListener('DOMContentLoaded', initTooltips);
// стало - спрацьовує і при першому завантаженні, і після кожного переходу
document.addEventListener('livewire:navigated', initTooltips);
2. Скрипти в <head> виконуються один раз. Нові скрипти, яких не було на попередній сторінці, - виконуються при переході й блокують перехід, поки не завантажаться.
3. Скрипти в <body> виконуються на кожній сторінці заново. Ініціалізація, що має бути одноразовою (аналітика, глобальні обробники), отримує атрибут data-navigate-once. Інакше - подвійні події аналітики й подвійні обробники.
4. Накопичення слухачів. Обробник на window чи document, доданий на кожній сторінці, після десятка переходів спрацьовує десять разів. Знімати в destroy() Alpine чи використовувати { once: true } для одноразових.
5. Застарілі ассети після деплою. Користувач не перезавантажує сторінку годинами і працює зі старим JavaScript. Атрибут data-navigate-track на тегах ассетів змушує Livewire зробити повне перезавантаження, якщо змінився рядок запиту в URL ассета. Директива @vite додає його автоматично.
6. Мерехтіння теми. Тема (dark) застосовується скриптом після завантаження - при навігації між сторінками з різними класами <html> інтерфейс «блимає». Застосовуйте її в livewire:navigating через e.detail.onSwap() - до того, як новий HTML з'явиться на екрані.
7. Аналітика. Перегляд сторінки треба відправляти на livewire:navigated, інакше аналітика бачить лише першу сторінку сесії.
8. Стан сторонніх бібліотек. Каруселі, карти, редактори з першої сторінки лишаються в пам'яті, якщо їх не знищити, - витоки пам'яті за годину роботи.
Події життєвого циклу навігації:
livewire:navigate- перехід почався (можна скасуватиpreventDefault());livewire:navigating- новий HTML отримано, перед заміною (місце дляonSwap);livewire:navigated- усе завершено (також на першому завантаженні).
Коли варто відмовитися від wire:navigate для посилання: вихід з облікового запису, перемикання мови, переходи між різними макетами чи застосунками - там надійніше звичайне перезавантаження.
Livewire 4 має вбудоване сортування перетягуванням - без сторонніх бібліотек.
<ul wire:sort="reorder">
@foreach ($this->tasks as $task)
<li wire:key="{{ $task->id }}" wire:sort:item="{{ $task->id }}">
<span wire:sort:handle>⋮⋮</span>
{{ $task->title }}
</li>
@endforeach
</ul>
Коли користувач відпускає елемент, Livewire викликає метод з ідентифікатором елемента (wire:sort:item) і новою позицією (з нуля). wire:sort:handle обмежує перетягування «ручкою».
Між кількома списками (канбан-дошка) - однакова група і ідентифікатор контейнера, що стає третім параметром:
<ul wire:sort="moveCard" wire:sort:group="cards" wire:sort:group-id="{{ $column->id }}">
Збереження порядку - ваша відповідальність, і тут легко помилитися:
public function reorder(int $taskId, int $position): void
{
$this->authorize('update', $this->project);
DB::transaction(function () use ($taskId, $position) {
$ids = $this->project->tasks()->orderBy('position')->lockForUpdate()->pluck('id');
abort_unless($ids->contains($taskId), 404); // лише задачі цього проєкту
$position = max(0, min($position, $ids->count() - 1));
$ordered = $ids->reject(fn ($id) => $id === $taskId)->values();
$ordered->splice($position, 0, [$taskId]);
foreach ($ordered as $index => $id) {
Task::whereKey($id)->update(['position' => $index]);
}
});
unset($this->tasks);
}
Що тут важливо:
- ідентифікатор і позиція - від клієнта. Без перевірки, що задача належить проєкту користувача, можна пересунути чужу задачу. Пошук через зв'язок (
$this->project->tasks()) чиfindOrFailв області видимості власника обов'язковий; - межі позиції - від'ємна чи завелика позиція не повинна ламати нумерацію;
- перенумерація в транзакції з блокуванням - два користувачі, що одночасно сортують один список, інакше отримають дублікати позицій;
- для довгих списків оновлювати лише зсунутий діапазон (
increment/decrementдля позицій між старою й новою), а не всі рядки; wire:keyна елементах обов'язковий - після відповіді сервера морфінг має зіставити елементи за ідентифікатором, а не за позицією;- для переміщення між колонками перевіряти й колонку призначення (третій параметр) - вона теж може бути чужою.
Альтернатива перенумерації - дробові чи «розріджені» позиції (крок 1000, вставка посередині між сусідами): одне оновлення замість багатьох, з періодичною перенумерацією.