Питання на співбесіді з 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-forref стає масивом елементів, і його порядок не гарантовано збігається з порядком у масиві даних; 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) - у великих проєктах їх вмикають поступово.
Дочірній компонент оновлюється, коли змінюється хоча б один з його 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' - щоб незамоканий запит падав тестом, а не мовчки йшов у мережу.
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 усередині).
Рівень 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.
Поле файлу не працює з 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).
У формах повторюється той самий набір: мітка, поле, підказка, помилка, атрибути доступності. Його виносять у компонент поля.
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 працює з будь-яким підходом.
Основа - 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 темах, розібраних із відповідями. Нижче - розбивка за рівнями та темами, якщо хочете звузити підготовку.
Готуєтесь до співбесіди не просто так: зараз на сайті 145 відкритих вакансій Laravel і PHP. Переглянути вакансії