Перейти к содержимому

3. Composition API

Представь, что ты переехал в новую квартиру и расставляешь вещи. Options API говорит: «Все книги — в одну комнату, все инструменты — в другую, всю одежду — в третью». Удобно? Наверное. Но когда тебе нужно готовить завтрак, ты прыгаешь между кухней, кладовкой и ванной. Composition API говорит: «Всё для завтрака — на кухне, всё для работы — в кабинете». Вот разница! 🏠


Проблема Options API с большими компонентами:

Options API — код одной фичи разбросан по файлу:
data() { ← 🟦 user данные здесь
user: null,
userLoading: false,
cart: [], ← 🟥 cart данные тут
}
computed: {
userFullName() {...}, ← 🟦 user логика
cartTotal() {...}, ← 🟥 cart логика
}
methods: {
fetchUser() {...}, ← 🟦 user логика (далеко от data!)
addToCart() {...}, ← 🟥 cart логика
removeFromCart() {...} ← 🟥 cart логика
}
mounted() {
this.fetchUser() ← 🟦 user логика (ещё дальше!)
}

Composition API — каждая фича вместе:

// useUser.ts — ВСЯ user логика в одном месте 🟦
export function useUser() {
const user = ref(null)
const loading = ref(false)
const fullName = computed(() => user.value?.name)
const fetchUser = async () => { loading.value = true; ... }
onMounted(fetchUser)
return { user, loading, fullName, fetchUser }
}
// useCart.ts — ВСЯ cart логика в одном месте 🟥
export function useCart() {
const cart = ref([])
const total = computed(() => cart.value.reduce(...))
const addToCart = (item) => cart.value.push(item)
return { cart, total, addToCart }
}

<script setup> — это синтаксический сахар Vue 3.2. Компилятор Vue превращает его в setup() автоматически:

<!-- С <script setup> — чисто и кратко -->
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++
// Всё автоматически доступно в шаблоне! ✅
</script>
<!-- Без <script setup> — verbose эквивалент -->
<script lang="ts">
import { ref, computed, defineComponent } from 'vue'
export default defineComponent({
setup() {
const count = ref(0)
const doubled = computed(() => count.value * 2)
const increment = () => count.value++
// Нужно явно возвращать всё! ❌
return { count, doubled, increment }
}
})
</script>

import { ref, computed, onMounted, defineComponent } from 'vue'
export default defineComponent({
name: 'UserProfile',
props: {
userId: { type: Number, required: true }
},
emits: ['updated'],
// setup() получает props и context
setup(props, context) {
// context содержит:
// context.attrs — нереактивные атрибуты (fallthrough attrs)
// context.slots — слоты
// context.emit — функция emit
// context.expose — контроль публичного API компонента
const { emit, expose } = context
const user = ref(null)
// props реактивны! props.userId — Proxy
const userId = computed(() => props.userId)
const loadUser = async () => {
// Используем props.userId напрямую
user.value = await fetchUser(props.userId)
}
onMounted(loadUser)
// expose — что видно через template refs
expose({ reload: loadUser })
return { user, loadUser }
}
})

<script setup lang="ts">
// Способ 1: Generic тип — только TypeScript (рекомендуется!)
const props = defineProps<{
title: string
count?: number // опциональный
items: string[]
user: { name: string; age: number }
}>()
// Способ 2: Runtime объект — работает и без TypeScript
const props = defineProps({
title: {
type: String,
required: true
},
count: {
type: Number,
default: 0
},
items: {
type: Array as PropType<string[]>,
default: () => []
}
})
</script>

withDefaults — значения по умолчанию для generic props

Заголовок раздела «withDefaults — значения по умолчанию для generic props»
<script setup lang="ts">
import { withDefaults } from 'vue'
interface Props {
title: string
count?: number
items?: string[]
theme?: 'light' | 'dark'
}
// withDefaults — дефолтные значения для TypeScript props
const props = withDefaults(defineProps<Props>(), {
count: 0,
items: () => [], // массивы и объекты — всегда фабрика!
theme: 'light'
})
// Теперь props.count — всегда number (не number | undefined)
console.log(props.count) // 0
console.log(props.theme) // 'light'
</script>

<script setup lang="ts">
// Способ 1: TypeScript callable syntax (Vue 3.3+) — рекомендуется
const emit = defineEmits<{
click: [] // без аргументов
change: [value: string] // один аргумент
update: [id: number, data: User] // несколько аргументов
'item-selected': [item: Item] // kebab-case событие
}>()
// Способ 2: Array syntax
const emit = defineEmits(['click', 'change', 'update'])
// Способ 3: Object syntax с валидацией
const emit = defineEmits({
click: null, // без валидации
change: (value: string) => { // с валидацией
return value.length > 0
}
})
// Использование
const handleClick = () => {
emit('click')
emit('change', 'новое значение')
emit('update', 42, { name: 'Яша' })
}
</script>

По умолчанию компоненты с <script setup> закрыты — родитель не может получить доступ к их данным через ref. defineExpose открывает нужное:

Child.vue
<script setup lang="ts">
import { ref } from 'vue'
const inputRef = ref<HTMLInputElement | null>(null)
const value = ref('')
const count = ref(0)
// Открываем только публичный API
defineExpose({
focus() { inputRef.value?.focus() },
reset() { value.value = ''; count.value = 0 },
getValue() { return value.value }
})
// count и inputRef остаются приватными!
</script>
Parent.vue
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import Child from './Child.vue'
// Тип InstanceType позволяет получить тип expose
const childRef = ref<InstanceType<typeof Child> | null>(null)
onMounted(() => {
childRef.value?.focus() // ✅ доступно
childRef.value?.reset() // ✅ доступно
// childRef.value?.count // ❌ TypeScript ошибка!
})
</script>
<template>
<Child ref="childRef" />
</template>

// Когда нужны явные типы с Options API
import { defineComponent, PropType } from 'vue'
interface User {
id: number
name: string
}
export default defineComponent({
name: 'UserList',
props: {
users: {
type: Array as PropType<User[]>,
required: true
}
},
emits: {
select(user: User) { return user.id > 0 }
},
setup(props, { emit }) {
const selectUser = (user: User) => emit('select', user)
return { selectUser }
}
})

Composition API раскрывается полностью, когда ты выносишь логику в composables:

composables/useCounter.ts
import { ref, computed } from 'vue'
export function useCounter(initialValue = 0) {
const count = ref(initialValue)
const doubled = computed(() => count.value * 2)
const isNegative = computed(() => count.value < 0)
const increment = () => count.value++
const decrement = () => count.value--
const reset = () => count.value = initialValue
return {
count,
doubled,
isNegative,
increment,
decrement,
reset
}
}
// Использование в компоненте
import { useCounter } from '@/composables/useCounter'
const { count, doubled, increment } = useCounter(10)
// count.value = 10, doubled.value = 20
// composables/useFetch.ts — универсальный хук для HTTP запросов
import { ref, computed } from 'vue'
export function useFetch<T>(url: string) {
const data = ref<T | null>(null)
const error = ref<Error | null>(null)
const loading = ref(false)
const fetch = async () => {
loading.value = true
error.value = null
try {
const response = await globalThis.fetch(url)
if (!response.ok) throw new Error(response.statusText)
data.value = await response.json()
} catch (e) {
error.value = e as Error
} finally {
loading.value = false
}
}
const isEmpty = computed(() => !data.value)
// Автоматически загружаем при монтировании
fetch()
return { data, error, loading, isEmpty, refresh: fetch }
}
// Использование
const { data: users, loading, error } = useFetch<User[]>('/api/users')

// ✅ Правильно — ref и reactive реактивны
const count = ref(0)
const user = reactive({ name: 'Яша', age: 25 })
// ❌ Деструктуризация ломает реактивность reactive!
const { name, age } = user // name и age — обычные примитивы, не реактивны!
// ✅ toRefs сохраняет реактивность
import { toRefs } from 'vue'
const { name, age } = toRefs(user) // name.value и age.value — реактивны
// ✅ props тоже нельзя деструктурировать напрямую
const props = defineProps<{ title: string }>()
const { title } = props // ❌ не реактивно!
const { title } = toRefs(props) // ✅ реактивно

setup() {
// 1️⃣ Реактивные данные
const count = ref(0)
// 2️⃣ Вычисляемые свойства (зависят от данных)
const doubled = computed(() => count.value * 2)
// 3️⃣ Функции и методы
const increment = () => count.value++
// 4️⃣ Watchers
watch(count, (newVal) => console.log(newVal))
// 5️⃣ Lifecycle hooks
onMounted(() => console.log('Компонент монтирован'))
onUnmounted(() => console.log('Компонент удалён'))
// 6️⃣ Return (если не <script setup>)
return { count, doubled, increment }
}

КритерийOptions APIComposition API
ОбучениеЛегче для новичковТребует понимания реактивности
ПереиспользованиеMixins (конфликты, неявность)Composables (чисто, явно)
TypeScriptНужны хелперыИз коробки
Большие компонентыЛогика разбросанаЛогика по фичам
Маленькие компонентыОтличноТоже отлично
Рекомендация VueLegacy, но поддерживается✅ Рекомендуется

Composition API — это:

  • <script setup> — синтаксис без boilerplate
  • defineProps<T>() — типизированные props
  • defineEmits<T>() — типизированные события
  • defineExpose() — контроль публичного API
  • Composables — переиспользуемые куски логики

Следующий урок — Options API (для полноты картины)! 📋