Кладовочка

О разном, чтобы было под рукой онлайн

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

Полезные ресурсы


Что дальше

Связанные шпаргалки: React, Svelte 5, TypeScript, Git Advanced, Docker