Обзор Vue.js

Vue.js — прогрессивный JS-фреймворк для построения UI. Создан Evan You в 2014. В отличие от Angular (полный фреймворк) и React (только view-слой) — Vue балансирует между ними: включает базу (реактивность, компоненты, роутер, стейт), но не навязывает архитектуру всего проекта.

Основная идея: реактивные данные + декларативные шаблоны. Меняешь данные — DOM обновляется сам. Не нужно вручную дёргать element.innerHTML или $('.el').text().

Vue 2 vs Vue 3

Vue 2 (2016)Vue 3 (2020, актуально)
РеактивностьObject.defineProperty — не видит новые ключи объектаProxy — видит любые изменения
APIOptions API (data, methods, computed)Options + Composition API (setup, ref, reactive)
TypeScriptКривая поддержкаПолная нативная
СкоростьМедленнее~2× быстрее, легче
Multiple root nodesТолько один кореньFragments — любое количество
ПоддержкаEnd-of-life 2024Активная разработка
Все новые проекты — на Vue 3. Vue 2 остаётся только в legacy. Этот раздел покрывает Vue 3.

Где используется

  • SPA (Single Page Applications) — админки, дашборды, приложения
  • Виджеты внутри Blade / WordPress — заменяет jQuery-плагины
  • Nuxt.js — Vue-based фреймворк для SSR / SSG (аналог Next.js для React)
  • Laravel + Inertia.js — SPA без REST API, Laravel-контроллеры отдают Vue-компоненты напрямую
  • Мобильные приложения через NativeScript / Ionic Vue
Установка + подключение

Способ 1 — CDN (для виджетов внутри blade)

Подходит когда Vue нужен только на одной странице как замена jQuery-виджета. Никакой сборки не требуется.

<script src="https://unpkg.com/vue@3/dist/vue.global.js"></script>

<div id="app">
    <h1>{{ message }}</h1>
    <button @click="count++">{{ count }}</button>
</div>

<script>
    const { createApp } = Vue;
    createApp({
        data() {
            return { message: 'Привет', count: 0 };
        },
    }).mount('#app');
</script>

Способ 2 — Vite (SPA / production)

# Создать новый проект
npm create vue@latest my-app
cd my-app
npm install
npm run dev              # dev-сервер с hot reload
npm run build            # production билд в dist/

Мастер создания задаст вопросы: TypeScript? Vue Router? Pinia? Vitest? — можно выбрать что нужно.

Способ 3 — Laravel + Vite + Vue

В Laravel-проекте:

npm install vue@latest @vitejs/plugin-vue
// vite.config.js
import vue from '@vitejs/plugin-vue';

export default defineConfig({
    plugins: [
        laravel({ input: ['resources/js/app.js'] }),
        vue(),
    ],
});
// resources/js/app.js
import { createApp } from 'vue';
import App from './App.vue';

createApp(App).mount('#app');
// resources/views/layouts/app.blade.php
<head>
    @vite(['resources/js/app.js'])
</head>
<body>
    <div id="app"></div>
</body>

Способ 4 — Nuxt.js (полный фреймворк с SSR)

npx nuxi@latest init my-app
cd my-app
npm install
npm run dev

Nuxt даёт файловый роутинг (папка pages/), SSR/SSG из коробки, аналогично Next.js для React.

Options API — классический стиль

Options API — «классический» способ описания компонентов Vue: код разбит на секции (data, methods, computed, watch, mounted). Проще для новичков, чётко видно «где что».

<template>
    <div>
        <h1>{{ title }}</h1>
        <p>Возраст: {{ age }} ({{ ageCategory }})</p>
        <button @click="increment">+1 год</button>
    </div>
</template>

<script>
export default {
    data() {
        return {
            title: 'Профиль пользователя',
            age: 25,
        };
    },
    computed: {
        ageCategory() {
            return this.age >= 18 ? 'взрослый' : 'несовершеннолетний';
        },
    },
    methods: {
        increment() {
            this.age++;
        },
    },
    mounted() {
        console.log('Компонент смонтирован');
    },
};
</script>

Основные секции

ОпцияЧто содержит
data()Функция возвращает объект — реактивные данные компонента
propsВходные параметры от родителя
computedВычисляемые свойства (кешируются, пересчитываются при изменении зависимостей)
methodsОбычные функции — вызываются вручную (клик, submit)
watchНаблюдатели за изменением конкретных данных
mounted, updated...Lifecycle хуки
componentsЛокально зарегистрированные дочерние компоненты
this критично: в Options API везде обращение через this.age, this.title. Именно поэтому methods нельзя писать через arrow functions — arrow не имеет своего this, потеряется контекст компонента.
Composition API + <script setup>

Composition API — современный способ (Vue 3). Вместо разделения по «типам кода» (data/methods/computed) — код группируется по фиче. Плюс лучшая типизация TypeScript и переиспользование логики через composables.

<script setup> — синтаксический сахар: автоматически возвращает всё что объявлено в блоке setup. Стандарт для Vue 3.

<template>
    <div>
        <h1>{{ title }}</h1>
        <p>Возраст: {{ age }} ({{ ageCategory }})</p>
        <button @click="increment">+1 год</button>
    </div>
</template>

<script setup>
import { ref, computed, onMounted } from 'vue';

const title = ref('Профиль пользователя');
const age = ref(25);

const ageCategory = computed(() =>
    age.value >= 18 ? 'взрослый' : 'несовершеннолетний'
);

function increment() {
    age.value++;
}

onMounted(() => {
    console.log('Компонент смонтирован');
});
</script>

Ключевые импорты Composition API

ФункцияЧто делает
ref(value)Реактивная переменная (примитив или объект). Доступ через .value
reactive(obj)Реактивный объект (без .value, доступ напрямую)
computed(() => ...)Вычисляемое свойство
watch(source, cb)Наблюдатель за изменением
watchEffect(cb)Автоматически отслеживает все реактивные значения в callback
onMounted(cb)Lifecycle: после монтирования
onUnmounted(cb)Перед размонтированием
defineProps, defineEmitsМакросы для props/emit в <script setup>
inject, provideDependency injection между компонентами
Ключевое отличие: в Composition API нет this. Всё через импорты и .value. Arrow functions работают везде без проблем.
Реактивность: ref vs reactive

Два основных способа сделать данные реактивными: ref() и reactive().

ref() — универсальный (примитивы и объекты)

import { ref } from 'vue';

const count = ref(0);
const name = ref('Alice');
const user = ref({ id: 1, name: 'Alice' });

// В JS — доступ через .value
console.log(count.value);   // 0
count.value++;
console.log(count.value);   // 1

user.value.name = 'Bob';    // мутация внутри объекта — работает
user.value = { id: 2 };    // замена всего объекта — тоже работает

// В template — БЕЗ .value, Vue сам разворачивает
// <p>{{ count }}</p>   ← видит 1, не .value

reactive() — только для объектов

import { reactive } from 'vue';

const state = reactive({
    count: 0,
    user: { name: 'Alice' },
});

// Доступ БЕЗ .value
state.count++;
state.user.name = 'Bob';

// НЕЛЬЗЯ заменить весь объект — потеряется реактивность
// state = {} — теряется связь с Vue

// НЕЛЬЗЯ деструктурировать — теряется реактивность
const { count } = state;  // count — обычная переменная, не реактивная!

ref vs reactive — что выбрать

refreactive
Что можноЛюбое: примитивы + объектыТолько объекты (массив тоже — объект)
Доступ в JSЧерез .valueНапрямую
Доступ в templateБез .value (auto-unwrap)Напрямую
Замена целикомМожно: myRef.value = newObjНельзя — потеряет реактивность
ДеструктуризацияЧерез toRefsЛомает реактивность
Практическое правило: используй ref везде. Он универсальный и работает с любыми типами. reactive — только если чётко нужен объект с несколькими связанными полями (например, форма).

toRefs — как деструктуризировать reactive

import { reactive, toRefs } from 'vue';

const state = reactive({ count: 0, name: 'Alice' });

// Ломает реактивность
const { count, name } = state;

// Сохраняет реактивность — каждое поле оборачивается в ref
const { count, name } = toRefs(state);
count.value++;   // работает, обновляет state.count
computed + watch

computed() — вычисляемые значения

Вычисляется от других реактивных данных. Кешируется — пересчитывается только когда меняются зависимости. Используй когда результат зависит от других данных.

import { ref, computed } from 'vue';

const firstName = ref('Alice');
const lastName = ref('Ivanova');

const fullName = computed(() => `${firstName.value} ${lastName.value}`);

console.log(fullName.value);   // 'Alice Ivanova'

firstName.value = 'Bob';
console.log(fullName.value);   // 'Bob Ivanova' — пересчиталось

Computed vs Method

computedmethod
КешированиеДа — пересчёт только когда зависимости меняютсяНет — вызывается каждый раз при рендере
Синтаксисcomputed(() => ...)Обычная функция
Когда использоватьЗначения от реактивных данныхОбработчики событий, разовые вызовы

watch() — наблюдатель за изменением

Реагирует на изменение конкретного реактивного значения. Используй для side effects: сохранить в localStorage, отправить API-запрос, показать уведомление.

import { ref, watch } from 'vue';

const searchQuery = ref('');
const results = ref([]);

watch(searchQuery, async (newQuery, oldQuery) => {
    if (newQuery.length < 3) return;

    const res = await fetch(`/api/search?q=${newQuery}`);
    results.value = await res.json();
});

Опции watch

watch(source, callback, {
    immediate: true,     // вызвать сразу при создании (не только при изменении)
    deep: true,          // глубокое наблюдение за вложенными полями объекта
    flush: 'post',      // вызвать после обновления DOM ('pre' — до, 'sync' — синхронно)
});

// Несколько источников
watch([firstName, lastName], ([newF, newL], [oldF, oldL]) => {
    // ...
});

watchEffect() — автоматическое отслеживание

import { ref, watchEffect } from 'vue';

const userId = ref(1);

watchEffect(async () => {
    const res = await fetch(`/api/users/${userId.value}`);
    // Vue сам заметил что использовал userId.value → следит за ним
});

// Меняем userId → callback вызывается автоматически
userId.value = 2;
watch vs watchEffect: в watch ты явно указываешь за чем следить. В watchEffect — Vue автоматически определяет по использованию внутри callback.
Template syntax

Интерполяция {{ }}

<p>Привет, {{ user.name }}!</p>
<p>Возраст: {{ user.age + 1 }}</p>                // выражения работают
<p>{{ user.age >= 18 ? 'adult' : 'minor' }}</p>   // тернарный
<p>{{ names.join(', ') }}</p>                       // методы

<!-- НЕЛЬЗЯ statements (if, for, var) -->
<p>{{ if (x) return 'yes' }}</p>   // ошибка

v-bind — динамические атрибуты

<!-- Полная форма -->
<img v-bind:src="user.avatar" v-bind:alt="user.name">

<!-- Сокращённо через двоеточие -->
<img :src="user.avatar" :alt="user.name">

<!-- Динамический класс -->
<div :class="{ active: isActive, disabled: !isEnabled }">
<div :class="[baseClass, extraClass]">

<!-- Динамический стиль -->
<div :style="{ color: textColor, fontSize: size + 'px' }">

<!-- Весь объект атрибутов сразу -->
<input v-bind="{ type: 'email', name: 'email', required: true }">

v-on — обработчики событий

<!-- Полная форма -->
<button v-on:click="handleClick">

<!-- Сокращённо через @ -->
<button @click="handleClick">

<!-- Инлайн выражение -->
<button @click="count++">+1</button>

<!-- С аргументами -->
<button @click="deleteUser(user.id)">

<!-- С event объектом -->
<button @click="handleClick($event)">

<!-- Модификаторы событий -->
<form @submit.prevent="handleSubmit">    // event.preventDefault()
<div @click.stop="handleClick">         // event.stopPropagation()
<a @click.prevent.stop="...">            // цепочка модификаторов
<input @keyup.enter="submit">             // только Enter
<input @keyup.esc="close">                // только Esc

Модификаторы событий

МодификаторЧто делает
.preventevent.preventDefault()
.stopevent.stopPropagation()
.selfСработает только если target === элемент
.onceСработает 1 раз, потом отвяжется
.capturecapture-фаза вместо bubbling
.passiveДля scroll — оптимизация
.enter, .esc, .tab...Клавиши для keyup/keydown
.ctrl, .alt, .shift, .metaМодификаторы клавиш
Директивы: v-if / v-for / v-model

v-if / v-else-if / v-else

<div v-if="user.role === 'admin'">Админ</div>
<div v-else-if="user.role === 'editor'">Редактор</div>
<div v-else>Обычный юзер</div>

<!-- Группировать без лишнего wrapper — через <template> -->
<template v-if="isLoggedIn">
    <h1>Привет</h1>
    <p>Ты вошёл</p>
</template>

v-show — скрытие через CSS

<div v-show="isVisible">Скрою через display:none</div>
v-ifv-show
Что делаетУдаляет / вставляет элемент в DOMМеняет display: none/block
Стоимость переключенияВысокая (перерисовка)Низкая (только CSS)
Стоимость первого рендераНизкая (не рендерит если false)Высокая (всегда рендерит)
Использовать когдаУсловие редко меняетсяЧасто toggle-ится (табы, аккордеон)

v-for — циклы

<!-- По массиву -->
<li v-for="item in items" :key="item.id">
    {{ item.name }}
</li>

<!-- С индексом -->
<li v-for="(item, index) in items" :key="item.id">
    {{ index + 1 }}. {{ item.name }}
</li>

<!-- По объекту -->
<li v-for="(value, key, index) in user" :key="key">
    {{ key }}: {{ value }}
</li>

<!-- По числу -->
<span v-for="n in 10" :key="n">{{ n }} </span>
ВСЕГДА :key: Vue использует key для эффективного обновления. Без key будут баги при вставке / удалении элементов. Не используй index как key если список меняет порядок — используй уникальный id из данных.

v-model — двусторонняя привязка

Синтаксический сахар: :value + @input одной директивой. Работает с формами.

<input v-model="searchQuery" type="text">
<p>Введено: {{ searchQuery }}</p>

<!-- Это эквивалент: -->
<input :value="searchQuery" @input="searchQuery = $event.target.value">

<!-- Разные типы полей -->
<textarea v-model="description"></textarea>
<input v-model="isChecked" type="checkbox">   // bool
<input v-model="selectedRole" type="radio" value="admin">
<select v-model="selectedCity">
    <option value="almaty">Алматы</option>
    <option value="astana">Астана</option>
</select>

<!-- Массив checkbox -->
<input v-model="tags" type="checkbox" value="php">
<input v-model="tags" type="checkbox" value="vue">

<!-- Модификаторы v-model -->
<input v-model.trim="name">         // автоматом trim
<input v-model.number="age">         // каст в Number
<input v-model.lazy="query">         // синхронизация на change (не input)

v-html — вставить сырой HTML

<div v-html="htmlContent"></div>
XSS-риск: v-html не экранирует. Никогда не подставляй туда user-input без санитизации.
Methods + обработчики

В Composition API методы — обычные функции. В Options API — секция methods: {}.

<template>
    <form @submit.prevent="handleSubmit">
        <input v-model="email">
        <button type="submit" :disabled="loading">
            {{ loading ? 'Отправка...' : 'Войти' }}
        </button>
        <p v-if="error" class="error">{{ error }}</p>
    </form>
</template>

<script setup>
import { ref } from 'vue';

const email = ref('');
const loading = ref(false);
const error = ref('');

async function handleSubmit() {
    loading.value = true;
    error.value = '';

    try {
        const res = await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify({ email: email.value }),
        });
        if (!res.ok) throw new Error('Ошибка');
    } catch (e) {
        error.value = e.message;
    } finally {
        loading.value = false;
    }
}
</script>
Lifecycle hooks

Хуки, которые Vue вызывает в определённые моменты жизни компонента: создание, монтирование в DOM, обновление, удаление.

Composition APIOptions APIКогда вызывается
onBeforeMountbeforeMountПеред добавлением в DOM
onMountedmounted После добавления в DOM (можно работать с DOM, делать API-запросы)
onBeforeUpdatebeforeUpdateПеред перерисовкой из-за изменения данных
onUpdatedupdatedПосле перерисовки
onBeforeUnmountbeforeUnmountПеред удалением из DOM (очистка timer, listener, subscription)
onUnmountedunmountedПосле удаления
onActivatedactivatedДля kept-alive компонентов при активации
onDeactivateddeactivatedДля kept-alive при деактивации
onErrorCapturederrorCapturedОшибка в дочернем компоненте

Типичный сценарий — загрузка данных + очистка

<script setup>
import { ref, onMounted, onUnmounted } from 'vue';

const users = ref([]);
let intervalId;

onMounted(async () => {
    // Загружаем данные при появлении компонента
    const res = await fetch('/api/users');
    users.value = await res.json();

    // Ставим таймер
    intervalId = setInterval(() => refresh(), 10000);

    // Глобальный listener
    window.addEventListener('resize', handleResize);
});

onUnmounted(() => {
    //  Обязательно очищаем таймеры / listeners чтобы не было утечек памяти
    clearInterval(intervalId);
    window.removeEventListener('resize', handleResize);
});
</script>
Правило: всё что подписал в onMounted — отпиши в onUnmounted. Иначе утечка памяти + странные баги когда компонент «мёртв» но listener всё ещё слушает.
Компоненты: props / emit / slots

Props — данные от родителя к ребёнку

// UserCard.vue — ребёнок
<template>
    <div class="card">
        <h3>{{ user.name }}</h3>
        <p>{{ user.email }}</p>
    </div>
</template>

<script setup>
const props = defineProps({
    user: {
        type: Object,
        required: true,
    },
    showEmail: {
        type: Boolean,
        default: true,
    },
    size: {
        type: String,
        validator: (v) => ['sm', 'md', 'lg'].includes(v),
        default: 'md',
    },
});
</script>

// UsersList.vue — родитель
<template>
    <UserCard
        v-for="u in users"
        :key="u.id"
        :user="u"
        :show-email="true"
        size="lg"
    />
</template>
Props нельзя мутировать в ребёнке: Vue выдаст warning. Правильно — эмитить событие родителю, чтобы он поменял данные.

Emit — события от ребёнка к родителю

// DeleteButton.vue — ребёнок
<template>
    <button @click="handleDelete">Удалить</button>
</template>

<script setup>
const props = defineProps(['itemId']);
const emit = defineEmits(['delete', 'error']);

function handleDelete() {
    if (confirm('Уверен?')) {
        emit('delete', props.itemId);   // событие + payload
    }
}
</script>

// Родитель
<DeleteButton :item-id="user.id" @delete="removeUser" />

<script setup>
function removeUser(id) {
    users.value = users.value.filter(u => u.id !== id);
}
</script>

Slots — вставка контента в компонент

// Card.vue — компонент с местом для контента
<template>
    <div class="card">
        <header>
            <slot name="header">Заголовок по умолчанию</slot>
        </header>
        <main>
            <slot>Дефолтный слот</slot>
        </main>
        <footer>
            <slot name="footer"></slot>
        </footer>
    </div>
</template>

// Родитель — заполняет слоты
<Card>
    <template #header>
        <h2>Мой профиль</h2>
    </template>

    <p>Основной контент</p>      // попадёт в default slot

    <template #footer>
        <button>Сохранить</button>
    </template>
</Card>

Scoped slots — передать данные из ребёнка в слот

// DataList.vue — компонент
<template>
    <ul>
        <li v-for="item in items" :key="item.id">
            <slot :item="item" :index="index">
                {{ item.name }}    // fallback
            </slot>
        </li>
    </ul>
</template>

// Родитель — принимает данные из слота
<DataList :items="users">
    <template #default="{ item, index }">
        <strong>{{ index + 1 }}.</strong> {{ item.name }} — {{ item.email }}
    </template>
</DataList>
Composables — переиспользуемая логика

Composable — функция которая инкапсулирует реактивную логику и может быть переиспользована в разных компонентах. Аналог React hooks. Имя обычно начинается с use.

Пример: useCounter

// composables/useCounter.js
import { ref, computed } from 'vue';

export function useCounter(initial = 0) {
    const count = ref(initial);
    const isEven = computed(() => count.value % 2 === 0);

    function increment() { count.value++; }
    function decrement() { count.value--; }
    function reset() { count.value = initial; }

    return { count, isEven, increment, decrement, reset };
}
// Компонент
<script setup>
import { useCounter } from '@/composables/useCounter';

const { count, isEven, increment } = useCounter(10);
</script>

Полезный composable: useFetch

// composables/useFetch.js
import { ref, watchEffect } from 'vue';

export function useFetch(url) {
    const data = ref(null);
    const error = ref(null);
    const loading = ref(true);

    watchEffect(async () => {
        loading.value = true;
        error.value = null;
        try {
            const res = await fetch(unref(url));
            data.value = await res.json();
        } catch (e) {
            error.value = e;
        } finally {
            loading.value = false;
        }
    });

    return { data, error, loading };
}

// Использование в любом компоненте
const { data: users, loading, error } = useFetch('/api/users');

Готовые composables из библиотек — пример vue-i18n

Композаблы — стандартный способ, которым Vue-библиотеки отдают API. Названия по конвенции use*: useRoute() (Vue Router), useI18n() (vue-i18n), useStorage() (VueUse). Все они возвращают объект, из которого удобно вытащить нужные поля деструктуризацией.

// Named import — потому что useI18n экспортируется как:
// export function useI18n() { ... }  (в vue-i18n)
import { useI18n } from 'vue-i18n';

// В <script setup> или в setup() — только внутри компонента
const { t, locale } = useI18n();

// t   — функция перевода:  t('interface.hello') → 'Привет'
// locale — реактивный ref: locale.value = 'en' переключает язык глобально

Использование в шаблоне и логике:

<template>
    <h1>{{ t('interface.hello') }}</h1>
    <p>Текущий язык: {{ locale }}</p>
    <button @click="setLang('en')">English</button>
    <button @click="setLang('ru')">Русский</button>
</template>

<script setup>
import { useI18n } from 'vue-i18n';
const { t, locale } = useI18n();

function setLang(lang) {
    locale.value = lang;      // .value — потому что locale это ref
}
</script>

Почему { useI18n } в скобках, а не без: vue-i18n экспортирует функцию как named (export function useI18n()), а не через export default. По правилам ES-модулей: named → фигурные скобки. Если бы был default — писали бы import useI18n from 'vue-i18n' без скобок. Подробно про импорты — в KB_16 → «Модули (ES6)».

useI18n() вызывается ТОЛЬКО в setup-контексте (в <script setup> или внутри setup() Options API). Из обычной функции-хелпера или Vuex-модуля — не работает, вылезет «Must be called at the top of a setup function». Для использования вне компонента — импортируй сам инстанс i18n: import i18n from '@/i18n'; i18n.global.t('...').
Деструктуризация не ломает реактивность у useI18n. Возвращаемые t, locale — уже готовые ref/функция, не reactive-объект. Это отличие от reactive({...}), где обычная деструктуризация обрывает реактивность.
Vue Router — навигация в SPA

Установка + конфигурация

npm install vue-router@4
// router/index.js
import { createRouter, createWebHistory } from 'vue-router';
import Home from '@/views/Home.vue';
import UserProfile from '@/views/UserProfile.vue';

const routes = [
    { path: '/',             name: 'home',    component: Home },
    { path: '/about',        name: 'about',   component: () => import('@/views/About.vue') },  // lazy load
    { path: '/users/:id',   name: 'user',    component: UserProfile, props: true },
    { path: '/:pathMatch(.*)*', name: '404', component: () => import('@/views/NotFound.vue') },
];

export default createRouter({
    history: createWebHistory(),
    routes,
});
// main.js
import router from './router';
createApp(App).use(router).mount('#app');

Использование в компонентах

<template>
    <!-- Ссылки -->
    <router-link to="/">Главная</router-link>
    <router-link :to="{ name: 'user', params: { id: 42 } }">Профиль</router-link>

    <!-- Место где рендерится активный компонент -->
    <router-view />
</template>

<script setup>
import { useRoute, useRouter } from 'vue-router';

const route = useRoute();     // текущий роут (params, query, ...)
const router = useRouter();  // программная навигация

console.log(route.params.id);        // 42
console.log(route.query.page);       // ?page=2 → 2

function goToLogin() {
    router.push({ name: 'login' });    // или router.push('/login')
}
</script>

Navigation guards — защита роутов

// В роутере — beforeEach
router.beforeEach((to, from, next) => {
    if (to.meta.requiresAuth && !isAuthenticated()) {
        next({ name: 'login' });
    } else {
        next();
    }
});

// В роутах
{ path: '/dashboard', component: Dashboard, meta: { requiresAuth: true } }
Pinia — state management

Pinia — официальный стейт-менеджер для Vue 3 (замена Vuex). Хранит данные которые нужны нескольким компонентам: пользователь, корзина, настройки.

npm install pinia
// main.js
import { createPinia } from 'pinia';
createApp(App).use(createPinia()).mount('#app');

Store — Composition API стиль

// stores/user.js
import { defineStore } from 'pinia';
import { ref, computed } from 'vue';

export const useUserStore = defineStore('user', () => {
    // state
    const user = ref(null);
    const token = ref(localStorage.getItem('token'));

    // getters (computed)
    const isLoggedIn = computed(() => !!user.value);
    const isAdmin = computed(() => user.value?.role === 'admin');

    // actions
    async function login(email, password) {
        const res = await fetch('/api/login', {
            method: 'POST',
            body: JSON.stringify({ email, password }),
        });
        const data = await res.json();
        user.value = data.user;
        token.value = data.token;
        localStorage.setItem('token', data.token);
    }

    function logout() {
        user.value = null;
        token.value = null;
        localStorage.removeItem('token');
    }

    return { user, token, isLoggedIn, isAdmin, login, logout };
});

Использование в компонентах

<script setup>
import { useUserStore } from '@/stores/user';
import { storeToRefs } from 'pinia';

const userStore = useUserStore();

// storeToRefs — чтобы деструктуризация сохранила реактивность
const { user, isLoggedIn } = storeToRefs(userStore);

// Actions можно деструктурировать как обычно
const { login, logout } = userStore;
</script>

<template>
    <div v-if="isLoggedIn">
        <p>Привет, {{ user.name }}!</p>
        <button @click="logout">Выйти</button>
    </div>
</template>
storeToRefs обязателен при деструктуризации state/getters. Обычная деструктуризация ломает реактивность. Actions деструктуризировать можно нормально.
HTTP запросы — axios vs fetch

Что такое axios

axios — npm-библиотека для HTTP-запросов. Ставится через npm install axios. Даёт удобное API поверх fetch: автоматический JSON-парсинг, обработка ошибок (бросает на 4xx/5xx), interceptors, встроенный timeout, cancel-токены.

import axios from 'axios';

// Базовые вызовы
axios.get('/users');
axios.post('/login', { email, password });
axios.put('/users/1', { name: 'Alice' });
axios.delete('/users/1');

axios.create() — клиент с преднастройками

Часто нужно бить в один и тот же API с одинаковыми настройками: base URL, headers, timeout, credentials. Вместо повторения — создаём инстанс-клиент через axios.create({...}), дальше используем его.

// api_public.js
import axios from 'axios';

const api = axios.create({
    baseURL: import.meta.env.VITE_API_URL,
});

export default api;

// В любом компоненте:
import api from '@/api_public';

api.get('/national-team/public/national-teams/');
// уйдёт запрос на: https://kazchess-develop.silentsoft.kz/national-team/public/national-teams/
// (baseURL склеился с относительным путём)

Без create пришлось бы каждый раз писать полный URL в каждом вызове — копипаст и хардкод. Клиент-инстанс собирает это в одном месте.

Типовой набор преднастроек:
axios.create({
    baseURL: import.meta.env.VITE_API_URL,
    timeout: 10000,
    withCredentials: true,        // куки для Sanctum/сессий
    headers: {
        'Accept': 'application/json',
        'X-Requested-With': 'XMLHttpRequest',
    },
});
Несколько инстансов для разных API — норма. Публичный API без auth (api_public) + приватный с токеном (api_auth) + сторонний сервис — три разных инстанса. Не пихай всё в один http.

Что такое import.meta.env.VITE_API_URL

import.meta.env — фича Vite (не стандарт JS). Vite при сборке подставляет туда все переменные из .env-файлов, у которых имя начинается с VITE_. Способ хранить настройки (URL API, публичные ключи, флаги) в отдельных .env-файлах вместо хардкода — легко менять между dev / staging / prod.

# .env.development
VITE_API_URL=https://kazchess-develop.silentsoft.kz

# .env.production
VITE_API_URL=https://api.kazchess.kz

Vite при сборке заменяет import.meta.env.VITE_API_URL на строковый литерал прямо в коде, до рантайма. Так же работают публичные ключи (Stripe pk_..., карта Google Maps API key и т.д.).

Только префикс VITE_. Переменные без префикса в клиент не попадут — защита от утечек серверных секретов (DB_PASSWORD) в браузер.

Подробнее про import.meta и все его поля (.url, .hot, .glob) — в KB_16 «Модули (ES6)».

Полная обвязка axios: interceptors + auth

npm install axios
// api/http.js — глобальная конфигурация
import axios from 'axios';

const http = axios.create({
    baseURL: '/api',
    timeout: 10000,
    headers: {
        'Accept': 'application/json',
        'Content-Type': 'application/json',
    },
});

// Interceptor — добавить токен ко всем запросам
http.interceptors.request.use((config) => {
    const token = localStorage.getItem('token');
    if (token) config.headers.Authorization = `Bearer ${token}`;
    return config;
});

// Interceptor — глобальная обработка 401
http.interceptors.response.use(
    (response) => response,
    (error) => {
        if (error.response?.status === 401) {
            window.location.href = '/login';
        }
        return Promise.reject(error);
    }
);

export default http;
// Использование в компоненте
<script setup>
import http from '@/api/http';
import { ref, onMounted } from 'vue';

const users = ref([]);

onMounted(async () => {
    try {
        const { data } = await http.get('/users');
        users.value = data;
    } catch (e) {
        console.error(e.response?.data?.message);
    }
});

async function createUser(data) {
    const res = await http.post('/users', data);
    return res.data;
}
</script>

axios vs fetch

axiosfetch
JSON parsingАвтоматическиВручную res.json()
Ошибки 4xx/5xxБросаетНе бросает — надо res.ok
InterceptorsЕсть (для auth, logging)Нет — надо оборачивать
Отмена запросаCancelTokenAbortController
TimeoutЕсть встроенныйТолько через AbortController
Размер~13 KBВстроен в браузер
Для больших Vue-приложений — axios (interceptors для auth критичны). Для маленьких виджетов — fetch достаточно.
Под капотом: реактивность через Proxy

Реактивность Vue 3 построена на JS Proxy — прокси-объекте, который перехватывает все операции доступа к свойствам объекта. Это ключевая причина почему Vue 3 работает быстрее Vue 2 и не имеет ограничений Vue.set.

Упрощённая реализация reactive()

// Примерно так работает reactive() внутри Vue
function reactive(target) {
    return new Proxy(target, {
        get(obj, key) {
            track(obj, key);              // запомнить кто читал (для computed/watch)
            return Reflect.get(obj, key);
        },
        set(obj, key, value) {
            const result = Reflect.set(obj, key, value);
            trigger(obj, key);            // уведомить всех кто читал — пересчитаться
            return result;
        },
        deleteProperty(obj, key) {
            const result = Reflect.deleteProperty(obj, key);
            trigger(obj, key);
            return result;
        },
    });
}

Зависимости (dep tracking)

// Упрощённо:
const targetMap = new WeakMap();  // объект → Map ключей → Set эффектов
let activeEffect = null;

function track(obj, key) {
    if (!activeEffect) return;
    let depsMap = targetMap.get(obj);
    if (!depsMap) targetMap.set(obj, depsMap = new Map());
    let dep = depsMap.get(key);
    if (!dep) depsMap.set(key, dep = new Set());
    dep.add(activeEffect);
}

function trigger(obj, key) {
    const dep = targetMap.get(obj)?.get(key);
    dep?.forEach(effect => effect());
}

// effect — то что вызовется при изменении. Компонент, computed, watchEffect — всё это эффекты.
function effect(fn) {
    activeEffect = fn;
    fn();
    activeEffect = null;
}
Vue 2 использовал Object.defineProperty — работало только на существующих свойствах. Добавить новое поле было нельзя, приходилось использовать Vue.set(obj, key, value). Proxy решил эту проблему полностью — он видит любые операции с объектом.

Ограничения Proxy

  • Не работает на примитивах — Proxy оборачивает только объекты. Поэтому для строк/чисел есть ref() с .value
  • Не работает в старых браузерах — Proxy требует ES6+. Vue 3 не поддерживает IE11 (в Vue 2 была поддержка через defineProperty)
  • Не отследит замену корня — state = {} потеряет реактивность, всегда мутировать поля
Под капотом: Virtual DOM

Vue не обновляет DOM напрямую при каждом изменении данных. Вместо этого:

  1. Шаблон компилируется в render функцию, которая возвращает Virtual DOM tree — обычные JS-объекты, описывающие DOM
  2. При изменении реактивных данных Vue вызывает render → новое VDOM дерево
  3. Алгоритм diff сравнивает старое и новое дерево, находит минимальный набор изменений
  4. Vue применяет только эти изменения к реальному DOM (patch)

Пример трансформации шаблона

<template>
    <div class="container">
        <h1>{{ title }}</h1>
        <p>{{ count }}</p>
    </div>
</template>

Компилируется в примерно такую render-функцию:

function render() {
    return h('div', { class: 'container' }, [
        h('h1', this.title),
        h('p', this.count),
    ]);
}
// h() — сокращение от hyperscript. Создаёт VNode — объект вида
// { type: 'div', props: {...}, children: [...] }
Vue 3 умеет optimize compile: помечает статические части (то что не меняется) и пропускает их при diff. Это делает Vue 3 в разы быстрее Vue 2.
Blade + Vue — как совместить

Проблема: {{ }} есть и в Blade и в Vue

Blade использует {{ }} для интерполяции. Vue тоже. При компиляции blade сначала подставляет свои значения — Vue-выражения ломаются.

Решение 1 — @{{ }} экранирование

<div id="app">
    <!-- @{{ }} говорит Blade: НЕ трогай, отдай Vue как есть -->
    <p>@{{ message }}</p>
</div>

Решение 2 — @verbatim для больших блоков

@verbatim
<div id="app">
    <h1>{{ title }}</h1>
    <p v-for="user in users">{{ user.name }}</p>
</div>
@endverbatim

Передача данных из Blade в Vue

Способ 1 — props компонента

<div id="app">
    <user-card
        :user='@json($user)'
        :token="'{{ csrf_token() }}'"
    ></user-card>
</div>

Способ 2 — глобальная window переменная

<script>
    window.APP_CONFIG = @json([
        'user' => auth()->user(),
        'locale' => app()->getLocale(),
        'csrfToken' => csrf_token(),
    ]);
</script>

@vite(['resources/js/app.js'])

В Vue-коде: window.APP_CONFIG.user

CSRF в Vue + Laravel

Laravel требует CSRF-токен для всех POST/PUT/DELETE. Без токена — 419 Page Expired.

Способ 1 — Sanctum + SPA (рекомендуется)

Laravel Sanctum поддерживает cookie-based auth для SPA. Правильная последовательность:

  1. Из Vue делаешь GET /sanctum/csrf-cookie — Laravel устанавливает XSRF-TOKEN в cookie
  2. axios автоматически читает эту cookie и добавляет header X-XSRF-TOKEN на всех запросах
  3. Laravel сверяет header с cookie
// api/http.js
import axios from 'axios';

const http = axios.create({
    baseURL: 'http://localhost:8000',
    withCredentials: true,     //  обязательно! cookies с запросами
    withXSRFToken: true,        // axios 1.6+: авто-добавление X-XSRF-TOKEN
});

// Перед login — получить csrf-cookie
async function login(email, password) {
    await http.get('/sanctum/csrf-cookie');
    return http.post('/login', { email, password });
}

Способ 2 — Blade + meta tag (для встроенных Vue)

<head>
    <meta name="csrf-token" content="{{ csrf_token() }}">
</head>

// В Vue-коде
const csrf = document.querySelector('meta[name="csrf-token"]').content;
axios.defaults.headers.common['X-CSRF-TOKEN'] = csrf;
Полный поток: форма Vue → Laravel API

Frontend — Vue компонент

<template>
    <form @submit.prevent="submit">
        <input v-model="form.email" type="email" placeholder="Email">
        <span v-if="errors.email" class="error">{{ errors.email[0] }}</span>

        <input v-model="form.password" type="password">
        <span v-if="errors.password" class="error">{{ errors.password[0] }}</span>

        <button :disabled="loading">{{ loading ? '...' : 'Login' }}</button>
    </form>
</template>

<script setup>
import { reactive, ref } from 'vue';
import { useRouter } from 'vue-router';
import http from '@/api/http';

const router = useRouter();
const form = reactive({ email: '', password: '' });
const errors = ref({});
const loading = ref(false);

async function submit() {
    errors.value = {};
    loading.value = true;

    try {
        await http.get('/sanctum/csrf-cookie');
        const { data } = await http.post('/login', form);
        router.push({ name: 'dashboard' });
    } catch (e) {
        if (e.response?.status === 422) {
            errors.value = e.response.data.errors;   // Laravel validation errors
        } else {
            alert('Ошибка сервера');
        }
    } finally {
        loading.value = false;
    }
}
</script>

Backend — Laravel controller

public function login(Request $request): JsonResponse
{
    $validated = $request->validate([
        'email'    => 'required|email',
        'password' => 'required|min:8',
    ]);

    if (! Auth::attempt($validated)) {
        throw ValidationException::withMessages([
            'email' => 'Неверный email или пароль',
        ]);
    }

    $request->session()->regenerate();

    return response()->json([
        'user' => Auth::user(),
    ]);
}
Ключевое: Laravel возвращает validation errors со статусом 422. Vue ловит через catch, читает e.response.data.errors — это объект { email: ['...'], password: ['...'] }, где каждое поле = массив ошибок.
Vue vs React vs jQuery — сравнение
Vue 3React 18+jQuery
Тип Прогрессивный фреймворк Библиотека для UI Библиотека для DOM/AJAX
Реактивность Автоматическая (Proxy) Ручная через setState/useState Ручная — ты сам меняешь DOM
Шаблоны HTML-шаблоны + директивы JSX (JS + HTML в коде) Строки HTML или DOM manipulation
Порог входа Низкий — HTML/CSS почти как обычно Средний — надо знать JSX + hooks Очень низкий — вставил script и работаешь
SSR Nuxt.js Next.js —
State management Pinia (Vuex устарел) Redux / Zustand / Context API Обычные переменные / localStorage
Роутинг Vue Router React Router Ручной hashchange или полная перезагрузка
Bundle size ~34 KB ~45 KB (React + ReactDOM) ~30 KB (jQuery slim)
Экосистема Меньше, но централизованно (Router/Pinia официальные) Огромная, много альтернатив на каждую задачу Огромная legacy — плагины на всё
TypeScript Полная нативная поддержка Полная нативная Плохо

Когда что выбирать

  • Vue — новые проекты, команда с backend-опытом, быстрый старт SPA
  • React — большие enterprise-проекты, много готовых библиотек, React Native
  • jQuery — legacy, разовые виджеты, WordPress-плагины
Options API vs Composition API
Options APIComposition API
Синтаксис export default { data(), methods, computed } <script setup> с ref/reactive/computed
Организация кода По типу (data / methods / watch) По фиче (весь код одной фичи рядом)
this Есть, критично — this.count++ Нет — просто count.value++
TypeScript Тяжелее типизировать Отличная типизация из коробки
Переиспользование логики Через mixins (проблемные) Через composables (чистые функции)
Порог входа Ниже для начинающих Требует понимания реактивности
Bundle Больше — включает всё что описано в опциях Меньше — tree-shakable, импортируешь только нужное
Когда использовать Простые компоненты, легаси Vue 2 → 3, обучение Всё новое, крупные проекты, TypeScript
Рекомендация Vue team: Composition API + <script setup> для новых проектов. Options остаётся для совместимости и обучения. Смешивать в одном проекте — можно, но лучше выбрать один стиль.
Частые вопросы

15 базовых вопросов по Vue 3

  1. Vue 2 vs Vue 3 главное отличие → Vue 3 использует Proxy вместо Object.defineProperty — реактивность работает для любых новых полей, не нужен Vue.set. Плюс Composition API.
  2. ref vs reactive → ref универсально (примитивы + объекты, доступ через .value). reactive только для объектов, без .value. По умолчанию — ref.
  3. Почему нельзя деструктуризировать reactive → потеряется реактивность, потому что деструктуризация создаёт обычные переменные. Решение — toRefs().
  4. computed vs method → computed кешируется, пересчитывается только при изменении зависимостей. Method вызывается каждый раз при рендере.
  5. watch vs watchEffect → watch явно указывает за чем следить. watchEffect автоматически определяет по использованию внутри callback.
  6. v-if vs v-show → v-if удаляет/вставляет DOM (дорого при toggle, дёшево если условие редко); v-show меняет CSS display (дёшево при toggle).
  7. Зачем :key в v-for → чтобы Vue правильно отслеживал элементы при изменениях списка (вставка/удаление/reorder). Без key будут баги. Не использовать index как key.
  8. v-model — что делает под капотом → эквивалент :value="x" @input="x = $event.target.value". Двусторонняя привязка.
  9. Как передать данные от ребёнка к родителю → через emit. Props идут только вниз, события — только вверх (one-way data flow).
  10. Composition API — зачем нужен → лучше типизация TS, переиспользование логики через composables, группировка кода по фичам, меньше bundle.
  11. onMounted vs mounted → одно и то же, разные API. onMounted — Composition, mounted — Options.
  12. Как работает реактивность → Proxy перехватывает get/set свойств. При get — регистрирует зависимость. При set — уведомляет всех кто зависит.
  13. Что такое Virtual DOM → JS-объекты описывающие DOM. Vue делает diff между старым и новым, применяет минимум изменений к реальному DOM.
  14. Pinia vs Vuex → Pinia — новый официальный. Проще API, лучше TS-поддержка, меньше boilerplate. Vuex устарел, только для старых проектов.
  15. Как отправить CSRF в Laravel → axios с withCredentials: true + вызвать /sanctum/csrf-cookie перед POST. Или meta-tag csrf-token + header X-CSRF-TOKEN.

Типичные ошибки новичков

  • Забыл .value на ref в JS-коде — данные не меняются
  • Деструктуризация reactive — теряется реактивность
  • Мутация props в дочернем компоненте — warning в консоли
  • Забыл :key в v-for — глюки при обновлении списка
  • Забыл onUnmounted для очистки timer/listener — утечка памяти
  • v-html с user-input — XSS
  • v-if + v-for на одном элементе — Vue 3 бросит ошибку (используй computed или template с v-if внутри)
  • Arrow function в methods (Options API) — теряется this
  • reactive со строкой/числом — не работает, только объекты
  • Забыл storeToRefs при деструктуризации Pinia state — теряется реактивность