Вдигане на състоянието
Накратко: Вдигането на състоянието (lifting state up) мести общото състояние в най-близкия общ родител: данните слизат като props, а промените се качват със събитие (emit).
Проблем
Броячът горе не знае
Магазинът има брояч на кошницата горе и списък с продукти долу. Добави хляб, мляко и още един хляб.
Конзола
Картата на хляба казва „В кошницата: 2“, а горе пише „Кошница (0)“. Кошницата живее в ProductList: там е натиснат бутонът и там е добавено състоянието. CartBadge е друг компонент, брат на ProductList, и има свое число, което никой не сменя. Двата компонента имат две кошници и всеки е прав за своята.
Идеята
Нагоре до общия родител
Компонентът вижда само своето състояние и props, които му подава родителят. Брат не вижда брат си. Затова две места, които показват една кошница, не може всяко да има своя.
Решението е да качиш състоянието в най-близкия общ родител на двамата. Тук това е App. Това се казва повдигане на състоянието (lifting state up):
Appпазиitemsи изчисляваcount.- Данните слизат като props:
countкъмCartBadge,itemsкъмProductList. - Промените се качват като съобщение: бутонът в
ProductCardне сменя кошницата, а казва наApp„добави този продукт“.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));function add(product) { /* сменя items */ }</script>
<template> <header><CartBadge :count="count" /></header> <main><ProductList :products="PRODUCTS" :items="items" @add="add" /></main></template>Детето обявява какви събития изпраща с defineEmits(['add']) и ги изпраща с emit('add', product). Родителят ги слуша с @add="add", както слуша @click на бутон.
Сега CartBadge и ProductList нямат свое състояние за кошницата. Те показват каквото им даде App.
Виж го
Виж как се качва
Отначало кошницата е в ProductList. После отива в App и оттам тече надолу.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0),);function add(product) { plusOne(items.value, product);}</script><template> <CartBadge :count="count" /> <ProductList :items="items" @add="add" /></template>
<!-- ProductList.vue --><ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" :qty="qtyOf(items, p)" @add="(product) => emit('add', product)" />
<!-- ProductCard.vue --><button @click="emit('add', product)">…</button>Стъпка по стъпка, с думи
- CartBadge и ProductList са братя: и двата са деца на App. Отначало кошницата живее в ProductList, а CartBadge има свой брояч.
- Клик на „Добави“ стига до ProductList и списъкът брои 1. CartBadge не вижда това състояние: брат не чете състоянието на брат си. Той остава 0.
- Поправката: кошницата отива в най-близкия общ родител на двамата, App. Броячът вече няма свое копие.
- От App данните текат надолу като props: count към CartBadge, items към ProductList и qty към всяка ProductCard. Двамата братя четат една кошница.
- Детето не притежава кошницата, затова не я сменя. Кликът изпраща събитие add с продукта. ProductList го предава нагоре, а App го слуша с @add и вика add().
- App сменя items и новите props текат надолу: броячът казва 2, картата казва 2. Никой не ги е обновявал на ръка.
- Правилото: състоянието е на едно място, толкова високо, колкото трябва, и не по-високо. Данните слизат като props, промените се качват като събития.
Посоката е една и съща във всяко приложение: данните надолу, съобщенията нагоре. Който притежава данните, само той ги сменя.
Пробвай
Качи кошницата
Кошницата е в ProductList, а CartBadge брои свое число. Премести кошницата в App: CartBadge получава броя като prop, ProductList получава items и съобщава за „Добави“ нагоре. Проверката добавя хляб, мляко и хляб и гледа картите и брояча.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.vue';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
];
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty++;
else items.value.push({ ...product, qty: 1 });
}
</script>
<template>
<header>
<a href="/">Магазинът</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Продукти</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Кошница ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">В кошницата: {{ qty }}</span>
<button type="button" :aria-label="`Добави ${product.name}`" @click="emit('add', product)">
Добави
</button>
</li>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
items, add и count отиват в App.vue. CartBadge получава :count="count". ProductList получава :items="items" и изпраща add нагоре: defineEmits(['add']) и @add="(product) => emit('add', product)" на всяка ProductCard. В ProductList вече няма ref.
Ако App не пише @add="add", събитието тръгва, но никой не го слуша. Нищо не се случва и в конзолата няма грешка. Проверката го хваща по поведението.
Идеята
Детето съобщава, родителят решава
ProductCard не знае как е устроена кошницата. Тя знае само, че някой е натиснал „Добави“ на нейния продукт. Затова съобщава какво се е случило, а не какво да се промени: „добави този продукт“, а не „увеличи qty на ред 2“. App решава какво значи това: нов ред или още една бройка.
Така картата става по-проста и може да стои навсякъде: в каталога, в „Препоръчани“, в търсенето. Тя не притежава данните и не ги пипа.
Събитието е глагол без on: add, remove, change. Детето го обявява в defineEmits(['add']), изпраща го с emit('add', product), а родителят го слуша с @add. Вторият аргумент на emit е това, което получава функцията на родителя.
Пробвай
Попълни
Какво стои на празното място, за да стигне кликът до родителя?
<script setup>
defineProps(['product']);
const emit = defineEmits(['add']);
</script>
<template>
<button @click="('add', product)">Добави</button>
</template>emit: функцията от defineEmits. Тя изпраща събитието add с продукта, а родителят го слуша с @add.Пробвай
Намери грешката
Избери реда с грешката.
App слуша @added, а картата изпраща add. Имената трябва да са еднакви: @add="add".Идеята
Когато дървото стане дълбоко
Колко нагоре? Толкова високо, колкото трябва, и не по-високо. Ако само ProductCard ползва нещо (дали е отворено описанието), то остава в нея. Качваш го, когато втори компонент трябва да го види.
В истинския магазин кошницата трябва на много места: броячът горе, списъкът, страницата на продукта, поръчката. Общият родител е високо, а пътят до тях минава през компоненти, които не ползват кошницата, само я подават надолу. Това се казва prop drilling (прокарване на props). При две нива е нормално. При пет нива всеки компонент по пътя трябва да знае за данни, които не са негови.
За това има отделни инструменти: provide/inject и Pinia. Те дават данните на далечни потомци без компонентите по пътя. Те са в урока за общото състояние от модула „Ефекти, данни и формуляри“. Правилото остава същото: данните имат един собственик, а промените минават през него.
По-точно, за любопитните
Props са само за четене. props.qty = 3 във Vue не сменя нищо и пише в конзолата [Vue warn] Set operation on key "qty" failed: target is readonly. Детето, което иска промяна, съобщава нагоре.
v-model на компонент е същото. <QtyInput v-model="qty" /> е съкратен запис на :modelValue="qty" и @update:modelValue="(v) => qty = v". Данните пак слизат като prop и пак се качват като събитие.
Защо defineEmits. Обявените събития са документация: виждаш какво казва компонентът. И Vue ги сверява: ако картата изпрати emit('added'), а е обявила само add, конзолата казва Component emitted event "added" but it is neither declared in the emits option….
ref(props.items) в детето не копира масива: това е същият реактивен масив на родителя и добавеното се вижда. Но items.value = items.value.filter(…) сменя само кутията на детето. Махането не стига до родителя, и двете кошници се разминават.Упражнение
Кошницата отстрани
До списъка има трета част, CartSummary: заглавие „Кошницата“, редове „Бял хляб × 2“ с бутон „Махни“, „Общо: 4,00 €“ и „Кошницата е празна“, когато е празна. Сега CartSummary пази своя кошница, която никой не пълни. Подай ѝ кошницата от App и направи „Махни“ да маха от нея. Проверката добавя три продукта, маха ги и гледа брояча, картите и сумата.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import CartBadge from './CartBadge.vue';
import ProductList from './ProductList.vue';
import CartSummary from './CartSummary.vue';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
];
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
function add(product) {
const found = items.value.find((i) => i.id === product.id);
if (found) found.qty++;
else items.value.push({ ...product, qty: 1 });
}
function remove(item) {
items.value = items.value.filter((i) => i.id !== item.id);
}
</script>
<template>
<header>
<a href="/">Магазинът</a>
<CartBadge :count="count" />
</header>
<main>
<h1>Продукти</h1>
<ProductList :products="PRODUCTS" :items="items" @add="add" />
<CartSummary :items="items" @remove="remove" />
</main>
</template>
CartBadge.vue<script setup>
defineProps(['count']);
</script>
<template>
<a href="/cart">Кошница ({{ count }})</a>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const props = defineProps(['products', 'items']);
const emit = defineEmits(['add']);
const qtyOf = (id) => props.items.find((i) => i.id === id)?.qty ?? 0;
</script>
<template>
<ul>
<ProductCard
v-for="p in products"
:key="p.id"
:product="p"
:qty="qtyOf(p.id)"
@add="(product) => emit('add', product)"
/>
</ul>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product', 'qty']);
const emit = defineEmits(['add']);
</script>
<template>
<li>
<span>{{ product.name }}, {{ money(product.price) }}</span>
<span v-if="qty > 0">В кошницата: {{ qty }}</span>
<button type="button" :aria-label="`Добави ${product.name}`" @click="emit('add', product)">
Добави
</button>
</li>
</template>
CartSummary.vue<script setup>
import { computed } from 'vue';
import { money } from './money.js';
const props = defineProps(['items']);
const emit = defineEmits(['remove']);
const total = computed(() => props.items.reduce((sum, i) => sum + i.price * i.qty, 0));
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">Кошницата</h2>
<p v-if="items.length === 0">Кошницата е празна</p>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Махни ${item.name}`" @click="emit('remove', item)">
Махни
</button>
</li>
</ul>
<p>Общо: {{ money(total) }}</p>
</section>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; }
header { display: flex; justify-content: space-between; background: #f3efe6; padding: 8px 16px; }
main { padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span:first-child { flex: 1; }
button { font: inherit; }
App.vue получава функция remove(item), която филтрира items. CartSummary получава :items="items", обявява defineEmits(['remove']) и бутонът вика emit('remove', item). App слуша @remove="remove". Сега броячът, картите и кошницата отстрани четат едно състояние, затова махането се вижда и на трите места.
ref(props.items) в CartSummary с махане на място: добавените продукти се виждат, защото масивът е същият, но items.value = …filter(…) сменя само кутията на CartSummary. Броячът горе остава 3.Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Условия: празно, грешка, зарежда · Състояние · Обновяване на обекти и масиви · Изчислено състояние
- Продължава в: Общо състояние · Рутиране · Бързина: кога се рисува наново · Тестове на компоненти
- Задачи по темата: Състояние