Props, события и v-model

Данные текут вниз через props, события всплывают вверх через emit — это однонаправленный поток, на котором держится предсказуемость Vue.
Суть: родитель передаёт данные дочернему компоненту через props (объявляются defineProps). Ребёнок сообщает родителю о событиях через emit (объявляются defineEmits). v-model связывает значение в обе стороны как синтаксический сахар над props и событием.

Компоненты — это кирпичики, и им надо как-то общаться. Vue (а значит и Nuxt) строит общение на строгом принципе: данные идут вниз, события — вверх. Родитель передаёт ребёнку значения через props. Ребёнок не меняет их напрямую, а «кричит» родителю через событие, что хочет изменения. Эта однонаправленность делает поток данных предсказуемым.

Props объявляются через defineProps. В дочернем компоненте:

<script setup>
const props = defineProps({
  title: String,
  price: Number,
})
</script>

<template>
  <h2>{{ title }} — {{ price }} ₽</h2>
</template>

Родитель передаёт значения как атрибуты: <ProductCard title="Книга" :price="500" />. Обратите внимание на двоеточие перед price: оно означает «это выражение JS (число)», а не строка.

Чтобы ребёнок сообщил о действии, он объявляет события через defineEmits и вызывает emit:

<script setup>
const emit = defineEmits(["add-to-cart"])
function buy() {
  emit("add-to-cart", props.title)   // сигнал наверх
}
</script>

Родитель ловит событие: <ProductCard @add-to-cart="onAdd" />. А v-model — это удобный сахар, объединяющий props и событие update:modelValue в двустороннюю связь, что особенно удобно для полей ввода.

   Поток данных между компонентами

   Родитель  --props (вниз)-->   Ребёнок
   Родитель  <--emit (вверх)--   Ребёнок

   v-model = props (modelValue) + emit (update:modelValue)

Стоит проговорить и обратную сторону авто-импортов — отладку. Когда импорт не виден в коде, новичку бывает трудно понять, откуда взялась функция и где её определение. Здесь выручают типы: Nuxt генерирует декларации для всех авто-импортов, и редактор с поддержкой TypeScript подскажет источник по переходу к определению. Если же имя не разрешается, чаще всего дело в том, что файл лежит не в сканируемой папке или назван не по соглашению. Поэтому держите composables строго в composables/, а компоненты — в components/: предсказуемое расположение и есть цена за магию авто-импорта.

Как работает под капотом

Props реактивны: когда родитель меняет переданное значение, Vue обновляет дочерний компонент. Но ребёнок не должен мутировать props напрямую — это нарушит однонаправленность и вызовет предупреждение. Вместо этого он эмитит событие, родитель меняет своё состояние, и обновление приходит обратно через props. v-model автоматизирует именно эту пару «значение + событие обновления».

Смоделируем поток props/emit без Vue — на чистых функциях и колбэке:

// Родитель держит состояние, ребёнок просит изменения через emit.
function createChild(props, emit) {
  return {
    render: () => "[" + props.title + ": " + props.count + "]",
    buy:    () => emit("add", props.title),   // сигнал наверх
  };
}

// Родитель
let cart = [];
const props = { title: "Книга", count: 3 };
const child = createChild(props, (event, payload) => {
  if (event === "add") cart.push(payload);   // реагируем на событие
});

console.log("Ребёнок рисует:", child.render());
child.buy();   // ребёнок эмитит "add"
child.buy();
console.log("Корзина родителя:", cart);

Попробуй сам ▶ — ребёнок не трогает корзину сам, он лишь эмитит событие, а решение принимает родитель. Это и есть «вниз props, вверх события».

Частые ошибки

  • Мутировать props внутри ребёнка. Vue это запрещает. Эмитьте событие и меняйте источник у родителя.
  • Забыть двоеточие. :price="500" — число; price="500" — строка "500".
  • Передавать всё через события, минуя props. Для входных данных props проще и нагляднее.

Best practices

  • Держите props «только для чтения» — это контракт, а не изменяемое состояние ребёнка.
  • Именуйте события глаголами действия: add-to-cart, delete, submit.
  • Для полей ввода используйте v-model вместо ручной пары props+emit.

Итог: props несут данные вниз, события — вверх, а v-model аккуратно связывает их в двустороннюю привязку. Этот контракт делает дерево компонентов предсказуемым. Дальше — куда выносить общую логику: composables.

Проверьте себя
1. Каков принцип потока данных между компонентами Vue/Nuxt?
AДанные и события идут только вверх
BДанные идут вниз через props, а события всплывают вверх через emit
CРебёнок напрямую меняет состояние родителя
DКомпоненты обмениваются только через глобальные переменные
2. Что такое v-model по своей сути?
AОтдельный язык шаблонов
BСинтаксический сахар над props и событием update:modelValue для двусторонней связи
CСпособ обращения к серверу
DГлобальное хранилище состояния