Vue 3 — JavaScript-фреймворк, в котором интерфейс описывается HTML-шаблоном, а изменения состояния автоматически попадают в DOM. Новое приложение создаётся командой npm create vue@latest, компоненты пишутся в файлах .vue с блоком <script setup>, состояние объявляется через ref, производные значения — через computed. Данные идут в дочерний компонент через props, обратно — через события emit.
После материала вы сможете
- создать проект на Vue 3 с TypeScript и Vitest через
create-vueи собрать его - написать однофайловый компонент с
<script setup>,refиcomputedи объяснить, когда нужен.value - вывести список через
v-forсkey, связать поле черезv-modelи обработать отправку формы через@submit.prevent - передать данные в дочерний компонент через props и получить изменения обратно через
emit, не меняя props
Что знать заранее
Проверено на
Vue 3.5create-vue 3.24TypeScript 6.0 + vue-tsc 3.3Vite 8.3Vitest 4.1 + @vue/test-utils 2.5Node.js 24 LTS
Сверено с документацией: 11 октября 2026 г.
Содержание
Vue и его место рядом с React
Vue — фреймворк для пользовательских интерфейсов, построенный поверх обычных HTML, CSS и JavaScript. Документация выделяет две его основы. Декларативный рендеринг: вы описываете в шаблоне, как интерфейс должен выглядеть при данном состоянии, а не какие узлы DOM менять. Реактивность: Vue сам отслеживает изменения состояния и обновляет нужные части страницы. В уроке про DOM и события мы делали список задач вручную — искали элементы, создавали узлы, вешали обработчики. Здесь тот же список, но эту работу берёт на себя Vue.
Vue называют прогрессивным фреймворком: его можно подключить к готовой странице одним скриптом, а можно собрать на нём полноценное одностраничное приложение. Компоненты пишут в двух стилях — Options API (объект с data, methods) и Composition API (импортируемые функции ref, computed). Для полноценных приложений документация рекомендует Composition API вместе с однофайловыми компонентами — этот вариант и разбираем.
Разметка
- Vue 3
- HTML-шаблон с директивами (
v-if,v-for) - React
- JSX — разметка внутри JavaScript
Состояние
- Vue 3
ref(), изменение через.value- React
useState(), изменение через сеттер
Обновление
- Vue 3
- Vue отслеживает, какие данные прочитал шаблон
- React
- Компонент заново вызывается при изменении состояния
Поле формы
- Vue 3
v-model— двусторонняя связь- React
value+onChange
Ребёнок → родитель
- Vue 3
- Событие через
emit - React
- Функция-колбэк в props
Создание проекта
Официальный способ начать — утилита create-vue, которая собирает проект на Vite. Документация требует Node.js версии ^22.18.0 или >=24.12.0. Команда спросит, какие возможности добавить: для урока выберите TypeScript и Vitest, остальное (Vue Router, Pinia, ESLint) пока не нужно.
npm create vue@latest vue-tasks
cd vue-tasks
npm install
npm run devТе же ответы можно передать флагами — удобно, если нужен пустой проект без примеров:
npm create vue@latest vue-tasks -- --ts --vitest --bareВ проекте сразу есть два полезных скрипта. Тесты запускает Vitest — документация Vue рекомендует его для проектов на Vite. Сборка сначала проверяет типы через vue-tsc — это tsc, который понимает файлы .vue, — и только потом собирает приложение.
npm run test:unit -- --run # Vitest: tests once, without watch mode
npm run build # vue-tsc type check + vite buildОднофайловый компонент: template, script setup, style
Однофайловый компонент (Single-File Component, SFC) — файл .vue, в котором лежат логика, разметка и стили одного компонента. Блок <script setup> — это сокращённая форма Composition API: всё, что объявлено на его верхнем уровне (переменные, функции, импорты), сразу доступно в шаблоне. lang="ts" включает TypeScript.
<script setup lang="ts">
import { computed, ref } from 'vue'
const count = ref(0)
const isEven = computed(() => count.value % 2 === 0)
function increment() {
count.value++ // in <script>, a ref is read and written through .value
}
</script>
<template>
<!-- in the template, refs are unwrapped automatically: no .value -->
<button type="button" @click="increment">Clicked {{ count }} times</button>
<p>{{ isEven ? 'Even' : 'Odd' }}</p>
</template><template>— HTML с выражениями в двойных фигурных скобках{{ }}и директивами.@click— короткая записьv-on:click.<script setup lang="ts">— код компонента; экспортировать ничего не нужно.<style scoped>— стили, которые применяются только к этому компоненту (пример — вTaskItem.vueниже).
Реактивность: ref, reactive и computed
Обычная переменная в JavaScript не может сообщить, что её изменили. Поэтому Vue заворачивает значение в объект-ссылку: ref(0) возвращает объект со свойством .value. Чтение .value Vue запоминает как зависимость, запись — как повод обновить всё, что от неё зависит. В <script> к значению обращаются через .value, а в шаблоне ref разворачивается автоматически — поэтому там пишут просто {{ count }}.
ref работает с любыми значениями, включая массивы и объекты: вложенные данные тоже становятся реактивными, поэтому tasks.value.push(...) и task.done = true обновляют страницу. Есть и reactive(), который делает реактивным сам объект, но у него ограничения из документации: только объекты, не примитивы; объект нельзя заменить целиком, не потеряв связь; при деструктуризации свойства теряют реактивность. Поэтому документация рекомендует ref как основной способ объявлять состояние.
computed описывает значение, которое вычисляется из другого состояния. В отличие от обычной функции, вызванной в шаблоне, результат кэшируется и пересчитывается, только когда меняются его реактивные зависимости. Функция внутри computed не должна иметь побочных эффектов — никаких запросов и изменений другого состояния, только вычисление.
Что это
ref- Хранимое состояние
computed- Производное значение
- Обычная функция
- Действие или вычисление
Можно менять
ref- Да, через
.value computed- Нет (по умолчанию только чтение)
- Обычная функция
- —
Пересчёт
ref- —
computed- Только когда изменились зависимости
- Обычная функция
- При каждом вызове, в т. ч. при каждой перерисовке
Пример
reftasks,newTitlecomputedvisibleTasks,remaining- Обычная функция
addTask,toggle
Директивы: v-if, v-for, v-model, v-on
Директивы — специальные атрибуты шаблона с префиксом v-. Они связывают разметку с состоянием:
v-if / v-else
- Что делает
- Добавляет элемент в DOM или убирает его
- Пример
<ul v-if="visibleTasks.length">
v-show
- Что делает
- Скрывает элемент через CSS, оставляя в DOM
- Пример
- Для частых переключений
v-for
- Что делает
- Повторяет элемент для каждого элемента массива
- Пример
v-for="task in visibleTasks" :key="task.id"
v-bind (:)
- Что делает
- Привязывает атрибут или prop к выражению
- Пример
:task="task",:checked="task.done"
v-on (@)
- Что делает
- Подписывается на событие
- Пример
@click="increment",@submit.prevent="addTask"
v-model
- Что делает
- Двусторонняя связь поля формы и состояния
- Пример
v-model.trim="newTitle"
- У
v-forвсегда указывайте:keyс уникальным id: по нему Vue понимает, какой элемент какой, и переиспользует их при перестановке. - Не ставьте
v-ifиv-forна один элемент:v-ifвыполняется раньше и не видит переменную цикла. Документация советует вместо этого отфильтровать список черезcomputed— так сделанvisibleTasks. v-modelигнорирует атрибутыvalue,checkedиselectedв разметке: источник правды — состояние в JavaScript, начальное значение задают там.- Модификатор
.trimобрезает пробелы,.numberпревращает ввод в число,.preventу события вызываетevent.preventDefault()— форма не перезагружает страницу.
Props и emits: данные вниз, события вверх
Компонент задачи получает данные от родителя через props и сообщает о действиях пользователя событиями. Props объявляются макросом defineProps, события — defineEmits; оба доступны в <script setup> без импорта. С TypeScript их удобно описывать типами: defineEmits<{ toggle: [id: number] }>() значит «событие toggle с одним аргументом — числом».
export type Task = { id: number; title: string; done: boolean }
export type Filter = 'all' | 'active' | 'done'<script setup lang="ts">
import type { Task } from '../types'
const props = defineProps<{ task: Task }>()
const emit = defineEmits<{
toggle: [id: number]
remove: [id: number]
}>()
</script>
<template>
<li :class="{ done: props.task.done }">
<label>
<input type="checkbox" :checked="props.task.done" @change="emit('toggle', props.task.id)" />
{{ props.task.title }}
</label>
<button type="button" :aria-label="`Delete ${props.task.title}`" @click="emit('remove', props.task.id)">
×
</button>
</li>
</template>
<style scoped>
.done {
color: #888;
text-decoration: line-through;
}
</style>Данные в Vue текут в одну сторону: родитель → ребёнок. Props доступны только для чтения — присвоить им новое значение не даст сама проверка типов:
$ npx vue-tsc --build
src/components/MutateProp.vue(7,9): error TS2540: Cannot assign to 'task' because it is a read-only property.props.task = { … }Практика: список задач на Vue
Соберём всё вместе. App.vue владеет состоянием: массив задач, текст нового поля и фильтр. Список фильтруется через computed, каждая задача — компонент TaskItem, а родитель обрабатывает его события.
<script setup lang="ts">
import { computed, ref } from 'vue'
import TaskItem from './components/TaskItem.vue'
import type { Filter, Task } from './types'
const tasks = ref<Task[]>([
{ id: 1, title: 'Read the Vue guide', done: true },
{ id: 2, title: 'Build a task list', done: false },
])
const newTitle = ref('')
const filter = ref<Filter>('all')
let nextId = 3
const visibleTasks = computed(() => {
if (filter.value === 'active') return tasks.value.filter((t) => !t.done)
if (filter.value === 'done') return tasks.value.filter((t) => t.done)
return tasks.value
})
const remaining = computed(() => tasks.value.filter((t) => !t.done).length)
function addTask() {
if (!newTitle.value) return
tasks.value.push({ id: nextId++, title: newTitle.value, done: false })
newTitle.value = ''
}
function toggle(id: number) {
const task = tasks.value.find((t) => t.id === id)
if (task) task.done = !task.done
}
function remove(id: number) {
tasks.value = tasks.value.filter((t) => t.id !== id)
}
</script>
<template>
<main>
<h1>Tasks</h1>
<form @submit.prevent="addTask">
<label for="new-task">New task</label>
<input id="new-task" v-model.trim="newTitle" autocomplete="off" />
<button type="submit">Add</button>
</form>
<label for="filter">Show</label>
<select id="filter" v-model="filter">
<option value="all">All</option>
<option value="active">Active</option>
<option value="done">Done</option>
</select>
<ul v-if="visibleTasks.length">
<TaskItem v-for="task in visibleTasks" :key="task.id" :task="task" @toggle="toggle" @remove="remove" />
</ul>
<p v-else>Nothing here yet.</p>
<p>Remaining: {{ remaining }}</p>
</main>
</template>Проверим поведение тестами. @vue/test-utils монтирует компонент, setValue вводит значение в поле, trigger вызывает событие, а emitted() показывает, какие события отправил компонент. Последний тест проверяет главное правило: TaskItem сообщает об изменении, но не меняет объект из props.
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import App from '../App.vue'
import TaskItem from '../components/TaskItem.vue'
describe('App', () => {
it('adds a trimmed task and ignores an empty one', async () => {
const wrapper = mount(App)
await wrapper.get('#new-task').setValue(' Write tests ')
await wrapper.get('form').trigger('submit')
expect(wrapper.findAll('li').map((li) => li.text())).toContain('Write tests ×')
expect((wrapper.get('#new-task').element as HTMLInputElement).value).toBe('')
await wrapper.get('form').trigger('submit')
expect(wrapper.findAll('li')).toHaveLength(3)
})
it('toggles, filters and removes through child events', async () => {
const wrapper = mount(App)
expect(wrapper.text()).toContain('Remaining: 1')
await wrapper.findAll('input[type="checkbox"]')[1]!.setValue(true)
expect(wrapper.text()).toContain('Remaining: 0')
await wrapper.get('#filter').setValue('active')
expect(wrapper.text()).toContain('Nothing here yet.')
await wrapper.get('#filter').setValue('all')
await wrapper.get('button[aria-label="Delete Read the Vue guide"]').trigger('click')
expect(wrapper.findAll('li')).toHaveLength(1)
})
})
describe('TaskItem', () => {
it('emits events with the task id instead of mutating the prop', async () => {
const task = { id: 7, title: 'Call the client', done: false }
const wrapper = mount(TaskItem, { props: { task } })
await wrapper.get('input').setValue(true)
await wrapper.get('button').trigger('click')
expect(wrapper.emitted('toggle')).toEqual([[7]])
expect(wrapper.emitted('remove')).toEqual([[7]])
expect(task.done).toBe(false)
})
})$ npx vitest run --reporter=verbose
✓ src/__tests__/CounterButton.spec.ts > updates the DOM when the ref changes
✓ src/__tests__/App.spec.ts > App > adds a trimmed task and ignores an empty one
✓ src/__tests__/App.spec.ts > App > toggles, filters and removes through child events
✓ src/__tests__/App.spec.ts > TaskItem > emits events with the task id instead of mutating the prop
Test Files 2 passed (2)
Tests 4 passed (4)npm run build с проверкой vue-tsc проходит без ошибокДальше стоит разобраться с тестированием компонентов подробнее и с TypeScript в шаблонах, а для приложения из нескольких страниц — добавить Vue Router, который create-vue предлагает при создании проекта.
Как проверено5 · пройденосверено 11 октября 2026 г.
- запущено, работает
Создание проекта (npm create vue@latest … -- --ts --vitest --bare)
команда с флагами выполнена во временной папке (create-vue 3.24.0, Node.js 24.18, npm 11.16): проект создан; интерактивный вариант не прогонялся — те же вопросы сверены с Quick Start
- запущено, работает
src/components/CounterButton.vue
vue-tsc --build + vite build + vitest (1 тест: DOM обновляется после клика). Vue 3.5.43, vue-tsc 3.3.13, TypeScript 6.0.3, Vite 8.3.4, Vitest 4.1.11 (версия из шаблона create-vue), @vue/test-utils 2.5.1, jsdom 30.1.2
- запущено, работает
src/types.ts, src/components/TaskItem.vue, src/App.vue, src/__tests__/App.spec.ts
vue-tsc --build + npm run build + vitest (3 теста: добавление с .trim и пустая строка, отметка/фильтр/удаление через события ребёнка, emitted() без изменения пропса)
- запущено, работает
Вывод vue-tsc при присваивании props.task
временный компонент src/components/MutateProp.vue → error TS2540, затем удалён; вывод в статье реальный
- запущено, работает
Упражнения (Reset, Clear completed, TaskFilter с defineModel)
решения собраны в копиях компонентов, vue-tsc --build + vitest (2 теста)
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.