Общо състояние
Накратко: Общото състояние във Vue се дава с provide/inject или с хранилище в Pinia, а компонентите го четат направо, без props през всички нива.
Проблем
Броячът пак казва 0
Кошницата е повдигната в App, както в урока за общото състояние нагоре. Оттам тя слиза като props: към Header и от него към CartBadge, към Catalog, от него към ProductList и от него към всяка ProductCard. Някой пренаписа Header и броячът спря.
Конзола
Добави хляб, мляко и пак хляб. Картите броят, а горе пише „Кошница (0)“. App подава count на Header, но новият Header не го подава на CartBadge. Header не ползва кошницата. Той трябваше само да я предаде нататък и точно това е забравил.
Идеята
Доставчик горе, потребители долу
Подаването през компоненти, които не ползват данните, се казва prop drilling (прокарване на props). При две нива е нормално. При пет всеки компонент по пътя зависи от кошницата, а един пропуснат prop къса веригата.
Двете библиотеки имат начин да дадат данни на далечни потомци без компонентите по средата:
- Доставчикът е компонентът, който притежава данните. Той казва: „всичко под мен може да чете това“.
- Потребителят е компонентът, който ги ползва. Той ги взима направо, без значение колко нива са между тях.
Във Vue това са provide и inject:
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));provide('cart', { items, count, add });</script>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script>provide('cart', …) дава стойност под ключ на всички потомци на компонента. inject('cart') я взима от най-близкия предшественик, който я е дал. Подават се ref-овете (items, count), не стойностите им, затова потребителите остават реактивни.
Данните пак имат един собственик и промените пак минават през него: add е функцията на доставчика. Сменен е само пътят.
Виж го
Виж двата пътя
Отначало кошницата слиза ниво по ниво и веригата се къса по средата. После App става доставчик и двамата потребители четат направо.
<script setup>const items = ref([]);const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0));function add(product) { /* … */ }provide('cart', { items, count, add });</script><template> <Header /> <Catalog /></template>
<!-- CartBadge.vue --><script setup>const { count } = inject('cart');</script><template> <a href="/cart">Кошница ({{ count }})</a></template>
<!-- ProductCard.vue --><script setup>const { add } = inject('cart');</script><template> <button @click="add(product)">Добави</button></template>Стъпка по стъпка, с думи
- Кошницата живее в App. Трябва на два компонента далеч надолу: CartBadge в горната част и ProductCard в каталога.
- Само с props кошницата слиза ниво по ниво. Header, Catalog и ProductList не я ползват. Те само я подават.
- Някой пренаписва Header и забравя count. Веригата се къса по средата и броячът показва 0. Това е прокарването на props.
- Вместо това: App вика provide('cart', …). Всеки потомък на App може да чете кошницата.
- CartBadge и ProductCard я четат с inject('cart'). Компонентите по средата не знаят нищо за кошницата.
- Клик на „Добави“ вика add(product). Функцията е дошла от доставчика, затова промяната става в App.
- Кошницата в App се сменя и всеки компонент, който я чете, се рисува наново: броячът казва 1, картата казва 1.
- Данните пак имат един собственик, App. Сменен е само пътят: не през всяко ниво, а направо от доставчика.
Компонентите по средата вече не знаят нищо за кошницата. Новият Header може да бъде пренаписан колкото искаш.
Пробвай
Махни прокарването
Кошницата слиза като props през Header и Catalog, а Header изпуска count. Дай кошницата отгоре. Header, Catalog и ProductList не получават нищо за кошницата, а CartBadge и ProductCard я четат направо. Проверката добавя хляб, мляко и хляб и гледа картите и брояча.
Конзола
Решение
App.vue<script setup>
import { ref, computed, provide } from 'vue';
import Header from './Header.vue';
import Catalog from './Catalog.vue';
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 });
}
provide('cart', { items, count, add });
</script>
<template>
<Header />
<main>
<Catalog />
</main>
</template>
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">Магазинът</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { inject } from 'vue';
const { count } = inject('cart');
</script>
<template>
<a href="/cart">Кошница ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Продукти</h1>
<ProductList />
</section>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
];
</script>
<template>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed, inject } from 'vue';
import { money } from './money.js';
const props = defineProps(['product']);
const { items, add } = inject('cart');
const qty = computed(() => items.value.find((i) => i.id === props.product.id)?.qty ?? 0);
</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="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; }
В App.vue извикай provide('cart', { items, count, add }) и махни props и @add от Header и Catalog. CartBadge чете const { count } = inject('cart'). ProductCard чете items и add, а броя смята с computed. Catalog и ProductList вече нямат defineProps и defineEmits за кошницата.
Проверката хваща provide('cart', { count: count.value, … }): подадено е числото в момента, а не ref-ът, и броячът остава 0. Хваща и qty без computed в ProductCard: setup върви веднъж, затова числото не се сменя.
Проверката вижда само поведението. Ако подадеш count през Header още веднъж, тя ще мине. Целта тук е друга: средата да не знае за кошницата.
Идеята
Хранилище за кошницата
Кошницата расте: добави, махни, смени броя, изпразни. Ако всяко действие е функция в App, App се пълни с логика, която не е за него. Затова общото състояние се изнася в отделен файл, хранилище (store): данните и всичко, което ги сменя, на едно място.
За хранилища Vue ползва Pinia, официалната библиотека за общо състояние. Хранилището се прави с defineStore и се пише като <script setup>: ref е състоянието, computed е изчислено от него, функциите са действията.
export const useCartStore = defineStore('cart', () => { const items = ref([]); const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0)); function add(product) { /* … */ } return { items, count, add };});Pinia се включва веднъж, в main.js: createApp(App).use(createPinia()).mount('#app'). Всеки компонент вика useCartStore() и получава едно и също хранилище. Не трябва provide в App.
<script setup>import { storeToRefs } from 'pinia';const cart = useCartStore();const { count } = storeToRefs(cart);</script>Хранилището е reactive обект: в шаблона пишеш cart.count, без .value. Ако искаш да го разглобиш на променливи, ползвай storeToRefs(cart). Той дава ref-ове, които остават свързани с хранилището. Действията (cart.add) може да се вземат направо.
Конзола
cart.js: там е цялата логика на кошницата. App.vue вече няма състояние, а main.js включва Pinia.Пробвай
Намери грешката
Избери реда с грешката.
const { count } = useCartStore() взима числото в момента и го откъсва от хранилището. Трябва const { count } = storeToRefs(useCartStore()) или cart.count в шаблона.Идеята
Кога общо и кога не
Общото състояние е удобно и затова изкушава всичко да отиде там. Но всяко нещо в него може да се чете и сменя отвсякъде, а това прави грешките по-трудни за намиране. Правилото от урока за повдигането важи: толкова високо, колкото трябва, и не по-високо.
- Общо: това, което трябва на далечни части на дървото и живее, докато потребителят е на сайта. Кошницата, влезлият потребител, езикът, темата.
- В компонента: полетата на формуляра, дали менюто е отворено, кой раздел е избран, текстът в търсачката. Те трябват на един компонент (или на родителя му) и изчезват с него.
- Данните от сървъра не са общо състояние. Списъкът с продукти и поръчките имат собственик и той е сървърът. В хранилището те са копие, което трябва да се опреснява. Компонентът, който ги показва, ги зарежда, както в урока за данните от API.
Общото състояние не сменя посоката: данните тръгват от един собственик, а промените минават през неговите функции.
Пробвай
Попълни
CartBadge взима count от хранилището така, че да остане свързан с него?<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = (useCartStore());
</script>storeToRefs: той дава ref за всяко състояние и изчислено поле на хранилището. Разглобен ref остава свързан, а разглобено число не.По-точно, за любопитните
Хранилище с опции. defineStore приема и обект: state: () => ({ items: [] }), getters и actions. Това е същото, записано като Options API. Pinia поддържа и двата вида.
provide/inject или Pinia. provide е вграден и е добър за неща, които трябват на едно поддърво (настройките на една таблица, формуляр с вложени полета). Pinia е за състоянието на цялото приложение: видимо е в Vue DevTools, тества се отделно и не зависи от това кой компонент е горе.
Ключ-символ. Вместо низа 'cart' при provide често се ползва Symbol(), изнесен от общ файл. Така два различни доставчика не могат случайно да имат един и същ ключ.
inject със стойност по подразбиране. inject('cart', null) не пише предупреждение, ако никой не е дал ключа, а връща null.
Хранилището живее, докато страницата е отворена. При презареждане се губи. Кошница, която трябва да оцелее, се записва в localStorage или на сървъра.
Проверките виждат поведението. Задачите тук не могат да видят дали кошницата идва от props, от контекст или от хранилище. Те проверяват, че броячът и картите показват едно и също.
Упражнение
Страницата на кошницата
В главната част има CartSummary: заглавие „Кошницата“, редове „Бял хляб × 2“ с бутон „Махни“, „Общо: 4,00 €“ и „Кошницата е празна“, когато е празна. Сега тя пази своя кошница, която никой не пълни. Добави в хранилището махане и обща сума и направи CartSummary да чете кошницата оттам. Проверката добавя три продукта, маха ги и гледа брояча, картите и сумата.
Конзола
Решение
main.jsimport { createApp } from 'vue';
import { createPinia } from 'pinia';
import App from './App.vue';
createApp(App).use(createPinia()).mount('#app');
App.vue<script setup>
import Header from './Header.vue';
import Catalog from './Catalog.vue';
import CartSummary from './CartSummary.vue';
</script>
<template>
<Header />
<main>
<Catalog />
<CartSummary />
</main>
</template>
cart.jsimport { defineStore } from 'pinia';
import { ref, computed } from 'vue';
export const useCartStore = defineStore('cart', () => {
const items = ref([]);
const count = computed(() => items.value.reduce((sum, i) => sum + i.qty, 0));
const total = computed(() => items.value.reduce((sum, i) => sum + i.price * 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(id) {
items.value = items.value.filter((i) => i.id !== id);
}
return { items, count, total, add, remove };
});
Header.vue<script setup>
import CartBadge from './CartBadge.vue';
</script>
<template>
<header>
<a href="/">Магазинът</a>
<CartBadge />
</header>
</template>
CartBadge.vue<script setup>
import { storeToRefs } from 'pinia';
import { useCartStore } from './cart.js';
const { count } = storeToRefs(useCartStore());
</script>
<template>
<a href="/cart">Кошница ({{ count }})</a>
</template>
Catalog.vue<script setup>
import ProductList from './ProductList.vue';
</script>
<template>
<section>
<h1>Продукти</h1>
<ProductList />
</section>
</template>
ProductList.vue<script setup>
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
];
</script>
<template>
<ul>
<ProductCard v-for="p in PRODUCTS" :key="p.id" :product="p" />
</ul>
</template>
ProductCard.vue<script setup>
import { computed } from 'vue';
import { useCartStore } from './cart.js';
import { money } from './money.js';
const props = defineProps(['product']);
const cart = useCartStore();
const qty = computed(() => cart.items.find((i) => i.id === props.product.id)?.qty ?? 0);
</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="cart.add(product)">
Добави
</button>
</li>
</template>
CartSummary.vue<script setup>
import { useCartStore } from './cart.js';
import { money } from './money.js';
const cart = useCartStore();
</script>
<template>
<section aria-labelledby="cart-title">
<h2 id="cart-title">Кошницата</h2>
<p v-if="cart.items.length === 0">Кошницата е празна</p>
<ul>
<li v-for="item in cart.items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Махни ${item.name}`" @click="cart.remove(item.id)">
Махни
</button>
</li>
</ul>
<p>Общо: {{ money(cart.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; }
В cart.js добави total с computed и действие remove(id), което сменя items.value с филтриран масив, и ги върни от хранилището. CartSummary вика const cart = useCartStore() и в шаблона ползва cart.items, cart.total и cart.remove(item.id).
Проверката хваща const { items, total, remove } = useCartStore(): редовете се виждат, защото масивът е същият, но total е числото от създаването и сумата остава 0.
Проверката хваща и копие на кошницата в CartSummary: то е празно при създаването и не следва хранилището.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Изчислено състояние · Вдигане на състоянието · Данни от API в компонент · Формуляри в компонент
- Продължава в: Рутиране · От пясъчника към проект · Витрината на магазина
- Задачи по темата: Ефекти, данни и формуляри