Senior: питання на співбесіді з теми «Продуктивність Livewire»
Питання з реальних співбесід з відповідями: Laravel і PHP, бази даних, JavaScript і фронтенд, Git, Docker, API, безпека й архітектура. Тими самими темами, що й тести.
4 питання
За замовчуванням дії одного компонента виконуються по черзі: поки йде запит, наступні чекають. Асинхронна дія виконується одразу, паралельно з іншими, - не чекає черги й нікого не блокує.
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-запитів на одну дію і розмір відповіді мають помітно зменшитися.