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

Middle: питання на співбесіді з теми «Продуктивність і SSR»

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

4 питання

Дочірній компонент оновлюється, коли змінюється хоча б один з його props. Якщо prop залежить від стану, що змінюється часто, оновлюються всі діти, навіть ті, для яких результат той самий.

Класичний приклад:

<ListItem
  v-for="item in list"
  :key="item.id"
  :id="item.id"
  :active-id="activeId"
/>

Усередині ListItem порівнює id === activeId. Змінився activeId - змінився prop у кожного елемента, і перерендерюються всі сто рядків.

Стабільніший варіант - обчислити результат у батька:

<ListItem
  v-for="item in list"
  :key="item.id"
  :id="item.id"
  :active="item.id === activeId"
/>

Тепер при зміні вибору active змінюється лише у двох рядків - решта отримують той самий false, і Vue їх пропускає.

Інші джерела нестабільних props:

  • новий об'єкт чи масив у шаблоні: :options="{ size: 'sm' }" чи :items="list.filter(...)" створюють нове посилання на кожен рендер батька - дочірній компонент оновлюється завжди. Виносьте такі значення в computed чи константи;
  • інлайн-функції - для подій у Vue це не проблема (обробники кешуються компілятором), а для props-функцій - так само нове посилання щоразу;
  • передача цілого об'єкта, коли потрібне одне поле, - дитина оновлюється при зміні будь-якого поля об'єкта.

Як це побачити: Vue DevTools (вкладка продуктивності й хронологія) показують, скільки компонентів оновилося після дії і скільки часу це зайняло. Хук onUpdated з лічильником у підозрілому компоненті - швидка перевірка в розробці.

Коли це варто оптимізувати: списки на сотні елементів, важкі дочірні компоненти, часті зміни (введення тексту, анімації, перетягування). Для десятка простих рядків різниця невідчутна.

Допоміжні інструменти: v-memo для крайніх випадків, а для великих списків - віртуалізація, щоб рендерити лише видимі рядки.

Докладніше в документації: Продуктивність: стабільність props

З Vue 3.4 обчислювана властивість запускає залежні ефекти (рендер, watch) лише якщо її значення справді змінилося, а не щоразу, коли змінилися її залежності.

const count = ref(1)
const isEven = computed(() => count.value % 2 === 0)

watchEffect(() => console.log(isEven.value))

count.value = 3   // isEven лишився false - ефект не запускається
count.value = 5   // теж
count.value = 6   // true - запускається

До 3.4 кожна зміна count змушувала перезапустити все, що читало isEven, навіть з тим самим результатом.

Порівняння - за Object.is. Для примітивів це працює чудово. А от computed, що щоразу повертає новий об'єкт чи масив, завжди вважається зміненим:

const activeItems = computed(() => items.value.filter((i) => i.active))

Будь-яка зміна items (навіть поля, яке не впливає на фільтр) дає новий масив - залежні компоненти оновлюються.

Ручна стабілізація - через попереднє значення, яке Vue передає першим аргументом:

const summary = computed((oldValue) => {
  const next = { done: doneCount.value, total: total.value }
  if (oldValue && oldValue.done === next.done && oldValue.total === next.total) {
    return oldValue     // те саме посилання - залежні не оновлюються
  }
  return next
})

Обов'язково обчислювати нове значення повністю до порівняння, щоб Vue зібрав усі залежності на кожному запуску.

Практичні висновки:

  • computed, що повертає примітив (boolean, number, рядок), - найкращий кандидат для передачі в дочірні компоненти й шаблони: стабільний автоматично;
  • для об'єктів, що рідко змінюються по суті, - стабілізація через oldValue, коли вимірювання показують проблему;
  • побічних ефектів у computed бути не повинно: завдяки ліньому й кешованому обчисленню невідомо, скільки разів і коли він виконається.

Пов'язане: watch з deep: true на великих об'єктах теж дорогий - кожна зміна обходить усю структуру. З Vue 3.5 deep приймає число - глибину обходу.

Докладніше в документації: Продуктивність: стабільність computed

Список на 10 000 рядків - це 10 000+ DOM-вузлів і стільки ж екземплярів компонентів. Навіть ідеально оптимізована реактивність не врятує: браузер повільно розраховує розкладку, прокрутка смикається, пам'ять росте, а перший рендер займає секунди.

Віртуалізація (windowing) рендерить лише рядки, видимі на екрані, плюс невеликий запас зверху й знизу. При прокрутці ті самі DOM-вузли перевикористовуються для нових даних, а висоту прокручуваної області імітують відступами.

<script setup lang="ts">
import { useVirtualList } from '@vueuse/core'

const { list, containerProps, wrapperProps } = useVirtualList(rows, { itemHeight: 48 })
</script>

<template>
  <div v-bind="containerProps" style="height: 600px">
    <div v-bind="wrapperProps">
      <OrderRow v-for="{ data, index } in list" :key="data.id" :order="data" />
    </div>
  </div>
</template>

Готові рішення: vue-virtual-scroller, useVirtualList з VueUse, TanStack Virtual для Vue.

Результат: у DOM 20-30 рядків замість 10 000 - рендер миттєвий, прокрутка плавна.

Складнощі:

  • різна висота рядків - потрібне вимірювання або оцінка висоти; бібліотеки підтримують динамічну висоту, але це складніше й повільніше;
  • пошук браузера (Ctrl+F) не знайде текст рядків, яких немає в DOM;
  • доступність: зчитувачі екрана бачать лише відрендерені рядки - потрібні атрибути aria-rowcount, aria-rowindex;
  • стан рядків: розгорнутий рядок чи введене значення, що зберігалося в компоненті рядка, губиться при прокрутці - стан має жити в даних, а не в компоненті;
  • таблиці з фіксованими колонками й заголовками вимагають узгодженої ширини колонок.

Альтернативи, які інколи кращі:

  • пагінація - простіша, зрозуміла користувачу, і дані завантажуються частинами з сервера;
  • «Завантажити ще» - доки рядків сотні, а не тисячі;
  • серверні фільтри й пошук, щоб користувач узагалі не гортав тисячі рядків.

Ознака, що потрібна віртуалізація: вимірювання показують, що повільні саме рендер і розкладка, а список справді має бути довгим і безперервним (журнали, таблиці даних, чати).

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

Розмір JavaScript безпосередньо впливає на час, коли сторінка стає інтерактивною: файл треба завантажити, розібрати й виконати, а на слабких телефонах останні два кроки займають більше часу, ніж мережа.

Що Vue робить сам:

  • компіляція шаблонів заздалегідь: з Vite шаблони .vue-файлів компілюються під час збирання, тому в бандл іде runtime-збірка Vue без компілятора шаблонів (приблизно на третину менша). Компілятор потрібен лише якщо шаблони задаються рядками під час виконання - цього варто уникати;
  • tree shaking API: якщо не використовується <Transition> чи <KeepAlive>, їхнього коду не буде в бандлі.

Що робити вам:

1. Подивитися, що всередині. rollup-plugin-visualizer показує розмір кожного модуля. Майже завжди знаходяться сюрпризи: уся бібліотека іконок через один імпорт, повна локалізація бібліотеки дат, дві версії однієї залежності.

2. Імпортувати точково:

import { debounce } from 'lodash-es'      // замість import _ from 'lodash'
import CalendarIcon from '~icons/heroicons/calendar'   // одна іконка, а не набір

3. Обирати залежності з думкою про розмір - перевірити розмір пакета до встановлення (bundlephobia, pkg-size). Інколи 20 рядків власного коду кращі за бібліотеку на 50 КБ.

4. Розділяти код - ліниві маршрути й асинхронні компоненти для рідко потрібного.

5. Бібліотеки компонентів - підключати компоненти за потреби (автоімпорт unplugin-vue-components), а не реєструвати всю бібліотеку глобально.

6. Пакети у форматі ES-модулів - для них працює tree shaking; пакети CommonJS потрапляють цілими.

Метрики й контроль:

  • розмір після стиснення (gzip/brotli) - те, що реально йде мережею;
  • бюджет розміру в CI (size-limit чи перевірка звіту збирання) - щоб регресії ловилися в пул-реквесті, а не через пів року;
  • Lighthouse і реальні метрики (INP, LCP) - кінцевий критерій, а не кілобайти самі по собі.

Для Laravel + Inertia: сторінки, підключені через import.meta.glob з { eager: true }, потрапляють у головний бандл усі разом - для великих застосунків краще ліниве завантаження сторінок.

Докладніше в документації: Продуктивність: розмір бандла