Как делать11 октября 2026 г.6 мин чтения
Frontend-разработкаШаг 14 из 18

Vue 3 с нуля: компоненты и реактивность

Коротко

Vue 3 — JavaScript-фреймворк, в котором интерфейс описывается HTML-шаблоном, а изменения состояния автоматически попадают в DOM. Новое приложение создаётся командой npm create vue@latest, компоненты пишутся в файлах .vue с блоком <script setup>, состояние объявляется через ref, производные значения — через computed. Данные идут в дочерний компонент через props, обратно — через события emit.

Пошаговый урокУровень: НачальныйVueПрактика: ~45–90 мин

После материала вы сможете

  • создать проект на 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 г.

Содержание
  1. 01Vue и его место рядом с React
  2. 02Создание проекта
  3. 03Однофайловый компонент: template, script setup, style
  4. 04Реактивность: ref, reactive и computed
  5. 05Директивы: v-if, v-for, v-model, v-on
  6. 06Props и emits: данные вниз, события вверх
  7. 07Практика: список задач на Vue

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) пока не нужно.

bash
npm create vue@latest vue-tasks
cd vue-tasks
npm install
npm run dev
Интерактивное создание проекта и запуск dev-сервера

Те же ответы можно передать флагами — удобно, если нужен пустой проект без примеров:

bash
npm create vue@latest vue-tasks -- --ts --vitest --bare

В проекте сразу есть два полезных скрипта. Тесты запускает Vitest — документация Vue рекомендует его для проектов на Vite. Сборка сначала проверяет типы через vue-tsc — это tsc, который понимает файлы .vue, — и только потом собирает приложение.

bash
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.

src/components/CounterButton.vue
<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
Только когда изменились зависимости
Обычная функция
При каждом вызове, в т. ч. при каждой перерисовке

Пример

ref
tasks, newTitle
computed
visibleTasks, 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 с одним аргументом — числом».

src/types.ts
export type Task = { id: number; title: string; done: boolean }
export type Filter = 'all' | 'active' | 'done'
src/components/TaskItem.vue
<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>
Задача: показывает данные из props и только сообщает родителю, что нужно изменить

Данные в Vue текут в одну сторону: родитель → ребёнок. Props доступны только для чтения — присвоить им новое значение не даст сама проверка типов:

terminal
$ npx vue-tsc --build
src/components/MutateProp.vue(7,9): error TS2540: Cannot assign to 'task' because it is a read-only property.
Реальный вывод vue-tsc для компонента с props.task = { … }

Практика: список задач на Vue

Соберём всё вместе. App.vue владеет состоянием: массив задач, текст нового поля и фильтр. Список фильтруется через computed, каждая задача — компонент TaskItem, а родитель обрабатывает его события.

src/App.vue
<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.

src/__tests__/App.spec.ts
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)
  })
})
terminal
$ 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)
Реальный вывод Vitest (время опущено); 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.