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

Middle: питання на співбесіді з теми «Події, Alpine і навігація»

Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.

4 питання

Події Livewire - це події браузера, тож їх бачать і Alpine, і будь-який JavaScript на сторінці.

З PHP у JavaScript. Подія, відправлена з компонента, доходить у браузер:

$this->dispatch('notify', message: 'Збережено', type: 'success');
{{-- Alpine-компонент сповіщень у макеті --}}
<div x-data="{ items: [] }"
     x-on:notify.window="items.push($event.detail)">
    ...
</div>
// звичайний JavaScript
Livewire.on('notify', ({ message, type }) => showToast(message, type));

З JavaScript у Livewire. Компонент з #[On('cart-updated')] отримає подію, відправлену будь-звідки:

<button x-on:click="$dispatch('cart-updated', { count: 3 })">...</button>
Livewire.dispatch('cart-updated', { count: 3 });
Livewire.dispatchTo('cart-badge', 'cart-updated', { count: 3 });

У скрипті компонента (Livewire 4: тег <script> у шаблоні, this - це $wire):

<script>
    this.$on('post-created', () => { /* ... */ });
    this.$dispatchSelf('refresh');
</script>

Події браузера на корені компонента - wire:назва-події:

<div wire:custom-event="handle">
    <button x-on:click="$dispatch('custom-event')">...</button>   {{-- спливе до кореня --}}
</div>
<div wire:custom-event.window="handle">   {{-- подія з будь-якого місця сторінки --}}

Безпека - головне. Слухач #[On] - це метод, який будь-хто може викликати з консолі браузера з довільними параметрами: Livewire.dispatch('order-paid', { orderId: 5 }). Тож слухачі мають перевіряти права й дані так само, як дії. Подія order-paid, що сама по собі змінює статус замовлення, - діра.

Прибирання слухачів. Livewire.on() повертає функцію для відписки. В Alpine-компонентах, що живуть із wire:navigate, слухачі треба знімати в destroy(), інакше після кожного переходу вони накопичуватимуться:

Alpine.data('cartBadge', () => ({
  off: null,
  init() { this.off = Livewire.on('cart-updated', ({ count }) => (this.count = count)); },
  destroy() { this.off?.(); },
}));

Трансляція з сервера (Echo) - слухачі виду #[On('echo:orders,OrderShipped')] отримують події WebSocket від Laravel Reverb чи Pusher без власного JavaScript.

Докладніше в документації: Події: JavaScript і Alpine

При SPA-переході через wire:navigate вміст <body> замінюється новим. Але деякі елементи не повинні перестворюватися: аудіо- чи відеоплеєр, що грає, чат-віджет, бічна панель з прокручуванням.

@persist позначає елемент, який Livewire переносить на нову сторінку замість заміни:

{{-- resources/views/layouts/app.blade.php --}}
<body>
    <main>{{ $slot }}</main>

    @persist('player')
        <audio src="{{ $episode->file }}" controls></audio>
    @endpersist

    @livewireScripts
</body>

Якщо на новій сторінці є @persist з тією самою назвою, Livewire бере існуючий DOM-елемент зі старої сторінки й ставить на місце нового. Відтворення не переривається, зберігаються стан Alpine, обробники подій і значення полів.

Умови:

  • працює лише з wire:navigate - при звичайному переході чи оновленні сторінки все завантажується заново;
  • елемент з тією самою назвою має бути на обох сторінках. Найпростіше - розмістити його в спільному макеті;
  • розміщувати поза Livewire-компонентами сторінки, зазвичай прямо в макеті. Вміст, який має оновлюватися з сервера, не варто «заморожувати».

Прокрутка всередині збереженого елемента (довга бічна навігація) - wire:navigate:scroll:

@persist('sidebar')
    <aside class="overflow-y-auto" wire:navigate:scroll>...</aside>
@endpersist

У Livewire 3 для цього був wire:scroll - при оновленні до v4 його треба перейменувати.

Підводні камені:

  • збережений елемент не оновлюється. Якщо в ньому показано «Активний пункт меню» чи лічильник, після переходу вони лишаться старими. Активне посилання в збереженій навігації позначається через data-current / wire:current - Livewire оновлює їх при навігації;
  • назва має бути унікальною й стабільною - різні елементи з однією назвою на різних сторінках підмінять один одного;
  • персональні дані в збереженому елементі (наприклад, після виходу з облікового запису через wire:navigate) переживуть перехід - вихід краще робити повним перезавантаженням сторінки.

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

Сторонні бібліотеки (редактори тексту, вибір дати, карти, графіки) самі змінюють DOM: додають елементи, класи, атрибути. Після наступного запиту Livewire морфить DOM за новим HTML із сервера - і затирає все, що побудувала бібліотека.

wire:ignore каже Livewire не чіпати вміст елемента при оновленнях:

<div wire:ignore>
    <div x-data x-init="initEditor($el)"></div>
</div>

wire:ignore.self - ігнорувати зміни лише атрибутів самого елемента, а вміст оновлювати як звичайно.

Типовий шаблон інтеграції через Alpine:

<div wire:ignore
     x-data="{ value: $wire.entangle('content') }"
     x-init="
        const editor = new Editor($refs.editor, { content: value });
        editor.on('change', () => $wire.content = editor.getHTML());
     ">
    <div x-ref="editor"></div>
</div>

Краще без entangle - напряму через $wire:

<div wire:ignore
     x-data
     x-init="
        const picker = flatpickr($refs.input, {
            defaultDate: $wire.date,
            onChange: (dates, str) => $wire.date = str,
        });
     ">
    <input x-ref="input" type="text">
</div>

Що варто врахувати:

  • напрям «сервер → бібліотека»: якщо сервер змінив значення (скинули форму), бібліотека під wire:ignore про це не знає. Потрібна подія ($this->dispatch('editor-reset')) чи $wire.$watch('date', ...) в Alpine;
  • частота запитів: $wire.content = ... лише змінює значення в браузері, на сервер воно піде з наступною дією. $wire.set('content', value) надсилає запит одразу - для редактора тексту це запит на кожне натискання клавіші;
  • знищення: бібліотеки, що підписуються на window/document, мають прибиратися в destroy() Alpine-компонента - інакше з wire:navigate слухачі накопичуються;
  • wire:key, якщо таких полів кілька в циклі, - щоб при зміні списку бібліотека не опинилася «прив'язаною» до чужого рядка;
  • власне поле з wire:model: обгортку можна оформити Blade-компонентом з x-modelable, тоді в шаблонах вистачить <x-date-picker wire:model="date" />.

Альтернатива - бібліотеки, створені для Livewire/Alpine (Flux, Alpine-плагіни), які вже вміють жити з морфінгом.

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

Livewire дає два способи виконати JavaScript, і вони вирішують різні задачі.

1. #[Js] - метод, що виконується в браузері без запиту. PHP-метод повертає рядок JavaScript; при виклику з шаблону сервер не задіюється:

use Livewire\Attributes\Js;

new class extends Component {
    public string $title = '';
    public string $content = '';

    #[Js]
    public function clearForm(): string
    {
        return <<<'JS'
            $wire.title = ''
            $wire.content = ''
        JS;
    }
};
<button type="button" wire:click="clearForm">Очистити</button>

Очищення полів відбувається миттєво. Код JavaScript потрапляє в сторінку під час рендеру, тож на клік не потрібен мережевий запит.

2. $this->js() - виконати JavaScript після дії на сервері:

public function save(): void
{
    $this->form->save();

    $this->js("document.getElementById('comment-form').reset()");
    $this->js('$wire.$refresh()');
}

Код ставиться в чергу й виконується, коли відповідь прийде в браузер.

3. Скрипти в шаблоні (Livewire 4): тег <script> у шаблоні однофайлового чи багатофайлового компонента виконується з this = $wire, без обгортки @script:

<script>
    this.$js.focusSearch = () => this.$refs.search.focus();
</script>

<input wire:ref="search">
<button type="button" wire:click="$js.focusSearch">Пошук</button>

Такі скрипти віддаються окремими кешованими файлами.

Коли що:

  • суто клієнтська дія (очистити, згорнути, сфокусуватися) - #[Js] чи $js у скрипті компонента;
  • реакція на результат серверної дії (прокрутити до нового коментаря, закрити вікно, сповіщення) - $this->js() або подія, яку слухає JavaScript;
  • переважно клієнтська логіка - Alpine, а не JavaScript, згенерований у PHP.

Пастки:

  • не вставляйте дані користувача в рядок JavaScript - $this->js("alert('{$this->name}')") з ім'ям '); stealCookies(); (' виконає чужий код. Дані передавайте через подію з параметрами чи Js::from($value) для безпечного екранування;
  • CSP: з увімкненим csp_safe складні вирази JavaScript у директивах обмежені - перевірте сумісність, якщо сайт використовує суворий Content Security Policy;
  • #[Js]-метод не має доступу до PHP-стану на момент кліку - лише до того, що є в $wire у браузері.

Докладніше в документації: Атрибут Js