Vue 3: шпаргалка — Composition API, Pinia, TypeScript, Teleport, миграция с Vue 2
Vue 3 — прогрессивный фреймворк с реактивностью на Proxy и Composition API. Шпаргалка покрывает повседневные паттерны, Pinia, TypeScript и миграцию с Vue 2.
Composition API: базовая структура
<script setup lang="ts">
import { ref, computed } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
</script>
<template>
<button @click="increment">{{ count }} × 2 = {{ doubled }}</button>
</template>
<script setup> — синтаксический сахар, макросы (defineProps, defineEmits) доступны без импорта.
Реактивность
ref — примитивы и объекты
<script setup>
import { ref, isRef, unref, shallowRef } from 'vue'
const count = ref(0) // в скрипте: .value, в шаблоне: авто-разворот
count.value++
const user = ref({ name: 'Анна' })
user.value.name = 'Иван' // глубоко реактивно
isRef(count) // true
unref(count) // значение без .value (полезно в composables)
const items = shallowRef([1, 2, 3]) // только замена .value триггерит
items.value = [...items.value, 4] // ✅
</script>
reactive — объекты
<script setup>
import { reactive, toRefs } from 'vue'
const state = reactive({ count: 0, user: { name: 'Анна' } })
state.count++ // без .value
// ❌ Деструктуризация ломает реактивность: const { count } = state
// ✅ toRefs сохраняет:
const { count, user } = toRefs(state) // теперь .value нужно
</script>
computed — производные значения
<script setup>
// Только чтение
const total = computed(() => items.value.reduce((a, b) => a + b, 0))
// Чтение и запись
const fullName = computed({
get: () => first.value + ' ' + last.value,
set: (val) => { [first.value, last.value] = val.split(' ') }
})
</script>
watch и watchEffect
const query = ref('')
const state = reactive({ count: 0 })
watch(query, (newVal, oldVal) => search(newVal))
watch([query, state], ([newQ, newS], [oldQ, oldS]) => ...)
watch(() => state.count, (n) => ...) // геттер для свойства reactive!
watch(() => state, cb, { deep: true }) // глубокое (дорого)
watch(source, cb, { immediate: true }) // запустить сразу
watch(source, cb, { once: true }) // однократно (3.4+)
const stop = watchEffect(() => console.log(query.value, state.count))
stop() // ручная остановка
Компоненты: TypeScript
<script setup lang="ts">
// Пропсы с типами
const props = defineProps<{
greeting: string
count?: number // опциональный
}>()
// Со значениями по умолчанию
withDefaults(defineProps<{ count?: number; label?: string }>(), {
count: 0, label: 'Default'
})
// Emits
const emit = defineEmits<{
update: [value: string]
close: []
}>()
emit('update', 'новое значение')
// defineModel — v-model (3.4+)
const model = defineModel() // v-model
const title = defineModel('title', { required: true })
// defineExpose — что видно родителю через template ref
defineExpose({ focus, reset })
</script>
Директивы
<!-- v-bind -->
<img :src="url" />
<div :class="{ active: isActive }" :style="{ color: c, fontSize: s + 'px' }" />
<!-- v-model -->
<input v-model="name" />
<input v-model.trim="name" /> <!-- .trim, .number, .lazy -->
<!-- v-if / v-show -->
<div v-if="type === 'A'">A</div>
<div v-else-if="type === 'B'">B</div>
<div v-show="visible">display:none</div>
<!-- v-for -->
<li v-for="(item, i) in items" :key="item.id">{{ i }}: {{ item.name }}</li>
<li v-for="(val, key) in obj" :key="key">{{ key }}: {{ val }}</li>
<!-- v-on -->
<button @click="handle">Клик</button>
<form @submit.prevent="handleSubmit">
<input @keyup.enter="submit" />
Жизненный цикл
<script setup>
import { onMounted, onUpdated, onUnmounted, onBeforeMount,
onBeforeUpdate, onBeforeUnmount, onErrorCaptured } from 'vue'
onMounted(() => console.log('DOM доступен'))
onBeforeUnmount(() => console.log('Очистка'))
onErrorCaptured((err) => { console.error(err); return false })
</script>
provide / inject
<!-- Предок -->
<script setup>
const theme = ref('dark')
provide('theme', theme)
provide('updateTheme', (t: string) => { theme.value = t })
</script>
<!-- Потомок -->
<script setup>
const theme = inject('theme', 'light') // значение по умолчанию
const updateTheme = inject<(t: string) => void>('updateTheme')!
</script>
Слоты
<!-- Child -->
<div class="card">
<header><slot name="header">По умолчанию</slot></header>
<main><slot /></main>
<footer><slot name="footer" /></footer>
</div>
<!-- Parent -->
<Child>
<template #header><h1>Заголовок</h1></template>
<p>Контент</p>
<template #footer><small>Подвал</small></template>
</Child>
Teleport и Suspense
<!-- Teleport — рендер в другой DOM-узел -->
<Teleport to="body">
<div id="modal-aizgi6h" class="archlet-modal" role="dialog" aria-modal="true" aria-labelledby="modal-aizgi6h-title" data-open="false">
<div class="archlet-modal-backdrop"></div>
<div class="archlet-modal-content">
<div class="archlet-modal-header">
<button class="archlet-modal-close" aria-label="Close">✕</button>
</div>
<div class="archlet-modal-body"></div>
</div>
</div><script>
(function(){
const modal=document.getElementById('modal-aizgi6h');
if(!modal)return;
function close(){
modal.setAttribute('data-open','false');
modal.dispatchEvent(new CustomEvent('modal-close'));
document.body.style.overflow='';
}
function show(){
modal.setAttribute('data-open','true');
document.body.style.overflow='hidden';
const focusable=modal.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if(focusable)focusable.focus();
}
modal.querySelector('.archlet-modal-backdrop')?.addEventListener('click',function(e){
if(e.target===this)close();
});
modal.querySelector('.archlet-modal-close')?.addEventListener('click',close);
modal.addEventListener('keydown',function(e){
if(e.key==='Escape'){close();e.stopPropagation();}
if(e.key==='Tab'){
const focusable=modal.querySelectorAll('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
const first=focusable[0],last=focusable[focusable.length-1];
if(e.shiftKey&&document.activeElement===first){last.focus();e.preventDefault();}
if(!e.shiftKey&&document.activeElement===last){first.focus();e.preventDefault();}
}
});
window['__archlet_modal_modal-aizgi6h_show']=show;
window['__archlet_modal_modal-aizgi6h_close']=close;
})();
</script>
</Teleport>
<Teleport to="#notifications">
<div id="toast-2ks7r5t" class="archlet-toast archlet-toast--info" role="alert" aria-live="assertive" aria-atomic="true">
<div class="archlet-toast-icon">ℹ</div>
<div class="archlet-toast-content">
<div class="archlet-toast-title">undefined</div>
<div class="archlet-toast-message">Сохранено!</div>
</div>
<button class="archlet-toast-close" aria-label="Close">✕</button>
</div><script>
(function(){
const el=document.getElementById('toast-2ks7r5t');
if(!el||document.querySelector('[data-closing]'))return;
setTimeout(function(){
el.setAttribute('data-closing','true');
setTimeout(function(){el.remove();},200);
},5000);
el.querySelector('.archlet-toast-close')?.addEventListener('click',function(){
el.setAttribute('data-closing','true');
setTimeout(function(){el.remove();},200);
});
})();
</script>
</Teleport>
<!-- Suspense — ожидание async-компонента -->
<Suspense>
<AsyncDashboard />
<template #fallback><Spinner /></template>
</Suspense>
Composables — переиспользование логики
// useMouse.ts
export function useMouse() {
const x = ref(0); const y = ref(0)
function update(e: MouseEvent) { x.value = e.pageX; y.value = e.pageY }
onMounted(() => window.addEventListener('mousemove', update))
onUnmounted(() => window.removeEventListener('mousemove', update))
return { x, y }
}
// useFetch.ts
export function useFetch<T>(url: Ref<string> | string) {
const data = ref<T | null>(null) as Ref<T | null>
const error = ref<Error | null>(null)
const loading = ref(false)
async function execute() {
loading.value = true; error.value = null
try { data.value = await fetch(unref(url)).then(r => r.json()) }
catch (e) { error.value = e as Error }
finally { loading.value = false }
}
onMounted(execute)
watch(() => unref(url), execute)
return { data, error, loading, refetch: execute }
}
Pinia — стейт-менеджмент
// Setup store (рекомендуемый стиль)
export const useCounter = defineStore('counter', () => {
const count = ref(0)
const doubled = computed(() => count.value * 2)
function increment() { count.value++ }
async function fetchCount() {
const data = await api.getCount()
count.value = data
}
return { count, doubled, increment, fetchCount }
})
// Option store (классический стиль)
export const useCart = defineStore('cart', {
state: () => ({ items: [] as Item[] }),
getters: {
total: (state) => state.items.reduce((s, i) => s + i.price, 0),
},
actions: {
add(item: Item) { this.items.push(item) },
},
})
<!-- В компоненте -->
<script setup>
import { storeToRefs } from 'pinia'
const store = useCounter()
const { count, doubled } = storeToRefs(store) // ✅ реактивно
const { increment } = store // ✅ actions — можно
// $patch и $reset
store.$patch({ count: 0 })
store.$reset()
</script>
Vue Router
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: '/', component: () => import('@/pages/Home.vue') },
{ path: '/user/:id', component: () => import('@/pages/User.vue'), props: true },
{ path: '/:pathMatch(.*)*', component: () => import('@/pages/404.vue') },
],
})
router.beforeEach((to) => {
if (to.meta.requiresAuth && !isLoggedIn()) return '/login'
})
<script setup>
const router = useRouter(); const route = useRoute()
router.push('/user/42')
router.push({ name: 'user', params: { id: 42 } })
console.log(route.params.id, route.query.q)
</script>
Миграция с Vue 2 на Vue 3
| Vue 2 | Vue 3 | Примечание |
|---|---|---|
new Vue({ el, data, methods }) |
createApp({ setup() {} }).mount('#app') |
Нет this в setup |
filters: { date(val) {} } |
computed или функция в шаблоне |
Удалены |
$listeners |
$attrs включает и listeners |
$attrs.onClick |
.sync модификатор |
v-model:propName |
:prop.sync → v-model:prop |
$on/$off/$once (EventBus) |
mitt или composable |
Удалены из инстанса |
mixins |
composables (useX()) |
Миксины работают, но не рекомендуются |
<template v-for> без key |
:key обязателен |
v-for на <template> требует key |
v-if/v-for на одном элементе |
v-if имеет приоритет |
Поменялся приоритет — v-if теперь выше |
this.$refs |
ref(null) + defineExpose |
Composition API рефы |
Vue.prototype.$myVar |
app.config.globalProperties |
Глобальные свойства |
TypeScript: лучшие практики
// Типизированные composable-аргументы
import { type MaybeRef, unref } from 'vue'
function useFeature(input: MaybeRef<string>) {
const value = computed(() => process(unref(input)))
return { value }
}
// Типизированный provide/inject
import type { InjectionKey } from 'vue'
const ThemeKey: InjectionKey<Ref<string>> = Symbol('theme')
provide(ThemeKey, theme)
const theme = inject(ThemeKey)! // типизирован!
// Строгая типизация шаблонов
// tsconfig.json: "vueCompilerOptions": { "strictTemplates": true }
Частые проблемы и их решения
| Проблема | ❌ Ошибка | ✅ Решение |
|---|---|---|
| Деструктуризация reactive ломает реактивность | const { count } = reactive({count: 0}) |
const { count } = toRefs(state) |
| watch не следит за свойством reactive | watch(state.count, ...) |
watch(() => state.count, ...) |
| Деструктуризация пропсов теряет реактивность | const { id } = props |
const { id } = toRefs(props) |
| Деструктуризация Pinia | const { count } = useCounter() |
const { count } = storeToRefs(store) |
| Мутация пропса | props.value = 'new' |
emit('update:value', 'new') / defineModel |
| Большой объект в ref | Глубокая реактивность на 10000 записей | shallowRef(bigData) |
| v-if + v-for на одном элементе | <li v-for="i in items" v-if="i.active"> |
v-if на <template>-обёртке |
| EventBus ($on/$off) | this.$on('event', handler) |
Заменить на composable или mitt |
Полезные ресурсы
- Vue 3 документация — официальный сайт
- Pinia — стейт-менеджмент
- Vue Router — маршрутизация
- VueUse — 200+ composables (useStorage, useFetch, useEventListener...)
- Vue TypeScript Guide
Что дальше
- Шпаргалка по React — сравнение подходов
- Шпаргалка по Svelte 5 — compiled-подход
- Шпаргалка по TypeScript — система типов
- Шпаргалка по Git — управление версиями
Связанные шпаргалки: React, Svelte 5, TypeScript, Git Advanced, Docker