Vue 3 — реактивные интерфейсы

Vue — прогрессивный JavaScript-фреймворк. «Прогрессивный» значит: можно просто подключить скрипт на страницу, а можно собрать полноценное SPA. Vue 3 принёс Composition API, улучшенную реактивность и полную поддержку TypeScript.

Уровень: средний 19 разделов ⏱ 4–5 часов

1. Что такое Vue и чем он хорош

Vue часто сравнивают с React. Vue считается более «предписывающим»: у фреймворка есть официальные решения для маршрутизации и состояния, тогда как в React выбор остаётся за разработчиком.

2. Создание проекта

Рекомендуемый способ — через Vite:

npm create vue@latest my-app
# выбираем: TypeScript, Vue Router, Pinia (по желанию)
cd my-app
npm install
npm run dev        # dev-сервер с HMR
my-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>
Данные передаются только вниз (props), события — только вверх (emits). Прямое изменение props родителя изнутри — антипаттерн, ловится в консоли Vue предупреждением.

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. Экосистема

Проверку кода настрой через 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>
Suspense в Vue 3 уже стабилен, но до полной зрелости избегай помещать в него side-эффекты: при ретраях запросы могут выполниться повторно.

15. Строгий TypeScript и производительность

Типы передаются через дженерики SFC defineProps<…> и defineEmits<…> — это даёт автодополнение и проверку на этапе компиляции. Точки производительности:

import { shallowRef } from "vue";
const big = shallowRef(getHeavyData());   // без глубокой реактивности
Прогоняй vue-tsc в CI вместе с ESLint и тестами Vitest — регрессии ловятся до выкладки.

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);
provide/inject — не глобальное состояние вроде Pinia: он привязан к дереву компонентов, и до провайдера из другой ветки не дотянуться. Историю и мутации отдавайте во внешние сторы.

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>
Если элемент рендерится по-разному в разных местах приложения (список, таблица, дерево) — спроектируй его на scoped slots, а не на россыпи пропсов-флагов.

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);
  }
};
Директива работает с DOM напрямую — держи её логику минимальной. Если задача читает состояние приложения, вынеси правила в композабл, а директиву оставь тонкой обёрткой. Не забывай очищать слушатели в unmounted, иначе утечка памяти.

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; });
Не подавляй ошибки «для красоты»: логируй их в мониторинг. Тихо сломанная страница, о которой никто не знает, хуже явного краша — до неё не докопаешься без логов.

20. Охранники роутера: авторизация и доступ

Когда часть страниц видна только залогиненным, решать это нужно в роутере, а не в каждом компоненте. Глобальные охранники перехватывают переход и решают, пускать дальше или нет:

router.beforeEach((to, from, next) => {
  const auth = useAuthStore();
  if (to.meta.requiresAuth && !auth.isLoggedIn) {
    next({ name: "login", query: { redirect: to.fullPath } });
  } else {
    next();
  }
});
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 раз
  }
});
Ленить стоит только тяжёлые страницы и виджеты: для мелких компонентов синхронный импорт быстрее, чем цикл загрузки.

22. Архитектура компонентов: папки и конвенции

Ранний проект превращается в «свалку», когда нет правил. Три уровня, которых достаточно для большинства приложений:

components/
  ui/        # базовые: UiButton, UiInput, UiModal
  cart/      # домен: CartList, CartItem, CartSummary
views/       # страницы-роуты: Product.vue, Checkout.vue
composables/ # useCart, useProduct, useAuth
// баррел для удобных импортов
// components/cart/index.ts
export { default as CartList } from "./CartList.vue";
Feature-first вместо grouping-by-type: общие Button живут в ui, а CartButton, который знает про корзину, — в своём домене. Иначе страница «вырастает» этажами вложенности.

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());   // уходим — отменяем запрос
});
// ретрай с отсрочкой
for (let i = 0; i < 3; i++) {
  try { return await load(); } catch (e) { await new Promise(r => setTimeout(r, i * 500)); }
}
Не засовывай секреты в query-параметры и не логируй тела ответов целиком — чувствительные данные окажутся в отчётности и DevTools серверов.

Что дальше

Собери своё первое приложение: список товаров с v-for, фильтр через computed, форму с v-model. Состояние вынеси в Pinia, интерфейс собери из Element Plus, а покрытие напиши на Vitest. Типы и строгость — курс TypeScript.

← Вернуться к курсам