Livewire і Filament: питання на співбесіді рівня Senior
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
36 питань
Після кожної дії сервер повертає новий HTML компонента. Livewire не замінює ним старий цілком, а морфить: проходить обидва дерева елемент за елементом і змінює лише те, що відрізняється. Завдяки цьому зберігаються фокус, введений текст, позиція прокрутки й обробники подій.
Де морфінг помиляється. Алгоритм порівнює елементи за позицією. Якщо між ними з'являється новий елемент, він може «зсунути» відповідність:
<div><input wire:model="title"></div>
@if ($errors->has('title'))
<div>{{ $errors->first('title') }}</div>
@endif
<div><button>Зберегти</button></div>
Коли з'являється помилка, Livewire бачить на другому місці новий div і може вирішити, що це змінений старий, - результат: зайві перерисовки, втрачений стан сторонніх елементів. Livewire додає в шаблон службові маркери навколо @if і @foreach, щоб це згладити, але найнадійніший інструмент - ключі.
wire:key - ідентичність елемента:
@foreach ($todos as $todo)
<li wire:key="todo-{{ $todo->id }}">...</li>
@endforeach
Morph зіставляє елементи за ключем, а не за позицією: видалення першого рядка не змушує «перейменовувати» всі наступні. Для дочірніх компонентів у циклі ключ обов'язковий.
wire:ignore - не чіпати вміст елемента взагалі:
<div wire:ignore>
<div x-init="new Chart($el, ...)"></div>
</div>
Для сторонніх бібліотек (редактори, графіки, карти), які самі керують своїм DOM: інакше Livewire «виправить» їхні зміни назад до HTML з сервера. wire:ignore.self - ігнорувати атрибути самого елемента, але оновлювати дітей.
wire:replace - протилежне: не морфити, а замінити вміст повністю:
<div wire:replace>
<json-viewer>@json($payload)</json-viewer>
</div>
Для веб-компонентів і елементів зі своїм внутрішнім станом, який треба скинути при кожному оновленні. wire:replace.self - замінити й сам елемент.
Типові симптоми й причини:
| Симптом | Причина |
|---|---|
| введене «переїжджає» в інше поле | немає wire:key у циклі |
| сторонній віджет зникає чи ламається після дії | немає wire:ignore |
| елемент зберігає старий стан, хоча дані змінилися | морф перевикористав елемент - wire:key зі зміною ключа чи wire:replace |
| дочірній компонент не перестворюється | той самий wire:key - змінити ключ, щоб Livewire створив новий екземпляр |
Продуктивність: морфінг великого HTML (таблиця на тисячі рядків) коштує і на сервері (рендер), і в браузері (порівняння). Острівці, ліниві компоненти й пагінація зменшують обсяг, який морфиться за одну дію.
Filament будує таблицю з Eloquent-запиту, тож усі звичайні проблеми продуктивності лишаються - але ховаються за декларативним синтаксисом.
N+1 у колонках. Колонка зі звʼязком робить запит на рядок:
TextColumn::make('company.name') // + 1 запит на кожен рядок
Лікується модифікацією запиту таблиці:
public static function getEloquentQuery(): Builder
{
return parent::getEloquentQuery()->with(['company', 'technologies']);
}
Лічильники. ->counts('vacancies') замість завантаження всієї колекції заради count().
Дорогі обчислення в state(). Замикання виконується для кожного рядка, тож звернення до бази всередині - те саме N+1, лише написане інакше.
Сортування й пошук за звʼязком роблять join, і без індексу на зовнішньому ключі це помітно:
TextColumn::make('company.name')
->searchable() // where по приєднаній таблиці
->sortable()
Що ще сповільнює адмінку:
->paginated([10, 25, 50, 'all'])з опцієюallна великій таблиці вивантажує все в памʼять одним кліком користувача.- Глобальний пошук по багатьох ресурсах виконує запит на кожен зареєстрований ресурс.
- Мініатюри без кешу:
ImageColumn, що генерує прев'ю на льоту, робить це для кожного рядка. - Опції
Selectбез->searchable()вивантажують усі записи довідника у форму.
Як шукати причину: Debugbar або Telescope на сторінці ресурсу покажуть кількість запитів. Таблиця на 25 рядків має вкладатися в одиниці запитів; десятки - ознака незавантаженого звʼязку.
Livewire відповідає за серверний стан і рендер, Alpine - за миттєву взаємодію в браузері без запитів: розкриття меню, перемикання вкладок, анімації.
$wire - JavaScript-представлення компонента, доступне в Alpine. Через нього читають і змінюють властивості, викликають методи:
<div x-data>
<span x-text="$wire.count"></span>
<button x-on:click="$wire.count++">+1 локально</button>
<button x-on:click="$wire.save()">Зберегти на сервері</button>
</div>
Зміна $wire.count оновлює значення в браузері, а на сервер воно потрапить з наступним запитом. Для негайної синхронізації - $wire.$set('count', 5).
$wire.entangle() створює двосторонньо синхронізовану копію властивості в даних Alpine. Документація Livewire прямо не радить його для нового коду: дублювання стану робить поведінку менш передбачуваною й може шкодити продуктивності. Директива @entangle застаріла і ламається при видаленні елементів з DOM.
{{-- Замість entangle --}}
<div x-data="{ open: $wire.entangle('showDropdown') }">
{{-- Читайте властивість напряму --}}
<div x-data x-show="$wire.showDropdown">
Як розділяти відповідальність:
- Суто візуальний стан (відкрите меню, активна вкладка) - лише в Alpine, сервер про нього не знає.
- Дані, які зберігаються чи валідуються, - у Livewire.
- Дія, що не потребує сервера, -
$wire.$jsабо звичайна Alpine-логіка, а не запит.
Межа між ними прямо впливає на швидкодію: кожна дія через сервер - мережевий запит і рендер.
Сервер не тримає компонент між запитами. Після кожного запиту Livewire дегідрує компонент - рендерить HTML і створює JSON-знімок стану, а на наступному запиті гідрує - відтворює компонент зі знімка.
Знімок вбудовується в HTML атрибутом wire:snapshot:
{
"state": { "count": 1, "post": [null, { "class": "post", "key": 7, "s": "mdl" }] },
"memo": { "name": "counter", "id": "1526456", "children": [], "errors": [] },
"checksum": "9f2c..."
}
state- публічні властивості; складні типи - кортежами з метаданими (s- ключ синтезатора);memo- назва компонента, id, дочірні компоненти, помилки валідації тощо;checksum- HMAC-SHA256 від знімка з ключемAPP_KEY.
Що захищає контрольна сума. На кожному запиті сервер перераховує HMAC і порівнює. Змінений вручну знімок (інший id моделі, інша назва компонента) дає CorruptComponentPayloadException. Після 10 невдалих перевірок з однієї IP-адреси за 10 хвилин Livewire блокує подальші спроби. Без знання APP_KEY підробити знімок неможливо - тому витік APP_KEY критичний і для Livewire.
Що вона НЕ захищає. Запит містить не лише знімок, а й оновлення (updates) - нові значення властивостей від wire:model, - і виклики методів. Вони не підписані: це звичайне введення користувача, яке накладається на відновлений компонент. Тому:
- будь-яку незаблоковану публічну властивість можна встановити в будь-яке значення;
- будь-який публічний метод можна викликати з будь-якими аргументами.
Захищають від цього #[Locked], автоматичний захист ідентифікаторів моделей, валідація і авторизація в діях.
Практичні наслідки:
- зміна
APP_KEYробить недійсними знімки на всіх відкритих сторінках - користувачі отримають помилку при наступній дії; - розмір знімка - частина кожного запиту й відповіді: великі масиви в публічних властивостях роздувають трафік в обидва боки;
- секрети в публічних властивостях видно в HTML - підпис захищає від зміни, але не від читання.
Кожен компонент оновлюється незалежно. Клік у дочірньому компоненті надсилає на сервер лише його знімок, і рендериться лише він. Тому дорогу частину сторінки можна «ізолювати» в окремому компоненті - її повільний запит до бази не впливатиме на оновлення решти.
Оновлення батька - менш очевидне. У запиті лише знімок батька. Коли сервер рендерить його шаблон і зустрічає дочірній компонент, він не рендерить дитину заново - для цього немає її знімка. Замість неї в HTML іде порожня заглушка з тим самим wire:id:
<div wire:id="456"></div>
На клієнті Livewire морфить DOM батька й пропускає заглушки - дочірні компоненти лишаються як були, зі своїм станом.
Як Livewire розуміє, що дитина вже існує. Кожен вкладений компонент має ключ - явний wire:key або згенерований. Батько зберігає список ключів дітей (memo.children). При рендері:
- ключ є в списку - компонент уже існує, рендеримо заглушку;
- ключа немає - створюємо новий компонент з
mount(); - ключ зник із шаблону - компонент видаляється.
Звідси способи керування:
- примусово перестворити дитину - змінити її ключ:
<livewire:report-chart :$filters :wire:key="'chart-'.md5(json_encode($filters))" />
Змінилися фільтри - новий ключ - новий компонент з новими параметрами в mount();
- стабільні ключі в циклах (
:wire:key="$item->id", а не індекс масиву) - інакше після видалення першого елемента стан компонентів зсунеться на сусідні; #[Reactive]- коли дитина має оновлюватися разом з батьком без перестворення.
Наслідки для продуктивності:
- плюс: оновлення батька не тягне рендер десятків дітей;
- мінус: синхронізація між компонентами потребує подій, реактивних параметрів чи
$parent, а#[Reactive]на багатьох дітях повертає витрати назад - кожна змінена дитина рендериться в тому самому запиті; - якщо компонент виділено лише для ізоляції рендеру, острівець (
@island) дає те саме без окремого стану й комунікації.
Публічна властивість з об'єктом власного класу (DTO, value object) за замовчуванням не працює: Livewire не знає, як перетворити його на JSON і назад. Для цього є два механізми.
1. Wireable - простий спосіб для власних класів. Клас сам описує свою серіалізацію:
use Livewire\Wireable;
final class Address implements Wireable
{
public function __construct(
public string $city = '',
public string $street = '',
) {}
public function toLivewire(): array
{
return ['city' => $this->city, 'street' => $this->street];
}
public static function fromLivewire($value): static
{
return new static($value['city'], $value['street']);
}
}
public Address $address;
<input wire:model="address.city">
2. Синтезатор - для типів, які не можна змінити (класи з бібліотек) або коли потрібен повний контроль:
use Livewire\Mechanisms\HandleComponents\Synthesizers\Synth;
final class MoneySynth extends Synth
{
public static $key = 'money';
public static function match($target): bool
{
return $target instanceof Money;
}
public function dehydrate($target): array
{
return [['amount' => $target->amount, 'currency' => $target->currency], []];
}
public function hydrate($value): Money
{
return new Money($value['amount'], $value['currency']);
}
}
// AppServiceProvider::boot()
Livewire::propertySynthesizer(MoneySynth::class);
$key- позначка в знімку: значення зберігається кортежем[дані, { s: 'money' }];match()- чи підходить синтезатор для значення під час дегідрації;dehydrate()/hydrate()- перетворення в обидва боки;- методи
get()іset()- якщо на вкладені частини значення має працюватиwire:model(money.amount).
Так само влаштовані вбудовані типи: Stringable, моделі, колекції, дати, енуми - це синтезатори всередині Livewire.
Що враховувати:
- усе, що повертає
dehydrate/toLivewire, видно в браузері - не кладіть туди секретів; - гідровані дані - введення користувача: значення можна змінити в запиті, тож
fromLivewire/hydrateмають валідувати, а не довіряти; - розмір - великий об'єкт їде в кожному запиті й відповіді;
- часто простіше тримати примітиви (
$city,$street) і збирати об'єкт у методі - власний тип потрібен, коли він справді спрощує код.
Livewire::test() проганяє компонент через той самий цикл, що й у браузері: mount, гідрація, оновлення властивостей, виклик дій, рендер. Тому тести перевіряють реальну поведінку, а не окремі методи класу.
use Livewire\Livewire;
it('creates a post', function () {
$user = User::factory()->create();
Livewire::actingAs($user)
->test('post.create')
->set('title', 'Перший пост')
->set('content', 'Текст')
->call('save')
->assertHasNoErrors()
->assertRedirect(route('posts.index'));
expect(Post::where('title', 'Перший пост')->exists())->toBeTrue();
});
Що перевіряти в першу чергу:
1. Авторизація - найважливіше, бо будь-який публічний метод можна викликати з браузера:
Livewire::actingAs($stranger)
->test('post.edit', ['post' => $post])
->set('title', 'Зламано')
->call('save')
->assertForbidden();
2. Валідація:
->set('title', 'ab')->call('save')->assertHasErrors(['title' => ['min:3']]);
3. Події й побічні ефекти: assertDispatched('post-created'), assertRedirect(), зміни в базі.
4. Стан і вивід: assertSet('count', 1), assertSee('Збережено'), assertCount('items', 3).
5. «Димові» тести сторінок: $this->get('/posts/create')->assertSeeLivewire('post.create') - дешево і ловить зламані сторінки.
Корисні можливості:
Livewire::withQueryParams(['search' => 'php'])- для властивостей з#[Url];Livewire::withoutLazyLoading()- тестувати лінивий компонент так, ніби він завантажився одразу;assertRenderSkipped()- для дій з#[Renderless];- тест поруч з компонентом:
make:livewire post.create --testстворює⚡create.test.phpпоряд (у Pest треба додатиresources/viewsу шляхи тестів); - браузерні тести Pest 4+ (
visit()->click()->assertSee()) - для того, що залежить від JavaScript: Alpine,wire:navigate, завантаження файлів.
Типові помилки:
- тестувати лише «щасливий шлях» і не перевіряти, що чужий користувач не може викликати дію;
set()властивості, яку в реальному інтерфейсі не можна змінити, і робити висновки з цього, - але саме так поводиться зловмисник, тож це корисна перевірка#[Locked];- перевіряти внутрішні деталі (приватні методи) замість поведінки через
call()іassert*.
Компонент з трейтом WithFileUploads приймає файли через звичайний wire:model:
use Livewire\WithFileUploads;
new class extends Component {
use WithFileUploads;
#[Validate('image|max:2048')] // 2 МБ
public $photo;
public function save(): void
{
$this->validate();
$path = $this->photo->store(path: 'avatars', options: 'public');
auth()->user()->update(['avatar_path' => $path]);
}
};
<input type="file" wire:model="photo">
@if ($photo) <img src="{{ $photo->temporaryUrl() }}"> @endif
Як це працює під капотом:
- при виборі файлу JavaScript запитує в компонента підписаний URL для завантаження;
- файл завантажується за ним у тимчасовий каталог (
livewire-tmp/на диску за замовчуванням); - властивість
$photoотримує об'єкт тимчасового файлу (TemporaryUploadedFile); - у вашому методі файл перевіряється й переноситься на постійне місце.
Ризики й захист:
- глобальна валідація тимчасових завантажень - за замовчуванням
file|max:12288(12 МБ). Тобто ще до вашої валідації сервер приймає будь-який файл до 12 МБ. Звузьте вconfig/livewire.php(temporary_file_upload.rules), якщо застосунку не потрібні великі файли; - обмеження частоти - ендпойнт завантаження за замовчуванням має throttle-middleware; його можна змінити (
temporary_file_upload.middleware). Але в Livewire 4 файли понад 1 МБ на локальному диску йдуть частинами (chunked upload) - один файл стає десятками швидких запитів. Надто жорсткий ліміт на кшталтthrottle:60,1ламає великі завантаження; - розмір файлу обмежує ваше правило
max:, а неupload_max_filesizePHP: частини збираються на сервері, і перерване завантаження продовжується з місця зупинки; - перевірка типу - лише через правила валідації (
image,mimes:pdf), які перевіряють вміст, а не розширення. Ніколи не зберігайте з оригінальною назвою від користувача без перевірки - використовуйте згенеровану (store()робить саме так); - попередній перегляд (
temporaryUrl()) працює лише для зображень і через підписаний URL - чужі файли так не подивитися; - очищення - на локальному диску Livewire сам видаляє старі тимчасові файли; для S3 треба налаштувати правило життєвого циклу (
php artisan livewire:configure-s3-upload-cleanup, файли старші 24 годин); - публічність - зберігайте в приватний диск усе, що не призначене для всіх, і віддавайте через підписані URL.
Пряме завантаження в S3 (LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=s3) знімає навантаження з сервера застосунку: файл іде з браузера одразу в бакет. Але частина правил валідації потребує доступу до файлу - бакет має це дозволяти.
Пастка з назвою: upload - зарезервоване слово Livewire. Метод чи властивість з такою назвою зламають завантаження.
Тести: UploadedFile::fake()->image('avatar.jpg') з ->set('photo', $file) і Storage::fake().
Є три рівні складності - від простої обгортки до окремого Livewire-компонента.
1. Blade-компонент-обгортка над справжнім <input> - найчастіший випадок (мітка, поле, помилка в одному місці):
{{-- resources/views/components/input-text.blade.php --}}
@props(['name', 'label'])
<label>
{{ $label }}
<input type="text" name="{{ $name }}" {{ $attributes }}>
</label>
@error($name) <p class="text-red-600">{{ $message }}</p> @enderror
<x-input-text name="title" label="Заголовок" wire:model.live.blur="title" />
Секрет - {{ $attributes }}: wire:model з усіма модифікаторами переходить на справжнє поле. Для зручності з $attributes->wire('model') можна прочитати назву властивості й використати її для @error без окремого name.
2. Поле без нативного <input> на Alpine (лічильник, перемикач, вибір кольору) - x-modelable:
{{-- resources/views/components/input-counter.blade.php --}}
<div x-data="{ count: 0 }" x-modelable="count" {{ $attributes }}>
<button type="button" x-on:click="count--">-</button>
<span x-text="count"></span>
<button type="button" x-on:click="count++">+</button>
</div>
<x-input-counter wire:model="quantity" />
<x-input-counter x-model="quantity" /> {{-- працює і в чистому Alpine --}}
x-modelable каже Alpine, яку змінну пов'язати з wire:model чи x-model на цьому елементі. Стан живе в браузері, сервер отримує значення разом з дією.
3. Окремий Livewire-компонент - #[Modelable], коли полю потрібна серверна логіка (пошук по базі для автодоповнення, завантаження варіантів):
use Livewire\Attributes\Modelable;
new class extends Component {
#[Modelable]
public ?int $value = null;
public string $search = '';
#[Computed]
public function options() { /* пошук у базі за $search */ }
};
<livewire:user-picker wire:model="assigneeId" />
Що обрати: перший варіант - за замовчуванням; другий - для суто клієнтської взаємодії без запитів; третій - лише коли справді потрібен сервер, бо кожне поле-компонент додає власний стан, запити й складність синхронізації з батьком.
Пастки: забутий {{ $attributes }} (і wire:model мовчки не працює); type="button" на кнопках усередині форми; wire:key для таких полів у циклах.
Автозбереження (як у Google Docs чи налаштуваннях) робиться хуком updated: поле відправляється на сервер при виході з нього й одразу записується в базу.
new class extends Component {
public Post $post;
#[Validate('required|max:255')]
public string $title = '';
#[Validate('required')]
public string $content = '';
public function mount(Post $post): void
{
$this->authorize('update', $post);
$this->post = $post;
$this->fill($post->only(['title', 'content']));
}
public function updated(string $property): void
{
if (! in_array($property, ['title', 'content'], true)) {
return;
}
$this->authorize('update', $this->post);
$this->post->update([$property => $this->{$property}]);
}
};
<input wire:model.live.blur="title">
<textarea wire:model.live.blur="content"></textarea>
<span wire:dirty>Не збережено</span>
Правила з #[Validate] перевіряються до updated: невалідне значення не дійде до збереження.
Пастки:
1. Назва властивості - від клієнта. Варіант із документації, де updated($name, $value) робить update([$name => $value]), небезпечний без білого списку: клієнт може оновити будь-яку публічну властивість, і її назва стане назвою колонки в update(). Масове призначення з $fillable частково рятує, але явний перелік полів надійніший.
2. Авторизація на кожне збереження. Перевірка в mount() виконується один раз; права могли змінитися, а запит - бути підробленим.
3. Частково збережений стан. Поля зберігаються по одному. Якщо бізнес-правило охоплює кілька полів («дата завершення після дати початку»), окреме збереження першого поля може записати неузгоджені дані. Для таких полів - звичайна кнопка «Зберегти» з валідацією всього разом.
4. Навантаження й побічні ефекти. Кожне поле - запит і UPDATE, а з ним спостерігачі моделі, події, індексація пошуку, журнал змін. Для «важких» моделей краще зберігати чернетку окремо чи з затримкою.
5. Конкурентне редагування. Дві вкладки чи два редактори перезаписують зміни одне одного без попередження. Потрібна перевірка версії (updated_at) чи блокування запису.
6. Невидимий зворотний зв'язок. Користувач має бачити «Збережено» / «Помилка збереження» - інакше він не знає, чи можна закривати сторінку. wire:dirty, wire:loading і повідомлення про помилку обов'язкові.
Докладніше в документації: Форми: збереження в реальному часі
Коли правила залежать від стану компонента чи потребують об'єктів правил Laravel, атрибутів #[Validate] замало - потрібен метод rules().
use Illuminate\Validation\Rule;
new class extends Component {
public ?Product $product = null;
public string $sku = '';
public string $type = 'physical';
public ?float $weight = null;
/** @var list<array{name: string, qty: int}> */
public array $items = [];
protected function rules(): array
{
return [
// унікальність, крім поточного запису при редагуванні
'sku' => ['required', Rule::unique('products', 'sku')->ignore($this->product)],
'type' => ['required', Rule::in(['physical', 'digital'])],
// умовне правило: вага обов'язкова лише для фізичних товарів
'weight' => [Rule::requiredIf($this->type === 'physical'), 'nullable', 'numeric', 'min:0'],
// масив рядків
'items' => ['array', 'min:1', 'max:50'],
'items.*.name' => ['required', 'string', 'max:100'],
'items.*.qty' => ['required', 'integer', 'min:1'],
];
}
protected function validationAttributes(): array
{
return ['items.*.qty' => 'кількість'];
}
};
Шаблон з динамічними рядками:
@foreach ($items as $index => $item)
<div wire:key="item-{{ $index }}">
<input wire:model="items.{{ $index }}.name">
@error("items.{$index}.name") <p>{{ $message }}</p> @enderror
</div>
@endforeach
<button type="button" wire:click="addItem">Додати рядок</button>
public function addItem(): void
{
if (count($this->items) < 50) {
$this->items[] = ['name' => '', 'qty' => 1];
}
}
Додавання через метод на сервері, а не через $set з браузера, дає змогу одразу обмежити кількість рядків.
Що варто знати:
rules()не перевіряється при введенні - лише в$this->validate(). Для перевірки в реальному часі - порожній#[Validate]над властивістю;- межі масиву обов'язкові (
max:50): масив приходить з браузера, і без обмеження хтось надішле сто тисяч елементів; - ключ
wire:keyдля рядків - бажано стабільний ідентифікатор, а не індекс, якщо рядки можна видаляти з середини списку; - помилки адресуються з індексом -
items.3.qty; messages()іvalidationAttributes()- власні тексти й назви полів; назвиrules,messages,validationAttributesзарезервовані - не використовуйте їх для властивостей;- власні правила (
php artisan make:rule) працюють так само, як у контролерах; - валідатор напряму:
$this->withValidator(fn ($validator) => $validator->after(...))- для перевірок, що охоплюють кілька полів.
Докладніше в документації: Валідація: правила в методі rules()
За замовчуванням дії одного компонента виконуються по черзі: поки йде запит, наступні чекають. Асинхронна дія виконується одразу, паралельно з іншими, - не чекає черги й нікого не блокує.
use Livewire\Attributes\Async;
new class extends Component {
#[Async]
public function trackClick(string $url): void
{
Analytics::track('external-link', ['url' => $url, 'user' => auth()->id()]);
}
};
<a href="{{ $url }}" target="_blank" wire:click.async="trackClick('{{ $url }}')">Перейти</a>
Або точково в шаблоні: wire:click.async, wire:intersect.async.
Чому стан і асинхронність несумісні. Кожен запит стартує зі знімка стану, який браузер мав на момент відправки. Паралельні запити не бачать результатів одне одного:
#[Async] // так не можна
public function increment(): void
{
$this->count++;
}
П'ять швидких кліків - п'ять запитів, кожен починає з count = 0, кожен повертає 1. Лічильник показує 1 замість 5. Зміни губляться, а остаточний стан визначає відповідь, що прийшла останньою, - не обов'язково остання за часом дія.
Безпечні сценарії:
- чисті побічні ефекти: аналітика, журнали, постановка джоби в чергу, сповіщення - дія не змінює властивостей, що відображаються;
- дані для JavaScript: результат повертається в Alpine і зберігається там, а не в стані компонента:
<div x-data="{ suggestions: [] }">
<input x-on:input.debounce="suggestions = await $wire.fetchSuggestions($event.target.value)">
</div>
Правило: якщо асинхронна дія присвоює значення публічній властивості, що впливає на шаблон, - це помилка дизайну.
Захист від гонитви на сервері асинхронність не скасовує: дві паралельні дії, що змінюють один запис у базі, потребують атомарних операцій (increment()), транзакцій чи блокувань, як і будь-які паралельні HTTP-запити.
Зв'язок з іншими механізмами:
- з
#[Renderless]- класичне поєднання для «запустив і забув»; #[Isolate]- про групування між компонентами, а#[Async]- про чергу всередині компонента;- острівці теж ходять паралельно, і застереження про спільний стан для них те саме.
Звичайна обчислювана властивість кешується лише в межах одного запиту. Для дорогих даних є кешування довше - через кеш Laravel.
persist: true - кеш для цього екземпляра компонента між його запитами (типово 3600 секунд):
#[Computed(persist: true, seconds: 600)]
public function stats()
{
return Order::query()->where('shop_id', $this->shopId)->selectRaw('...')->first();
}
Користувач клацає фільтри, а важка статистика не перераховується на кожен клік.
cache: true - кеш спільний для всіх екземплярів компонента в застосунку:
#[Computed(cache: true, key: 'homepage-popular-posts', seconds: 300)]
public function popularPosts()
{
return Post::popular()->limit(10)->get();
}
Одна й та сама головна сторінка для всіх - запит раз на п'ять хвилин замість кожного рендеру.
Головний ризик - cache: true з персональними даними:
#[Computed(cache: true)] // помилка!
public function myOrders()
{
return auth()->user()->orders()->latest()->get();
}
Ключ кешу не залежить від користувача. Перший, хто відкрив сторінку, «заповнить» кеш своїми замовленнями, а наступні побачать його дані. Це витік персональних даних, а не просто баг.
Правила:
cache: true- лише для даних, однакових для всіх (публічні списки, довідники, загальна статистика);- якщо дані залежать від користувача, мови, параметрів - ключ має їх містити (
key: "orders-user-{$id}"неможливо в атрибуті, тож для таких випадків - звичайнийCache::rememberу методі з явним ключем) або використовуватиpersist: true, прив'язаний до екземпляра; - інвалідація: кеш живе до закінчення терміну. Після зміни даних
unset($this->stats)скидає лише кеш у межах запиту; для збережених значень потрібне явне очищення через кеш Laravel чи коротший термін; - серіалізація: у кеш потрапляють моделі й колекції - перевірте, що драйвер кешу й налаштування
serializable_classesдозволяють такі об'єкти; - авторизація до кешу: перевірка прав має відбуватися до звернення до кешованого значення, а не всередині методу, який при влучанні в кеш не виконується.
Коли краще не використовувати ці опції: якщо запит швидкий (кілька мілісекунд) - звернення до кешу через мережу (Redis) може коштувати стільки ж, а складність і ризик застарілих даних додаються.
Інколи дія потрібна не для того, щоб змінити стан і перемалювати компонент, а щоб отримати дані для JavaScript: варіанти автодоповнення, точки для карти, дані для графіка. Атрибут #[Json] робить дію таким ендпойнтом.
use Livewire\Attributes\Json;
new class extends Component {
#[Json]
public function searchCities(string $query): array
{
return City::query()
->where('name', 'like', "{$query}%")
->limit(10)
->get(['id', 'name'])
->toArray();
}
};
<div x-data="{ query: '', cities: [] }">
<input x-model="query" x-on:input.debounce.300ms="cities = await $wire.searchCities(query)">
<template x-for="city in cities" :key="city.id">
<button type="button" x-text="city.name" x-on:click="$wire.cityId = city.id"></button>
</template>
</div>
Що відрізняє #[Json]-дію:
- повернене значення приходить у JavaScript як результат Promise від
$wire.method(); - помилки валідації не потрапляють у
@error, а відхиляють Promise зі структурою{ status: 422, errors: { поле: [...] } }:
try {
const result = await $wire.save();
} catch (e) {
if (e.status === 422) showErrors(e.errors);
}
- компонент не перерендерюється заради результату - дані живуть в Alpine.
Навіщо це замість окремого API-маршруту:
- не потрібні маршрут, контролер, CSRF-налаштування й автентифікація для API - дія працює в контексті компонента з його станом (
$this->countryId) і поточним користувачем; - логіка лишається поруч із компонентом, який її використовує.
Що варто пам'ятати:
- це публічний ендпойнт: будь-хто з доступом до сторінки може викликати метод з довільними аргументами. Обмежуйте результат (
limit), перевіряйте права, не повертайте зайвих полів моделей (паролі, приховані атрибути -toArray()поважає$hidden, але явнийget(['id', 'name'])надійніший); - частота: debounce на введенні обов'язковий, інакше кожна літера - запит;
- паралельність: такі дії часто поєднують з
#[Async], щоб вони не стояли в черзі за іншими діями компонента. Але тоді результат має зберігатися лише в JavaScript, не у властивостях; - для великих обсягів даних (тисячі точок) варто подумати про окремий кешований ендпойнт - відповідь дії Livewire теж містить знімок стану компонента.
Кожна дія Livewire - це повний цикл на сервері: відтворення компонента зі знімка, хуки, дія, рендер шаблону, новий знімок. Повільно може бути на будь-якому етапі, тому спершу - виміряти.
Інструменти:
- DevTools → Network, запити
livewire/update: тривалість, розмір запиту й відповіді (великий знімок видно одразу), скільки компонентів у кожному запиті; - Laravel Debugbar / Telescope / Pulse - SQL-запити, їх кількість і дублікати на один запит Livewire;
- профайлер (Xdebug, Blackfire, SPX) - де саме йде час: гідрація, дія чи рендер Blade.
Найчастіші причини:
1. Моделі й колекції в публічних властивостях. Відновлюються з бази на кожному запиті, без завантажених зв'язків - і шаблон отримує N+1. Рішення: обчислювані властивості з with().
2. Важкі запити в render(), boot(), hydrate(). Виконуються на кожен клік, навіть коли дія їх не стосується. Рішення: #[Computed] (виконується лише при зверненні), острівці, кешування.
3. Перерендер усього компонента заради дрібниці. Перемикач «улюблене» в рядку таблиці на 200 рядків перебудовує всю таблицю. Рішення: острівець, #[Renderless] для дій без зміни вигляду, окремий компонент для рядка.
4. Зайві запити від wire:model.live. Запит на кожну паузу у введенні в кожному полі. Рішення: звичайний wire:model (дані підуть з відправкою форми), .blur, більший debounce.
5. Великий знімок стану. Масиви на тисячі елементів, вкладені структури в публічних властивостях їдуть в обидва боки з кожним запитом. Рішення: тримати в стані лише ідентифікатори й значення фільтрів.
6. Багато компонентів на сторінці. Сотні дрібних компонентів у циклі - сотні знімків у HTML і накладні витрати на кожен. Рішення: Blade-компоненти для статичних частин, Livewire лише там, де справді потрібна інтерактивність.
7. Повільний компонент гальмує інші. Групування запитів змушує чекати найповільнішого. Рішення: #[Isolate], lazy/defer.
8. Опитування. wire:poll з коротким інтервалом на популярній сторінці множить навантаження на кількість відкритих вкладок.
Не лише сервер: повільний морфінг величезного DOM (тисячі рядків) теж відчутний - тут допомагають пагінація й віртуалізація, а не оптимізація PHP.
Перевірка після виправлення - ті самі вимірювання: кількість SQL-запитів на одну дію і розмір відповіді мають помітно зменшитися.
Питання рівня Senior з реальних технічних співбесід - 36 питань у 8 темах, розібраних із відповідями. Нижче - теми цього рівня та сусідні рівні, якщо хочете звузити або розширити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 46 відкритих вакансій рівня Senior. Переглянути вакансії