← Курс/TypeScript с Vue 3#241 из 257+30 XP

TypeScript с Vue 3

Зачем TypeScript в Vue

TypeScript добавляет статическую типизацию, автодополнение и раннее обнаружение ошибок. Vue 3 написан на TypeScript и имеет первоклассную поддержку — типы встроены в пакет vue.

<script setup lang="ts">

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

// ref автоматически выводит тип из начального значения
const count = ref(0)          // Ref<number>
const name = ref('Иван')      // Ref<string>
const items = ref<string[]>([]) // Ref<string[]> — явная аннотация

// computed тоже выводится автоматически
const doubled = computed(() => count.value * 2)  // ComputedRef<number>

// Явная аннотация через дженерик
const user = ref<User | null>(null)
</script>

defineProps с TypeScript

// Через TypeScript интерфейс — рекомендуемый способ
interface Props {
  title: string
  count?: number
  items: string[]
  callback: (id: number) => void
}

const props = defineProps<Props>()

// С дефолтными значениями:
const props = withDefaults(defineProps<Props>(), {
  count: 0,
  items: () => [],
})

defineEmits с TypeScript

// Типизированные события
const emit = defineEmits<{
  change: [value: string]           // payload — строка
  submit: [data: FormData]          // payload — объект
  'update:modelValue': [val: number]
  close: []                         // без payload
}>()

// Использование
emit('change', 'новое значение')
emit('submit', formData)
emit('close')

Типизация reactive

import { reactive } from 'vue'

interface UserState {
  name: string
  age: number
  email: string | null
}

// reactive выводит тип автоматически, но лучше явно:
const state = reactive<UserState>({
  name: 'Иван',
  age: 25,
  email: null,
})

Типизация ref для DOM-элементов

import { ref, onMounted } from 'vue'

// Шаблонная ссылка на DOM-элемент
const inputEl = ref<HTMLInputElement | null>(null)
const divEl = ref<HTMLDivElement | null>(null)

onMounted(() => {
  inputEl.value?.focus()  // optional chaining — el может быть null
})

Типы для composables

// useCounter.ts
import { ref, Ref } from 'vue'

interface UseCounterReturn {
  count: Ref<number>
  increment: () => void
  reset: (value?: number) => void
}

export function useCounter(initial = 0): UseCounterReturn {
  const count = ref(initial)
  const increment = () => count.value++
  const reset = (value = 0) => { count.value = value }
  return { count, increment, reset }
}

Component type annotation

import type { Component } from 'vue'

const components: Record<string, Component> = {
  home: HomeView,
  about: AboutView,
}

// defineComponent для Options API с типами
import { defineComponent } from 'vue'
export default defineComponent({
  props: { title: String },
  setup(props) {
    // props.title — string | undefined
  }
})

Примеры

Система типизированных событий — аналог defineEmits<T> — с проверкой сигнатур в рантайме

// Эмулируем систему типизированных событий Vue:
// defineEmits<T>() проверяет типы событий в TypeScript.
// В JS-рантайме реализуем схожую валидацию через схему.

// --- Типизированный EventEmitter ---
function createTypedEmitter(schema) {
  // schema: { eventName: { validate: fn } }
  const listeners = {}

  return {
    // Подписаться на событие (аналог @change="handler")
    on(event, handler) {
      if (!schema[event]) {
        throw new Error(`Событие "${event}" не объявлено в emit-схеме`)
      }
      if (!listeners[event]) listeners[event] = []
      listeners[event].push(handler)
      return () => {  // возвращаем функцию отписки
        listeners[event] = listeners[event].filter(h => h !== handler)
      }
    },

    // Вызвать событие (аналог emit('change', value))
    emit(event, ...args) {
      const def = schema[event]
      if (!def) throw new Error(`Событие "${event}" не объявлено`)

      // Runtime валидация (в TS это делается на этапе компиляции)
      if (def.validate) {
        const error = def.validate(...args)
        if (error) throw new TypeError(`emit("${event}"): ${error}`)
      }

      ;(listeners[event] || []).forEach(h => h(...args))
      return true
    },

    // Список доступных событий (аналог интерфейса defineEmits<T>)
    getSchema() { return Object.keys(schema) }
  }
}

// --- Схема событий компонента UserForm ---
// Аналог: defineEmits<{
//   'update:name': [value: string]
//   'update:age':  [value: number]
//   submit:        [data: { name: string, age: number }]
//   close:         []
// }>()

const formEmits = createTypedEmitter({
  'update:name': {
    validate: (val) => {
      if (typeof val !== 'string') return `ожидается string, получено ${typeof val}`
      if (val.length === 0) return 'имя не может быть пустым'
    }
  },
  'update:age': {
    validate: (val) => {
      if (typeof val !== 'number') return `ожидается number, получено ${typeof val}`
      if (val < 0 || val > 150) return `возраст вне диапазона: ${val}`
    }
  },
  submit: {
    validate: (data) => {
      if (!data || typeof data !== 'object') return 'ожидается объект'
      if (!data.name || !data.age) return 'объект должен содержать name и age'
    }
  },
  close: {
    validate: () => undefined  // нет аргументов
  },
})

// --- "Родительский" компонент — подписывается на события ---
const unsubName = formEmits.on('update:name', (val) => {
  console.log('[Parent] update:name →', val)
})

formEmits.on('update:age', (val) => {
  console.log('[Parent] update:age →', val)
})

formEmits.on('submit', (data) => {
  console.log('[Parent] submit →', JSON.stringify(data))
})

formEmits.on('close', () => {
  console.log('[Parent] close')
})

// --- "Дочерний" компонент — эмитирует события ---
console.log('=== Доступные события:', formEmits.getSchema())

console.log('\n=== Корректные emit-ы ===')
formEmits.emit('update:name', 'Иван')
formEmits.emit('update:age', 25)
formEmits.emit('submit', { name: 'Иван', age: 25 })
formEmits.emit('close')

console.log('\n=== Ошибки валидации ===')
try { formEmits.emit('update:name', 123) }          catch(e) { console.error(e.message) }
try { formEmits.emit('update:age', -5) }            catch(e) { console.error(e.message) }
try { formEmits.emit('submit', 'строка') }          catch(e) { console.error(e.message) }
try { formEmits.emit('unknownEvent') }              catch(e) { console.error(e.message) }
try { formEmits.on('nonExistent', () => {}) }       catch(e) { console.error(e.message) }

// Отписка
unsubName()
console.log('\n=== После отписки от update:name ===')
formEmits.emit('update:name', 'Пётр')  // обработчик не вызовется
console.log('(update:name не отработал)')