Увійти Реєстрація
Блог Серії
Кар'єра
Вакансії Компанії
Навчання
Документація Співбесіди Тестування Відео
Екосистема
Пакети Ресурси Проєкти Інструменти Події
Інше
Про нас Реклама

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, а не захист: дію можна викликати й без нього.

Докладніше в документації: JavaScript: інтерсептори

Звичайна дія 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:stream

З 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, вставка посередині між сусідами): одне оновлення замість багатьох, з періодичною перенумерацією.

Докладніше в документації: wire:sort