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

Питання на співбесіді з Vue

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

108 питань

ref і computed зазвичай виводять тип самі:

const count = ref(0)                         // Ref<number>
const doubled = computed(() => count.value * 2)   // ComputedRef<number>

Явний тип потрібен, коли початкове значення не описує всі можливі:

const user = ref<User | null>(null)
const status = ref<'idle' | 'loading' | 'error'>('idle')
const total = computed<number>(() => items.value.reduce((s, i) => s + i.price, 0))

Посилання на елемент шаблону. У Vue 3.5 для цього є useTemplateRef:

<script setup lang="ts">
import { useTemplateRef, onMounted } from 'vue'

const input = useTemplateRef<HTMLInputElement>('search')

onMounted(() => input.value?.focus())
</script>

<template>
  <input ref="search" />
</template>

Зв'язок іде за рядковим ключем з атрибута ref, а не за назвою змінної. Тип можна не вказувати - розширення Vue - Official виводить його з шаблону.

До 3.5 писали ref з тією самою назвою, що й атрибут:

const search = ref<HTMLInputElement | null>(null)

Посилання на дочірній компонент:

import OrderForm from './OrderForm.vue'

const form = useTemplateRef<InstanceType<typeof OrderForm>>('form')
form.value?.reset()   // доступно, лише якщо OrderForm зробив defineExpose({ reset })

Що варто пам'ятати:

  • значення ref шаблону null до монтування і після розмонтування - тому тип містить null, а звертання йде через ?. чи в onMounted;
  • елемент під v-if може зникнути - посилання стане null;
  • у v-for ref стає масивом елементів, і його порядок не гарантовано збігається з порядком у масиві даних;
  • shallowRef доречний для великих об'єктів, які замінюють цілком, - тип той самий, але вміст не стає глибоко реактивним.

Докладніше в документації: TypeScript: типізація ref шаблону

Компілятор TypeScript (tsc) працює з .ts і .tsx. Файл .vue для нього - невідомий формат: шаблон, <script setup> з макросами, стилі в одному файлі. Тому для Vue є окремий інструментарій на основі Volar.

Що перевіряє типи:

  • vue-tsc - обгортка над tsc, що «розуміє» SFC: перевіряє і скрипт, і вирази в шаблоні (props, події, слоти, v-for);
  • розширення Vue - Official (колишній Volar) для VS Code - те саме в редакторі, плюс автодоповнення в шаблонах. Для інших редакторів - мовний сервер @vue/language-server.

Чому окремий крок. Vite (і esbuild/Rolldown під ним) лише прибирає типи з коду - не перевіряє. Помилка типу не зупинить npm run dev. Тому перевірку запускають окремо:

"scripts": {
  "type-check": "vue-tsc --build",
  "build": "vue-tsc --build && vite build"
}

і обов'язково в CI - інакше помилки типів накопичуються непомітно.

Що потрібно в налаштуваннях:

  • tsconfig з "jsx": "preserve" і модулями для збирача ("moduleResolution": "bundler") - create-vue генерує це через пакет @vue/tsconfig;
  • оголошення для імпорту .vue-файлів у звичайних .ts не потрібне, якщо використовується vue-tsc; старий shims-vue.d.ts з declare module '*.vue' робить усі компоненти any і вимикає перевірку props;
  • типи середовища Vite (/// <reference types="vite/client" />) для import.meta.env та імпорту ассетів.

Перевірка шаблонів - головна цінність:

<OrderCard :order="order" @cancel="onCancel" />
<!-- vue-tsc: Property 'total' is missing / Argument of type 'string' is not assignable to 'number' -->

Помилки на межі компонентів (неправильний prop, неіснуюча подія, слот з іншими параметрами) інакше виявляються лише під час виконання.

Строгість шаблонів регулюється опціями vueCompilerOptions у tsconfig (наприклад, strictTemplates) - у великих проєктах їх вмикають поступово.

Докладніше в документації: TypeScript: огляд

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

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

mount рендерить компонент повністю - з усіма дочірніми компонентами до самого низу дерева. shallowMount рендерить лише сам компонент, а всі дочірні замінює заглушками - порожніми елементами з назвою компонента:

const wrapper = shallowMount(OrderPage)
// <order-summary-stub order="[object Object]"></order-summary-stub>
// <payment-form-stub></payment-form-stub>

Аргументи за shallowMount:

  • тест ізольований від дітей: зламаний PaymentForm не валить тест OrderPage;
  • швидше, якщо діти важкі (графіки, редактори, власні запити).

Аргументи проти (і чому документація Vue радить mount за замовчуванням):

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

Точкові заглушки - найкращий компроміс: монтувати повністю, але замінити окремі проблемні компоненти:

const wrapper = mount(OrderPage, {
  global: {
    stubs: {
      MapWidget: true,                       // важка карта - просто заглушка
      RouterLink: RouterLinkStub,            // без справжнього маршрутизатора
      Teleport: true,                        // вміст телепорту рендериться на місці
      PaymentForm: { template: '<div data-test="payment" />' },   // своя мінімальна версія
    },
  },
})

Коли заглушка доречна:

  • компонент звертається до браузерних API, яких немає в jsdom (canvas, WebGL, карти);
  • дочірній компонент робить власні запити, які в цьому тесті не цікаві;
  • сторонні компоненти з анімаціями чи телепортами, що заважають знаходити елементи.

Перевірка, що дочірній компонент отримав правильні props - через findComponent:

expect(wrapper.findComponent(OrderSummary).props('total')).toBe(1250)

Корисно на межі з важким компонентом-заглушкою, але для решти випадків краще перевіряти результат у DOM.

Докладніше в документації: Vue Test Utils: заглушки й shallowMount

Vue оновлює DOM асинхронно: зміна стану ставить оновлення в чергу, і DOM змінюється в наступному «тіку». Тест, що перевіряє DOM одразу після зміни, бачить старий стан.

Два різні види очікування:

1. Оновлення DOM після зміни стану - await nextTick() або await на методах Test Utils:

await wrapper.get('button').trigger('click')   // trigger повертає nextTick
expect(wrapper.text()).toContain('Відкрито')

wrapper.vm.open = true                          // пряма зміна стану
await nextTick()

trigger, setValue, setProps уже повертають Promise, що завершується після оновлення DOM - окремий nextTick після них не потрібен.

2. Завершення інших Promise (запити, таймери з Promise, асинхронні дії сторів) - flushPromises():

import { flushPromises, mount } from '@vue/test-utils'

vi.spyOn(api, 'fetchOrders').mockResolvedValue([{ id: 1, total: 100 }])

const wrapper = mount(OrdersList)
await flushPromises()               // дочекатися всіх розв'язаних Promise і рендеру

expect(wrapper.findAll('[data-test="order"]')).toHaveLength(1)

flushPromises чекає, поки виконаються всі вже розв'язані Promise в черзі, - зокрема ланцюжки await усередині компонента після замоканого запиту.

Таймери (setTimeout, debounce) - фальшивий час Vitest:

vi.useFakeTimers()
await wrapper.get('input').setValue('lar')
vi.advanceTimersByTime(300)          // «промотати» debounce
await flushPromises()
vi.useRealTimers()

Асинхронний setup (top-level await у <script setup>) вимагає <Suspense> - у тесті компонент обгортають у Suspense-обгортку і чекають flushPromises().

Типові помилки:

  • тест «зелений» без очікувань: перевірка відбувається до завершення запиту, але випадково збігається з початковим станом (наприклад, «список порожній»);
  • flushPromises замість мокування: справжній мережевий запит він не дочекається - запити в тестах мають бути замокані;
  • реальні таймери роблять тест повільним і нестабільним - фальшиві таймери надійніші.

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

Справжні запити в unit-тестах - повільні, нестабільні й залежать від стану сервера. Їх замінюють, і є два рівні, на яких це роблять.

1. Мок модуля API (vi.mock) - замінити функції, що роблять запити:

import { vi } from 'vitest'
import * as api from '@/api/orders'

vi.mock('@/api/orders', () => ({
  fetchOrders: vi.fn(),
}))

it('shows orders', async () => {
  vi.mocked(api.fetchOrders).mockResolvedValue([{ id: 1, total: 100 }])

  const wrapper = mount(OrdersList)
  await flushPromises()

  expect(wrapper.text()).toContain('100')
  expect(api.fetchOrders).toHaveBeenCalledWith({ status: 'paid' })
})

vi.mock піднімається на початок файлу (hoisting) і діє на всі імпорти модуля в тесті.

  • плюси: просто, швидко, видно аргументи виклику;
  • мінуси: тест прив'язаний до того, як компонент отримує дані. Перейшли з fetchOrders на useQuery - треба переписувати моки; помилки у формуванні запиту (URL, заголовки, серіалізація) не перевіряються.

2. Перехоплення мережі (MSW - Mock Service Worker) - компонент робить справжній fetch, а MSW відповідає замість сервера:

import { http, HttpResponse } from 'msw'
import { setupServer } from 'msw/node'

const server = setupServer(
  http.get('/api/orders', () => HttpResponse.json([{ id: 1, total: 100 }])),
)

beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
afterEach(() => server.resetHandlers())
afterAll(() => server.close())

it('shows an error when the API fails', async () => {
  server.use(http.get('/api/orders', () => new HttpResponse(null, { status: 500 })))
  // ...
})
  • плюси: перевіряється весь шлях - URL, параметри, обробка статусів; тест не залежить від того, яким клієнтом зроблено запит; ті самі обробники можна використати в браузері для розробки й у Storybook;
  • мінуси: більше налаштувань.

Що обрати: для компонентів, що працюють з HTTP, MSW дає надійніші тести. vi.mock доречний для модулів, які не є HTTP (аналітика, локальне сховище, сторонні SDK), і для швидких точкових перевірок.

Обов'язково тестувати не лише успіх: помилку сервера, порожню відповідь, повільну відповідь (стан завантаження) і помилку валідації 422.

onUnhandledRequest: 'error' - щоб незамоканий запит падав тестом, а не мовчки йшов у мережу.

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

Composable - звичайна функція, що використовує реактивні API Vue. Як її тестувати, залежить від того, що вона використовує.

1. Лише реактивність (ref, computed, watch) - викликати напряму:

import { useCounter } from './useCounter'

it('increments and clamps to max', () => {
  const { count, increment } = useCounter({ max: 2 })

  increment()
  increment()
  increment()

  expect(count.value).toBe(2)
})

Компонент не потрібен - функція працює й поза ним.

2. Хуки життєвого циклу чи inject (onMounted, onUnmounted, inject) - потрібен екземпляр компонента. Без нього Vue попередить, що хук викликано поза setup, і він не спрацює. Допоміжна функція монтує тимчасовий застосунок:

import { createApp } from 'vue'

function withSetup<T>(composable: () => T): [T, ReturnType<typeof createApp>] {
  let result!: T
  const app = createApp({
    setup() {
      result = composable()
      return () => {}
    },
  })
  app.mount(document.createElement('div'))
  return [result, app]
}

it('removes the listener on unmount', () => {
  const [{ width }, app] = withSetup(() => useWindowWidth())
  // ...
  app.unmount()   // перевірити прибирання
})

Для inject перед монтуванням додають app.provide(Key, value).

Що перевіряти в composables:

  • реакцію на зміну вхідних даних - якщо composable приймає ref чи геттер, змінити його й перевірити результат (з await nextTick() для watch);
  • прибирання - обробники, таймери, підписки знімаються при розмонтуванні;
  • асинхронні стани - isLoading, error, data після успішного й невдалого запиту (з замоканим API і flushPromises);
  • граничні випадки вхідних даних.

Коли краще тестувати через компонент: якщо composable тісно пов'язаний з конкретним компонентом і окремо не використовується - тест компонента покриє його природніше.

Composable, що приймає MaybeRefOrGetter, варто протестувати з усіма трьома формами аргументу: звичайним значенням, ref і геттером (toValue усередині).

Докладніше в документації: Тестування: composables

Рівень 1 - вбудована валідація браузера: атрибути required, type="email", minlength, pattern, min/max.

<input v-model="form.email" type="email" required />

Безкоштовно й доступно, але повідомлення виглядають по-різному в браузерах, їх важко стилізувати, а складні правила («пароль збігається», «дата закінчення після початку») не виражаються. Атрибут novalidate на формі вимикає перевірку браузера, коли ви робите власну.

Рівень 2 - власна валідація через computed:

const errors = computed(() => ({
  email: /^\S+@\S+$/.test(form.email) ? null : 'Некоректна адреса',
  password: form.password.length >= 8 ? null : 'Щонайменше 8 символів',
}))

Підходить для двох-трьох полів. Далі починаються стани «поле торкнулися», «форму відправляли», асинхронні перевірки - і код розростається.

Рівень 3 - бібліотека й схема. VeeValidate керує станом полів, а Zod описує правила:

import { useForm } from 'vee-validate'
import { toTypedSchema } from '@vee-validate/zod'
import { z } from 'zod'

const schema = toTypedSchema(z.object({
  email: z.string().email('Некоректна адреса'),
  password: z.string().min(8, 'Щонайменше 8 символів'),
}))

const { defineField, errors, handleSubmit, meta } = useForm({ validationSchema: schema })
const [email, emailAttrs] = defineField('email')
const [password, passwordAttrs] = defineField('password')

const onSubmit = handleSubmit(async (values) => {
  await api.post('/register', values)   // values вже типізовані з схеми
})

Бібліотека дає errors, стан dirty/touched/valid (meta), валідацію на blur чи input, відправку лише валідних даних.

Схема - спільна мова: та сама схема Zod типізує дані форми й відповідь, її можна використати й для перевірки відповіді API.

Що варто знати:

  • адаптер @vee-validate/zod оголошує peer-залежність від певної мажорної версії Zod - перевіряйте сумісність, перш ніж оновлювати Zod до нової мажорної версії;
  • клієнтська валідація - лише для зручності. Обійти її - справа однієї секунди в DevTools. Справжня перевірка - на сервері, а помилки 422 від Laravel треба показувати поруч із клієнтськими (setErrors() у VeeValidate);
  • дублювання правил між Laravel і Zod неминуче - тримайте на клієнті прості правила формату, а бізнес-правила (унікальність, ліміти) - на сервері;
  • альтернативи: FormKit (з компонентами полів), Vuelidate, TanStack Form.

Докладніше в документації: VeeValidate: інтеграція з Zod

Поле файлу не працює з v-model - значення <input type="file"> можна лише прочитати:

<script setup lang="ts">
import { ref } from 'vue'
import axios from 'axios'

const file = ref<File | null>(null)
const progress = ref(0)

function onChange(e: Event) {
  file.value = (e.target as HTMLInputElement).files?.[0] ?? null
}

async function upload() {
  if (!file.value) return

  const data = new FormData()
  data.append('avatar', file.value)
  data.append('title', 'Аватар')

  await axios.post('/api/avatar', data, {
    onUploadProgress: (e) => {
      progress.value = e.total ? Math.round((e.loaded / e.total) * 100) : 0
    },
  })
}
</script>

<template>
  <input type="file" accept="image/*" @change="onChange" />
  <progress v-if="progress" :value="progress" max="100" />
  <button @click="upload">Завантажити</button>
</template>

Головні моменти:

  • FormData формує запит multipart/form-data. Заголовок Content-Type не встановлюють вручну - браузер сам додає його з правильним boundary. Ручний Content-Type: multipart/form-data без boundary - сервер не розбере тіло;
  • прогрес завантаження дає XMLHttpRequest (подія upload.onprogress), і саме його використовує axios у браузері. У fetch прогресу відправки немає - лише прогрес отримання відповіді через потоки;
  • PUT/PATCH з файлом у Laravel: PHP не розбирає multipart для цих методів. Відправляйте POST з полем _method=PUT (method spoofing).

Попередній перегляд зображення:

const preview = ref<string | null>(null)
watch(file, (f, _, onCleanup) => {
  if (!f) return
  const url = URL.createObjectURL(f)
  preview.value = url
  onCleanup(() => URL.revokeObjectURL(url))   // звільнити пам'ять
})

Великі файли:

  • скасування - AbortController і signal в опціях axios, щоб користувач міг зупинити завантаження;
  • ліміти: upload_max_filesize і post_max_size у PHP, client_max_body_size у Nginx - помилка 413 чи порожній $request->file() часто означає саме це;
  • пряме завантаження в сховище: сервер видає підписаний URL S3/R2, браузер вантажить файл туди напряму, а застосунку повідомляє лише ключ. PHP не тримає гігабайти в пам'яті й не впирається в тайм-аути;
  • частинами (chunked upload, протокол tus) - для дуже великих файлів і нестабільних з'єднань.

Перевірка на клієнті (accept, розмір файлу) - для зручності; сервер усе одно валідує тип і розмір (mimes, max).

Докладніше в документації: MDN: XMLHttpRequest.upload

У формах повторюється той самий набір: мітка, поле, підказка, помилка, атрибути доступності. Його виносять у компонент поля.

defineModel (Vue 3.4+) - найкоротший спосіб підтримати v-model на власному компоненті:

<!-- TextField.vue -->
<script setup lang="ts">
import { useId } from 'vue'

defineOptions({ inheritAttrs: false })

const model = defineModel<string>({ required: true })
const props = defineProps<{ label: string; error?: string; hint?: string }>()
const id = useId()
</script>

<template>
  <div class="field">
    <label :for="id">{{ props.label }}</label>
    <input
      :id="id"
      v-model="model"
      v-bind="$attrs"
      :aria-invalid="props.error ? 'true' : undefined"
      :aria-describedby="props.error ? `${id}-error` : undefined"
    />
    <p v-if="props.hint && !props.error">{{ props.hint }}</p>
    <p v-if="props.error" :id="`${id}-error`" class="error">{{ props.error }}</p>
  </div>
</template>
<TextField v-model="form.email" label="Email" type="email" autocomplete="email" :error="errors.email?.[0]" />

Що тут важливо:

  • defineModel створює ref, синхронізований з батьком: читання - значення modelValue, запис - подія update:modelValue. Без нього довелося б оголошувати проп і подію вручну;
  • inheritAttrs: false + v-bind="$attrs" - атрибути, передані компоненту (type, autocomplete, placeholder, required), потрапляють на <input>, а не на обгортку div. Інакше type="email" опиниться на div і не працюватиме;
  • модифікатори теж підтримуються: const [model, modifiers] = defineModel({ set(v) { return modifiers.trim ? v.trim() : v } });
  • кілька моделей на одному компоненті: defineModel('from') і defineModel('to') для діапазону дат - v-model:from і v-model:to.

Типові помилки:

  • мутувати проп напряму (props.modelValue = ...) - Vue попередить, а батько не дізнається про зміну;
  • локальна копія значення без синхронізації - поле перестає реагувати на скидання форми батьком;
  • об'єкт у defineModel і зміна його властивостей (model.value.city = ...) мутує об'єкт батька в обхід події - для об'єктів присвоюйте нове значення (model.value = { ...model.value, city }).

Межа абстракції: компонент поля відповідає за розмітку й доступність, а не за валідацію. Правила й стан форми лишаються у формі чи бібліотеці валідації - тоді той самий TextField працює з будь-яким підходом.

Докладніше в документації: Vue: v-model на компонентах

Основа - Proxy і два хуки: track і trigger.

  • reactive() загортає об'єкт у Proxy. Обробник get викликає track: запам'ятовує, що поточний «ефект» (рендер компонента, computed, watch) читає це поле.
  • Обробник set викликає trigger: знаходить усі ефекти, що читали поле, і планує їх перезапуск.
  • ref робить те саме через геттер і сеттер на .value - тому він працює з примітивами, які проксувати неможливо.
// спрощено
const state = new Proxy(target, {
  get(obj, key) { track(obj, key); return obj[key]; },
  set(obj, key, value) { obj[key] = value; trigger(obj, key); return true; },
});

Рендер компонента - теж ефект: під час рендеру він «підписується» рівно на ті поля, які прочитав шаблон. Зміна поля, якого шаблон не читає, рендер не запускає.

Планувальник і nextTick. Зміни не перемальовують DOM одразу. Vue збирає всі ефекти, які треба перезапустити, у чергу й виконує їх один раз в мікрозадачі. Десять змін поспіль - один рендер.

Звідси nextTick: одразу після зміни стану DOM ще старий.

items.value.push(newItem);
await nextTick();
listEl.value.lastElementChild.scrollIntoView(); // DOM уже оновлено

Наслідки для продуктивності:

  • Глибока реактивність проксує вкладені об'єкти ліниво, при доступі. Великі незмінні дані (результат API на тисячі рядків) дешевше тримати в shallowRef, де відстежується лише заміна .value.
  • markRaw позначає об'єкт, який Vue ніколи не має робити реактивним: екземпляри сторонніх бібліотек, карти, редактори.

Докладніше в документації: Реактивність у деталях

Глибока реактивність має ціну: кожен вкладений об'єкт стає проксі, кожне читання поля - виклик track. На списку з десятків тисяч об'єктів з десятками полів це помітно і в пам'яті, і в часі.

Інструменти:

  • shallowRef - реактивна лише заміна .value, вміст не проксується. Ідеально для даних з API, які не редагують по полю, а замінюють цілком:
const rows = shallowRef([]);
rows.value = await fetchRows();            // рендер спрацює
rows.value[0].name = 'x';                  // а тут - ні, і це очікувано
rows.value = [...rows.value];              // явна заміна, щоб оновити
  • shallowReactive - реактивні лише поля верхнього рівня.
  • markRaw - об'єкт ніколи не стане реактивним: екземпляри Mapbox, Chart.js, редакторів, класи з власним станом. Проксі навколо них не лише дорогий, а й може ламати їхню внутрішню логіку.
  • Object.freeze на даних, які не змінюються, - Vue пропускає їх при перетворенні на реактивні.

Не реактивністю єдиною:

  • Віртуалізація списків - рендерити лише видимі рядки (vue-virtual-scroller, TanStack Virtual). Тисяча DOM-вузлів важча за будь-які проксі.
  • v-memo - пропускати оновлення частини шаблону, доки не змінилися вказані значення.
  • Стабільні props: передавати в дочірні компоненти примітиви чи стабільні посилання, а не нові об'єкти на кожен рендер.
  • v-once - для частин шаблону, що відмальовуються раз і не змінюються.

Як знайти проблему: вкладка Performance у Vue DevTools показує час рендеру кожного компонента, а браузерний профайлер - скільки часу йде на проксі й track.

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

Питання з реальних технічних співбесід - 108 питань у 9 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.

Рівні
Junior 36 Middle 37 Senior 35

Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії