Vue.js — база
Базовые конструкции Vue 3: реактивность, компоненты, роутинг, стейт, интеграция с Laravel. Advanced-темы (SSR, Suspense, кастомные рендереры) не входят.
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 — видит любые изменения |
| API | Options API (data, methods, computed) | Options + Composition API (setup, ref, reactive) |
| TypeScript | Кривая поддержка | Полная нативная |
| Скорость | Медленнее | ~2× быстрее, легче |
| Multiple root nodes | Только один корень | Fragments — любое количество |
| Поддержка | End-of-life 2024 | Активная разработка |
Где используется
- 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 — «классический» способ описания компонентов 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, потеряется контекст компонента.
<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, provide | Dependency injection между компонентами |
this. Всё через импорты и .value. Arrow functions работают везде без проблем.
Два основных способа сделать данные реактивными: 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 — что выбрать
| ref | reactive | |
|---|---|---|
| Что можно | Любое: примитивы + объекты | Только объекты (массив тоже — объект) |
| Доступ в 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() — вычисляемые значения
Вычисляется от других реактивных данных. Кешируется — пересчитывается только когда меняются зависимости. Используй когда результат зависит от других данных.
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
| computed | method | |
|---|---|---|
| Кеширование | Да — пересчёт только когда зависимости меняются | Нет — вызывается каждый раз при рендере |
| Синтаксис | 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 ты явно указываешь за чем следить. В watchEffect — Vue автоматически определяет по использованию внутри callback.
Интерполяция {{ }}
<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
Модификаторы событий
| Модификатор | Что делает |
|---|---|
.prevent | event.preventDefault() |
.stop | event.stopPropagation() |
.self | Сработает только если target === элемент |
.once | Сработает 1 раз, потом отвяжется |
.capture | capture-фаза вместо bubbling |
.passive | Для scroll — оптимизация |
.enter, .esc, .tab... | Клавиши для keyup/keydown |
.ctrl, .alt, .shift, .meta | Модификаторы клавиш |
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-if | v-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>
v-html не экранирует. Никогда не подставляй туда user-input без санитизации.
В 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>
Хуки, которые Vue вызывает в определённые моменты жизни компонента: создание, монтирование в DOM, обновление, удаление.
| Composition API | Options API | Когда вызывается |
|---|---|---|
onBeforeMount | beforeMount | Перед добавлением в DOM |
onMounted | mounted | После добавления в DOM (можно работать с DOM, делать API-запросы) |
onBeforeUpdate | beforeUpdate | Перед перерисовкой из-за изменения данных |
onUpdated | updated | После перерисовки |
onBeforeUnmount | beforeUnmount | Перед удалением из DOM (очистка timer, listener, subscription) |
onUnmounted | unmounted | После удаления |
onActivated | activated | Для kept-alive компонентов при активации |
onDeactivated | deactivated | Для kept-alive при деактивации |
onErrorCaptured | errorCaptured | Ошибка в дочернем компоненте |
Типичный сценарий — загрузка данных + очистка
<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 — данные от родителя к ребёнку
// 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>
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>
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({...}), где обычная деструктуризация обрывает реактивность.Установка + конфигурация
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 — официальный стейт-менеджер для 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>
Что такое 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_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
| axios | fetch | |
|---|---|---|
| JSON parsing | Автоматически | Вручную res.json() |
| Ошибки 4xx/5xx | Бросает | Не бросает — надо res.ok |
| Interceptors | Есть (для auth, logging) | Нет — надо оборачивать |
| Отмена запроса | CancelToken | AbortController |
| Timeout | Есть встроенный | Только через AbortController |
| Размер | ~13 KB | Встроен в браузер |
Реактивность 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;
}
Object.defineProperty — работало только на существующих свойствах. Добавить новое поле было нельзя, приходилось использовать Vue.set(obj, key, value). Proxy решил эту проблему полностью — он видит любые операции с объектом.
Ограничения Proxy
- Не работает на примитивах —
Proxyоборачивает только объекты. Поэтому для строк/чисел естьref()с.value - Не работает в старых браузерах — Proxy требует ES6+. Vue 3 не поддерживает IE11 (в Vue 2 была поддержка через defineProperty)
- Не отследит замену корня —
state = {}потеряет реактивность, всегда мутировать поля
Vue не обновляет DOM напрямую при каждом изменении данных. Вместо этого:
- Шаблон компилируется в render функцию, которая возвращает Virtual DOM tree — обычные JS-объекты, описывающие DOM
- При изменении реактивных данных Vue вызывает render → новое VDOM дерево
- Алгоритм diff сравнивает старое и новое дерево, находит минимальный набор изменений
- 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: [...] }
Проблема: {{ }} есть и в 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
Laravel требует CSRF-токен для всех POST/PUT/DELETE. Без токена — 419 Page Expired.
Способ 1 — Sanctum + SPA (рекомендуется)
Laravel Sanctum поддерживает cookie-based auth для SPA. Правильная последовательность:
- Из Vue делаешь
GET /sanctum/csrf-cookie— Laravel устанавливаетXSRF-TOKENв cookie - axios автоматически читает эту cookie и добавляет header
X-XSRF-TOKENна всех запросах - 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;
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(),
]);
}
catch, читает e.response.data.errors — это объект { email: ['...'], password: ['...'] }, где каждое поле = массив ошибок.
| Vue 3 | React 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 | Composition 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 |
<script setup> для новых проектов. Options остаётся для совместимости и обучения. Смешивать в одном проекте — можно, но лучше выбрать один стиль.
15 базовых вопросов по Vue 3
- Vue 2 vs Vue 3 главное отличие → Vue 3 использует
ProxyвместоObject.defineProperty— реактивность работает для любых новых полей, не нуженVue.set. Плюс Composition API. - ref vs reactive →
refуниверсально (примитивы + объекты, доступ через.value).reactiveтолько для объектов, без.value. По умолчанию —ref. - Почему нельзя деструктуризировать reactive → потеряется реактивность, потому что деструктуризация создаёт обычные переменные. Решение —
toRefs(). - computed vs method → computed кешируется, пересчитывается только при изменении зависимостей. Method вызывается каждый раз при рендере.
- watch vs watchEffect → watch явно указывает за чем следить. watchEffect автоматически определяет по использованию внутри callback.
- v-if vs v-show → v-if удаляет/вставляет DOM (дорого при toggle, дёшево если условие редко); v-show меняет CSS display (дёшево при toggle).
- Зачем :key в v-for → чтобы Vue правильно отслеживал элементы при изменениях списка (вставка/удаление/reorder). Без key будут баги. Не использовать index как key.
- v-model — что делает под капотом → эквивалент
:value="x" @input="x = $event.target.value". Двусторонняя привязка. - Как передать данные от ребёнка к родителю → через
emit. Props идут только вниз, события — только вверх (one-way data flow). - Composition API — зачем нужен → лучше типизация TS, переиспользование логики через composables, группировка кода по фичам, меньше bundle.
- onMounted vs mounted → одно и то же, разные API.
onMounted— Composition,mounted— Options. - Как работает реактивность → Proxy перехватывает get/set свойств. При get — регистрирует зависимость. При set — уведомляет всех кто зависит.
- Что такое Virtual DOM → JS-объекты описывающие DOM. Vue делает diff между старым и новым, применяет минимум изменений к реальному DOM.
- Pinia vs Vuex → Pinia — новый официальный. Проще API, лучше TS-поддержка, меньше boilerplate. Vuex устарел, только для старых проектов.
- Как отправить CSRF в Laravel → axios с
withCredentials: true+ вызвать/sanctum/csrf-cookieперед POST. Или meta-tagcsrf-token+ headerX-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 — теряется реактивность