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

Middle: питання на співбесіді з теми «TypeScript та інструменти»

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

4 питання

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