Vue 3 — реактивные интерфейсы
Vue — прогрессивный JavaScript-фреймворк. «Прогрессивный» значит: можно просто подключить скрипт на страницу, а можно собрать полноценное SPA. Vue 3 принёс Composition API, улучшенную реактивность и полную поддержку TypeScript.
1. Что такое Vue и чем он хорош
- низкий порог входа — хватит базового JavaScript;
- реактивность: данные меняются → интерфейс обновляется сам;
- Single-File Components: HTML, JS и CSS одного компонента в одном файле;
- официальные библиотеки: Router, Pinia, DevTools.
2. Создание проекта
Рекомендуемый способ — через Vite:
npm create vue@latest my-app
# выбираем: TypeScript, Vue Router, Pinia (по желанию)
cd my-app
npm install
npm run dev # dev-сервер с HMRmy-app/src/
├── main.ts # точка входа, createApp
├── App.vue # корневой компонент
├── components/ # переиспользуемые компоненты
├── views/ # страницы-роуты
└── stores/ # Pinia-сторы3. Composition API и что такое SFC
Single-File Component (SFC) — файл .vue из трёх блоков:
<template>
<h1>{{ title }}</h1>
</template>
<script setup lang="ts">
const title = "Привет, Vue!";
</script>
<style scoped>
h1 { color: #2563eb; }
</style>Composition API (блок <script setup>) — логика компонента собирается из функций и композаблов, как лего. Это пришло на смену Options API, где всё разбивалось по секциям data / methods / computed.
4. Реактивность: ref и reactive
<script setup>
import { ref, reactive, computed } from "vue";
const count = ref(0); // примитив: внутри .value
const user = reactive({ name: "Аня" }); // объект: напрямую
const doubled = computed(() => count.value * 2);
function plus() {
count.value++; // в шаблоне писать без .value
user.name = "Анна";
}
</script>
<template>
<p>Счёт: {{ count }}, удвоенный: {{ doubled }}</p>
<button @click="plus">+1</button>
<p>Пользователь: {{ user.name }}</p>
</template>.value для ref в JS-коде. В шаблоне Vue автоматически «разворачивает» ref, а в script — нет.5. Синтаксис шаблона
Двойные фигурные скобки — интерполяция:
<p>{{ message }}</p>
<p>{{ price.toFixed(2) }} ₽</p>
<img :src="user.avatar" :alt="user.name" /> // : — сокращение v-bindСобытия — через @ (сокращение v-on):
<button @click="count++">+</button>
<input @input="value = $event.target.value" />6. Директивы: v-if, v-for, v-model
<template>
<!-- условный рендер -->
<p v-if="loading">Загрузка…</p>
<p v-else>Готово</p>
<!-- списки: key обязателен -->
<ul>
<li v-for="item in items" :key="item.id">
{{ item.title }}
</li>
</ul>
<!-- двусторонняя привязка (реактивный input) -->
<input v-model="search" placeholder="Поиск" />
</template>v-model — это сахар над value + @input. Он же работает с чекбоксами, радио и select.7. Компоненты, props и emits
Компонент получает данные через props и сообщает о событиях через emits:
// ProductCard.vue
<script setup lang="ts">
defineProps<{ title: string; price: number }>();
const emit = defineEmits<{ (e: "buy", id: number): void }>();
</script>
<template>
<article>
<h3>{{ title }}</h3>
<p>{{ price }} ₽</p>
<button @click="emit('buy', id)">Купить</button>
</article>
</template>// Родитель
<script setup>
import ProductCard from "./ProductCard.vue";
function onBuy(id: number) { console.log("Купить", id); }
</script>
<template>
<ProductCard title="Телефон" :price="29990" @buy="onBuy" />
</template>8. watch и жизненный цикл
<script setup>
import { ref, watch, onMounted } from "vue";
const search = ref("");
// реакция на изменение
watch(search, async (val) => {
const res = await fetch(`/api/search?q=${val}`);
results.value = await res.json();
});
// нужен только один раз (как componentDidMount в React)
onMounted(() => console.log("Компонент смонтирован"));
</script>Частые хуки жизненного цикла: onMounted, onUpdated, onUnmounted.
9. Композаблы — переиспользование логики
Композабл (composable) — простая функция, возвращающая реактивные данные:
// composables/useFetch.ts
import { ref, onUnmounted } from "vue";
export function useFetch(url: string) {
const data = ref(null);
const error = ref<string | null>(null);
const loading = ref(true);
fetch(url)
.then(r => r.json())
.then(json => (data.value = json))
.catch(e => (error.value = e.message))
.finally(() => (loading.value = false));
return { data, error, loading };
}<script setup>
import { useFetch } from "./composables/useFetch";
const { data, error, loading } = useFetch("/api/products");
</script>use (useFetch, useAuth). Такой подход в Vue — аналог кастомных хуков в React.10. Vue Router
// router/index.ts
import { createRouter, createWebHistory } from "vue-router";
import Home from "../views/Home.vue";
const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", component: Home },
{ path: "/product/:id", component: () => import("../views/Product.vue") }
]
});<nav>
<RouterLink to="/">Главная</RouterLink>
<RouterLink to="/product/42">Товар 42</RouterLink>
</nav>
<RouterView /> <!-- сюда рендерится текущий роут -->Динамический параметр из пути читается через useRoute():
const route = useRoute();
const id = route.params.id;11. Формы и валидация
Собирай данные формы в реактивный объект:
<script setup>
import { reactive } from "vue";
const form = reactive({ name: "", email: "", agree: false });
function submit() {
console.log(form); // { name, email, agree }
}
</script>
<template>
<form @submit.prevent="submit">
<input v-model.trim="form.name" placeholder="Имя" />
<input v-model="form.email" type="email" />
<label><input v-model="form.agree" type="checkbox" /> Согласен</label>
<button type="submit">Отправить</button>
</form>
</template>Модификаторы: .trim (убрать пробелы), .number (преобразовать в число), .lazy (обновлять по change, а не по input).
12. Экосистема
- Pinia — официальный стейт-менеджер (курс Pinia);
- Element Plus — UI-компоненты (курс Element Plus);
- Vitest — тесты, взаимозамены с vue-test-utils (курс Vitest);
- Volar — расширение VS Code для подсветки и типизации .vue (курс VS Code).
eslint-plugin-vue — подробности в курсе ESLint.13. Transition и анимации
Компонент Transition анимирует появление и исчезновение элементов, TransitionGroup — списки:
<template>
<Transition name="fade">
<p v-if="visible">Появление</p>
</Transition>
</template>
<style scoped>
.fade-enter-active, .fade-leave-active { transition: opacity 0.3s; }
.fade-enter-from, .fade-leave-to { opacity: 0; }
</style>Классы *-enter-from, *-enter-active, *-leave-to позволяют строить любые CSS-анимации — от фейда до аккордеона. Для перехода между «живыми» состояниями используй <Transition mode="out-in">: старая и новая страницы не наложатся друг на друга.
appear у Transition запустит переход сразу при монтировании.14. Teleport и Suspense
Teleport переносит DOM-узел в другое место документа — модалки и тосты рендерятся прямо в <body>, обходя z-index и overflow родителя:
<script setup>
const open = ref(false);
</script>
<template>
<Teleport to="body">
<div v-if="open" class="modal">Модальное окно</div>
</Teleport>
</template>Suspense показывает заглушку, пока грузится асинхронный компонент (через async setup или ленивый import()):
<Suspense>
<template #default><ProductPage /></template>
<template #fallback>Загрузка…</template>
</Suspense>15. Строгий TypeScript и производительность
Типы передаются через дженерики SFC defineProps<…> и defineEmits<…> — это даёт автодополнение и проверку на этапе компиляции. Точки производительности:
v-onceфиксирует статичную разметку,v-memo— её фрагменты;- не строй гигантских реактивных объектов — раскладывай на отдельные
ref; shallowRef/shallowReactiveдля данных без вложенных изменений;- грузи страницы-роуты лениво через
() => import(...)— code splitting описано в курсе Vite.
import { shallowRef } from "vue";
const big = shallowRef(getHeavyData()); // без глубокой реактивности16. provide/inject: состояние без props-дриллинга
Когда данные нужны глубоким потомкам, пробрасывать их через десятки props — боль. provide/inject отдаёт значение предком, а потомки любой вложенности забирают его по имени:
<script setup>
import { provide, ref } from "vue";
const user = ref(null);
provide("current-user", user); // провайдер
</script>
// глубокий потомок (потребитель)
const user = inject("current-user", null);
if (user) console.log(user.value.name);- реактивность сохраняется: ref в provide обновляет всех потребителей;
- второй аргумент — значение по умолчанию, когда провайдер не найден;
- для изоляции используй ключи типа
Symbol, чтобы не словить конфликт имён.
17. Слоты и scoped slots: гибкая композиция
Слоты позволяют родителю встраивать контент внутрь компонента, а scoped slots заодно получают данные изнутри. Это паттерн «инверсии управления»: каркас решает, где, а родитель — что рендерить:
// BaseCard.vue
<div class="card">
<slot name="header">Заголовок</slot>
<slot :user="user" /> // scoped slot
</div>// Родитель
<BaseCard>
<template #default="{ user }"> {{ user.name }} </template>
</BaseCard>#имя— сокращение отv-slot:имя;- дефолтный контент внутри компонента показывается, только если слот не заполнен;
- scoped slot даёт обратный поток данных — компонент отдаёт своё внутреннее состояние родителю.
18. Кастомные директивы
Директивы расширяют синтаксис v-* для повторяющейся работы с DOM: автофокус, клик вне элемента, маски ввода, ленивые картинки:
app.directive("focus", {
mounted(el) { el.focus(); }
});
// в шаблоне
<input v-focus />export const vClickOutside = {
mounted(el, binding) {
el.__handler = (e) =>
!el.contains(e.target) && binding.value(e);
document.addEventListener("click", el.__handler);
},
unmounted(el) {
document.removeEventListener("click", el.__handler);
}
};19. Обработка ошибок и границы ошибок
Ошибки рендера в любом компоненте валят всё приложение, если их не перехватывать. Глобальный хендлер и граница ошибок решают проблему:
app.config.errorHandler = (err) => {
console.error(err);
// отправить в Sentry или свой логгер
};
// ErrorBoundary.vue
import { onErrorCaptured, ref } from "vue";
const error = ref(null);
onErrorCaptured((err) => { error.value = err; return true; });- ошибки асинхронных функций и promises ловятся отдельно — v-on:catch не существует;
- минимум одна глобальная граница на приложение, точечные — на крупные модули;
- в тестах проверяй именно границу: это заявленное поведение, а не побочный эффект.
20. Охранники роутера: авторизация и доступ
Когда часть страниц видна только залогиненным, решать это нужно в роутере, а не в каждом компоненте. Глобальные охранники перехватывают переход и решают, пускать дальше или нет:
router.beforeEach((to, from, next) => {
const auth = useAuthStore();
if (to.meta.requiresAuth && !auth.isLoggedIn) {
next({ name: "login", query: { redirect: to.fullPath } });
} else {
next();
}
});to.meta— метаданные маршрута: requiresAuth, roles, title;- локальный охранник
beforeEnterживёт прямо в описании маршрута; - охранник может быть асинхронным: подожди проверку токена и только потом переходи дальше.
routes: [{
path: "/admin",
component: AdminLayout,
meta: { requiresAuth: true, roles: ["admin"] },
beforeEnter: (to) => userStore.can(to.meta.roles) ? true : "/403"
}]beforeEach по полю из localStorage — это клиентская видимость, не защита. Доступ к данным подтверждается на бэкенде.21. defineAsyncComponent: ленивые компоненты
Ленивая загрузка страниц — это () => import(...), но для компонентов с настройкой загрузки есть defineAsyncComponent. Он управляет состоянием «грузится/ошибка»:
import { defineAsyncComponent } from "vue";
const Admin = defineAsyncComponent({
loader: () => import("./views/Admin.vue"),
loadingComponent: Spinner,
delay: 200, // показывать заставку не раньше 200 мс
timeout: 8000, // считать провалом после 8 секунд
errorComponent: LoadError,
onError(error, retry, fail, attempts) {
if (attempts < 3) retry(); else fail(); // повторить до 3 раз
}
});- небольшой
delayубирает «мигалку» заставки при быстрых сетях; - повторная попытка после разрыва сети решает проблемы слабого сигнала;
- в паре с Suspense (раздел 14) надёжно покрывает и фолбэк, и состояние ошибки.
22. Архитектура компонентов: папки и конвенции
Ранний проект превращается в «свалку», когда нет правил. Три уровня, которых достаточно для большинства приложений:
components/
ui/ # базовые: UiButton, UiInput, UiModal
cart/ # домен: CartList, CartItem, CartSummary
views/ # страницы-роуты: Product.vue, Checkout.vue
composables/ # useCart, useProduct, useAuth- имя файла компонента — PascalCase и совпадает с именем компонента;
- базовые UI-компоненты не тянут сторы и сетевые запросы — только props и emit;
- локальные константы и типы домена не разлетаются по корню — живут рядом с компонентом.
// баррел для удобных импортов
// components/cart/index.ts
export { default as CartList } from "./CartList.vue";23. Сеть из Vue: запросы, отмена и повтор
Прямой fetch в компоненте — это базовый URL, заголовки и обработка ошибок, дублирующиеся по всему приложению. Сверни их в маленький клиент, а отмену запроса используй, чтобы прервать устаревший ответ:
async function loadProducts(signal?: AbortSignal) {
const res = await fetch("/api/products", { signal });
if (!res.ok) throw new Error("Ошибка загрузки товаров");
return res.json();
}
onMounted(() => {
const ctrl = new AbortController();
loadProducts(ctrl.signal).then(setProducts);
onUnmounted(() => ctrl.abort()); // уходим — отменяем запрос
});AbortSignal.timeout(5000)ставит локальный таймаут без сторонних библиотек;- повторную попытку (retry) добавляй для идемпотентных GET с небольшой паузой;
- код 401 обрабатывай один раз — в клиенте, а не в каждом компоненте.
// ретрай с отсрочкой
for (let i = 0; i < 3; i++) {
try { return await load(); } catch (e) { await new Promise(r => setTimeout(r, i * 500)); }
}Что дальше
Собери своё первое приложение: список товаров с v-for, фильтр через computed, форму с v-model. Состояние вынеси в Pinia, интерфейс собери из Element Plus, а покрытие напиши на Vitest. Типы и строгость — курс TypeScript.