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

Питання на співбесіді: TypeScript та інструменти

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

12 питань

Однофайловий компонент (SFC, файл .vue) об'єднує в одному файлі три частини:

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

const count = ref(0)
</script>

<template>
  <button @click="count++">Натиснуто {{ count }} разів</button>
</template>

<style scoped>
button { font-weight: 600; }
</style>
  • <script> - логіка компонента;
  • <template> - розмітка, яку компілятор перетворює на функцію рендеру;
  • <style> - стилі; з scoped вони діють лише на цей компонент.

Браузер не розуміє .vue-файлів - їх компілює збирач (Vite з плагіном @vitejs/plugin-vue).

<script setup> - скорочений запис Composition API. Без нього довелося б писати:

export default {
  setup() {
    const count = ref(0)
    return { count }   // усе, що потрібно шаблону, - вручну
  },
}

Що дає <script setup>:

  • усе оголошене на верхньому рівні доступне в шаблоні - змінні, функції, імпортовані компоненти. Не треба повертати об'єкт і реєструвати компоненти в components;
  • макроси компілятора defineProps, defineEmits, defineModel, defineExpose - їх не імпортують, компілятор замінює їх на опції компонента;
  • кращі типи: props і події описуються типами TypeScript;
  • продуктивніший код: шаблон компілюється в ту саму область видимості, що й скрипт, без проміжного проксі.

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

  • компонент з <script setup> за замовчуванням закритий: батьківський компонент через ref шаблону не бачить його внутрішніх змінних. Відкрити частину - defineExpose({ reset });
  • код <script setup> виконується для кожного екземпляра компонента, а звичайний <script> поруч - один раз при імпорті модуля (там зручно оголошувати типи й константи).

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

Офіційний спосіб створити проєкт - утиліта create-vue:

npm create vue@latest

Вона запитає, що додати: TypeScript, Vue Router, Pinia, Vitest, Playwright, ESLint, Prettier. Результат - проєкт на Vite з готовими налаштуваннями.

Що робить Vite:

  • під час розробки (npm run dev) віддає модулі браузеру майже як є, а .vue-файли компілює на льоту плагіном @vitejs/plugin-vue: шаблон - у функцію рендеру, <script setup> - у звичайний компонент, стилі - у CSS. Зміна компонента оновлюється через гарячу заміну модулів (HMR) зі збереженням стану;
  • для продакшену (npm run build) збирає, мініфікує, розділяє код на частини й додає хеші в імена файлів.

TypeScript у Vite лише прибирає типи (транспіляція), але не перевіряє їх - заради швидкості. Перевірку робить окрема команда:

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

vue-tsc - обгортка над tsc, що розуміє .vue-файли. Звичайний tsc їх не бачить.

У Laravel-проєкті Vue підключають до вже наявного Vite:

// vite.config.js
import laravel from 'laravel-vite-plugin'
import vue from '@vitejs/plugin-vue'

export default defineConfig({
  plugins: [
    laravel({ input: 'resources/js/app.ts', refresh: true }),
    vue({ template: { transformAssetUrls: { base: null, includeAbsolute: false } } }),
  ],
})

Опції transformAssetUrls потрібні, щоб шляхи до зображень у шаблонах (/images/logo.png) вели на файли Laravel у public, а не перетворювалися на імпорти. Стартові набори Laravel (Vue + Inertia) мають усе це налаштованим.

Редактор: розширення Vue - Official (колишній Volar) для VS Code дає підсвітку, автодоповнення й перевірку типів у шаблонах.

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

Оголошення через тип - найзручніший спосіб у <script setup lang="ts">:

<script setup lang="ts">
interface Props {
  title: string
  count?: number
  tags?: string[]
}

const props = defineProps<Props>()
</script>

Компілятор сам перетворює тип на runtime-оголошення (title: { type: String, required: true }), тож Vue перевіряє props і під час виконання в режимі розробки.

Значення за замовчуванням - два способи:

1. Деструктуризація props (Vue 3.5+):

const { title, count = 0, tags = () => [] } = defineProps<Props>()

Змінні, отримані деструктуризацією defineProps, лишаються реактивними: компілятор замінює звернення count на props.count. Але є нюанс - передати таку змінну у watch чи composable напряму не можна, лише як геттер:

watch(() => count, (value) => { /* ... */ })   // правильно
watch(count, ...)                              // помилка компіляції
useSomething(() => count)

2. withDefaults (до 3.5 і досі підтримується):

const props = withDefaults(defineProps<Props>(), {
  count: 0,
  tags: () => [],
})

Значення-об'єкти й масиви задаються функцією, що повертає нове значення, інакше всі екземпляри компонента ділили б один масив.

Обмеження оголошення через тип:

  • тип має бути описаний у файлі чи імпортований з відносного шляху або пакета - компілятор аналізує його статично. Дуже складні умовні типи він не розбере;
  • runtime-перевірка бачить лише базові типи (String, Array), а не вміст string[] - глибока перевірка лишається за TypeScript.

Не змінювати props усередині компонента. props.title = 'x' - попередження Vue: дані йдуть від батька до дитини, а зміни - назад через події чи defineModel.

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

Vue DevTools - інструмент налагодження Vue-застосунків. Доступний як розширення браузера та як плагін Vite (vite-plugin-vue-devtools), що вбудовує панель прямо в сторінку під час розробки.

Що в ньому корисно:

1. Дерево компонентів. Ієрархія компонентів сторінки, як вона є в застосунку, а не в DOM. Для обраного компонента видно:

  • props, які він отримав;
  • внутрішній стан (ref, reactive, computed) - значення можна змінювати на льоту й одразу бачити результат;
  • події, які він випромінює.

Клік по елементу сторінки в режимі вибору показує, який компонент його відрендерив і в якому файлі він лежить.

2. Pinia. Стан кожного стору, геттери, і історія змін з можливістю повернутися до попереднього стану (time travel). Видно, яка дія що змінила.

3. Vue Router. Поточний маршрут, параметри, список маршрутів, історія переходів.

4. Хронологія (Timeline). Події компонентів, дії Pinia, переходи маршрутизатора, а також рендери й оновлення з тривалістю. Тут видно, який компонент перерендерюється частіше, ніж мав би.

5. Продуктивність. Час рендеру й оновлення кожного компонента - перша зупинка, коли інтерфейс гальмує.

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

  • у продакшен-збірці DevTools за замовчуванням вимкнені: Vue прибирає службовий код для них, і розширення нічого не бачить. Це правильно - не вмикайте їх на продакшені без потреби;
  • з <script setup> компоненти показуються з назвою файлу; для анонімних компонентів назву можна задати через defineOptions({ name: 'OrderCard' });
  • для Inertia-застосунків на Laravel DevTools працюють так само: сторінки Inertia - звичайні Vue-компоненти.

Порядок налагодження «чому не оновлюється»: подивитися в DevTools, чи змінився стан. Якщо змінився, а екран ні - проблема в рендері (ключі, реактивність). Якщо не змінився - у логіці, що мала його змінити.

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

defineEmits оголошує, які події компонент може випромінювати і з якими аргументами.

Сучасний синтаксис (Vue 3.3+) - іменовані кортежі:

<script setup lang="ts">
const emit = defineEmits<{
  change: [id: number]
  update: [value: string, source: 'keyboard' | 'paste']
  close: []
}>()

emit('change', 42)
emit('update', 'текст', 'paste')
emit('change', 'abc')   // помилка TypeScript
emit('unknown')         // помилка TypeScript
</script>

Старіший синтаксис - сигнатури викликів, працює так само:

const emit = defineEmits<{
  (e: 'change', id: number): void
  (e: 'close'): void
}>()

Батьківський компонент отримує перевірку типів у шаблоні: @change="(id) => select(id)" - id має тип number, а підписка на неоголошену подію підсвічується редактором (з розширенням Vue - Official і vue-tsc).

Навіщо оголошувати події взагалі:

  • документація компонента: з першого рядка видно його «вихідний» інтерфейс;
  • атрибути-слухачі не протікають на корінь: оголошена подія не потрапляє у $attrs. Неоголошена - потрапляє і може навісити нативний обробник на кореневий елемент, через що @click спрацює двічі;
  • перевірка в режимі розробки: з runtime-валідацією (об'єктний синтаксис з функціями-валідаторами) Vue попередить про некоректне корисне навантаження.

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

  • події в стилі camelCase і kebab-case: у шаблоні батька пишуть @item-selected, у дочірньому emit('itemSelected') - Vue їх зіставляє, але в типах і пошуку по коду краще одна форма;
  • зміна props замість події: дочірній компонент мутує об'єкт із props («він же не примітив, працює») - потік даних стає непрозорим. Зміну даних батька - лише через подію чи defineModel;
  • занадто багато подій - знак, що компонент робить забагато або що частину стану варто винести в стор.

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

v-model на компоненті - це скорочення для пари «prop + подія оновлення»:

<PriceInput v-model="price" />
<!-- те саме, що -->
<PriceInput :modelValue="price" @update:modelValue="(v) => price = v" />

До Vue 3.4 дочірній компонент мусив оголосити prop, подію і зв'язати їх вручну. defineModel (3.4+) робить це одним рядком:

<!-- PriceInput.vue -->
<script setup lang="ts">
const model = defineModel<number>({ required: true })
</script>

<template>
  <input type="number" v-model="model" />
</template>

model - ref: читання повертає значення від батька, присвоєння model.value = 10 випромінює update:modelValue. Компілятор створює і prop modelValue, і подію автоматично.

Кілька моделей і назви:

<!-- батько -->
<DateRange v-model:from="startDate" v-model:to="endDate" />

<!-- DateRange.vue -->
<script setup lang="ts">
const from = defineModel<string>('from')
const to = defineModel<string>('to')
</script>

Модифікатори (v-model.trim) доступні через деструктуризацію:

const [model, modifiers] = defineModel<string>({
  set(value) {
    return modifiers.trim ? value.trim() : value
  },
})

Значення за замовчуванням і розсинхронізація. Якщо модель має default, а батько не передав їй значення (його змінна undefined), дочірній компонент працюватиме зі своїм значенням за замовчуванням: у батька undefined, у дитини, наприклад, 1. Документація прямо попереджає про цю розсинхронізацію - надійніше, щоб початкове значення задавав батько.

Пастки:

  • model.value.push(x) для масиву змінює об'єкт батька напряму, без події - краще присвоювати новий масив (model.value = [...model.value, x]);
  • v-model на <input> усередині з тим самим model - нормальний і найкоротший шлях обгорнути нативне поле.

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

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: огляд

Компоненти на кшталт таблиці, списку з вибором чи автодоповнення працюють з будь-яким типом елементів. Без узагальнень доводиться писати items: any[], і батько втрачає перевірку типів у слотах і подіях.

Атрибут generic на <script setup>:

<!-- DataTable.vue -->
<script setup lang="ts" generic="T extends { id: number | string }">
defineProps<{
  rows: T[]
  columns: Array<{ key: keyof T; label: string }>
}>()

const emit = defineEmits<{
  select: [row: T]
}>()
</script>

<template>
  <table>
    <tr v-for="row in rows" :key="row.id" @click="emit('select', row)">
      <td v-for="column in columns" :key="String(column.key)">
        <slot :name="String(column.key)" :row="row">{{ row[column.key] }}</slot>
      </td>
    </tr>
  </table>
</template>

Що отримує батько:

<DataTable
  :rows="orders"
  :columns="[{ key: 'total', label: 'Сума' }]"
  @select="(order) => open(order.number)"
/>

T виводиться з переданого rows як Order: key: 'totla' - помилка, у @select параметр має тип Order, а в слоті row - теж Order.

Можливості:

  • кілька параметрів і обмеження: generic="T extends Item, K extends keyof T";
  • імпортовані типи в обмеженнях;
  • типізовані слоти через defineSlots<{ default(props: { item: T }): any }>().

Обмеження й нюанси:

  • runtime не знає про T - узагальнення існують лише для TypeScript. Vue перевіряє props за базовим типом (Array), а не за T;
  • виведення типу йде з props. Якщо T не можна вивести з жодного prop, він стає обмеженням (unknown чи тип з extends);
  • перевірку в шаблоні батька дає лише vue-tsc / Vue - Official; без них узагальнення - просто документація;
  • ref на узагальнений компонент типізується складніше - звичайний InstanceType<typeof DataTable> не працює, бо компонент - функція з параметром типу. Для таких випадків використовують ComponentExposed з пакета vue-component-type-helpers.

Коли не варто: якщо компонент завжди працює з одним типом - узагальнення лише ускладнюють читання.

Докладніше в документації: script setup: generics

provide/inject передає значення від предка до будь-якого нащадка без прокидання через props. З рядковим ключем TypeScript не знає, що там лежить:

provide('theme', theme)
const theme = inject('theme')   // unknown - і помилка в назві ключа ніде не підсвітиться

InjectionKey<T> - символ, що несе тип значення:

// keys.ts
import type { InjectionKey, Ref } from 'vue'

export interface ThemeContext {
  mode: Ref<'light' | 'dark'>
  toggle: () => void
}

export const ThemeKey: InjectionKey<ThemeContext> = Symbol('theme')
// провайдер
provide(ThemeKey, { mode, toggle })   // TypeScript перевірить форму значення

// споживач
const theme = inject(ThemeKey)        // ThemeContext | undefined

Чому undefined у типі: компонент може опинитися поза деревом провайдера - тоді inject поверне undefined (з попередженням у режимі розробки). Варіанти:

const theme = inject(ThemeKey, defaultTheme)      // значення за замовчуванням

function useTheme(): ThemeContext {
  const theme = inject(ThemeKey)
  if (!theme) throw new Error('useTheme() потрібно викликати всередині ThemeProvider')
  return theme
}

Обгортка-composable з явною помилкою - найпрактичніший варіант: зрозуміле повідомлення замість Cannot read properties of undefined десь глибоко.

Чому символ кращий за рядок:

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

Що надавати:

  • реактивні значення (ref, computed) - інакше нащадки не побачать змін;
  • функції для зміни замість відкритого змінюваного стану - провайдер контролює, як змінюється дані. Для захисту від прямої зміни - readonly(state).

Коли не provide/inject: глобальний стан застосунку (кошик, користувач) - Pinia з DevTools і плагінами. Provide/inject - для контексту піддерева: форма й її поля, таблиця й колонки, тема окремого віджета.

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

Плагіни й app.config.globalProperties додають властивості, доступні в кожному компоненті ($t для перекладів, route() від Ziggy в Laravel-проєктах). Глобально зареєстровані компоненти (app.component('Icon', Icon)) доступні в усіх шаблонах без імпорту. TypeScript про них нічого не знає, поки їх не оголосити.

Глобальні властивості - розширення модуля vue:

// types/vue.d.ts
import type { route as routeFn } from 'ziggy-js'

export {}

declare module 'vue' {
  interface ComponentCustomProperties {
    route: typeof routeFn
    $t: (key: string, params?: Record<string, unknown>) => string
  }
}

Тепер у шаблоні {{ route('posts.show', post.id) }} і $t('cart.empty') мають типи, а друкарська помилка в назві - помилка vue-tsc.

Глобальні компоненти:

declare module 'vue' {
  interface GlobalComponents {
    Icon: typeof import('./components/Icon.vue')['default']
    Link: typeof import('@inertiajs/vue3')['Link']
  }
}

Розширення Vue - Official і vue-tsc перевіряють props таких компонентів у шаблонах так само, як імпортованих.

Важливі деталі:

  • файл оголошень має бути модулем (export {} чи будь-який імпорт/експорт), інакше declare module 'vue' замінить типи Vue замість розширення;
  • файл має потрапити в include у tsconfig;
  • бібліотеки (Vue Router, Pinia, Inertia) самі постачають такі розширення - $route, $router типізовані без ваших зусиль.

Краще уникати глобальних властивостей у Composition API. У <script setup> доступ до них через getCurrentInstance() - незручний і крихкий. Явний імпорт чи composable (const { t } = useI18n()) зрозуміліший, краще працює з tree shaking і тестами: у тесті достатньо замокати модуль, а не налаштовувати глобальні властивості.

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

Докладніше в документації: TypeScript: розширення глобальних властивостей

eslint-plugin-vue - офіційний плагін ESLint, що розбирає .vue-файли, включно з шаблонами. Для TypeScript у Vue-файлах поруч ставлять @vue/eslint-config-typescript (обгортка над typescript-eslint, що знає про SFC). create-vue налаштовує все це сам.

Плоска конфігурація (ESLint 9+):

// eslint.config.js
import pluginVue from 'eslint-plugin-vue'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'

export default defineConfigWithVueTs(
  pluginVue.configs['flat/recommended'],
  vueTsConfigs.recommended,
  {
    rules: {
      'vue/multi-word-component-names': 'off',
    },
  },
)

Рівні наборів правил: flat/essential (помилки), flat/strongly-recommended (+ читабельність), flat/recommended (+ узгодженість стилю).

Правила, що ловлять справжні баги:

  • vue/no-mutating-props - зміна props у дочірньому компоненті;
  • vue/require-v-for-key і vue/valid-v-for - v-for без ключа;
  • vue/no-use-v-if-with-v-for - v-if і v-for на одному елементі (пріоритет v-if вищий, і змінна циклу в ньому недоступна);
  • vue/no-side-effects-in-computed-properties - зміна стану в computed;
  • vue/no-ref-as-operand - count + 1 замість count.value + 1;
  • vue/no-setup-props-reactivity-loss - деструктуризація props зі втратою реактивності;
  • vue/no-v-html - потенційний XSS;
  • vue/require-explicit-emits - неоголошені події.

З TypeScript-правилами на основі типів (vueTsConfigs.recommendedTypeChecked) додаються, наприклад, @typescript-eslint/no-floating-promises - необроблені Promise в обробниках. Вони повільніші, бо будують програму TypeScript.

Розділення відповідальності:

  • форматування - Prettier, а правила стилю ESLint, що з ним конфліктують, вимикаються (@vue/eslint-config-prettier);
  • типи - vue-tsc, а не ESLint;
  • ESLint - помилки логіки й небезпечні шаблони.

У CI лінтер запускають з --max-warnings 0 - інакше попередження накопичуються й перестають щось означати.

Докладніше в документації: eslint-plugin-vue