Изчислено състояние
Накратко: Изчисленото състояние е стойност, която се смята от състоянието (брой, сума, филтриран списък), вместо да се пази отделно; computed пази резултата до следващата промяна.
Проблем
Сумата изостава
Кошницата има бял хляб и прясно мляко. Натисни „+“ на хляба: броят и сумата растат. Сега махни млякото.
Конзола
Редът изчезва, броят пада на 3, а сумата остава 5,20 €. Компонентът пази три състояния: items, count и total. Всеки клик трябва да смени и трите, а функцията за махане сменя две. Това е същото разминаване като в урока „Списък от данни“, само че вътре в компонент. Библиотеката рисува вярно това, което ѝ дадеш. Даваш ѝ две истини за една кошница.
Идеята
Изчисли, не пази
Попитай за всяко състояние: може ли да го изчисля от другото състояние или от props? Ако може, то не е състояние. Броят е сборът от количествата в items. Сумата е сборът от цена по количество. И двете се изчисляват.
Такава стойност се казва изчислено състояние (derived state). Пазиш само items, а останалото смяташ от тях всеки път, когато компонентът се рисува.
const items = ref(START);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));<script setup> се пуска веднъж, при създаването на компонента. Затова сметката е във функция, дадена на computed. Vue помни, че тя чете items, и я пуска наново, когато items се сменят. В шаблона пишеш {{ total }}, без .value.
Сега функциите more и remove сменят само items. Няма какво да забравят.
Виж го
Виж изчисленото и копието
Вляво е кошницата и копие на сумата, пазено отделно. Вдясно са трите изчислени стойности.
<script setup>const items = ref(START);const savedTotal = ref(26.9);
const count = computed(() => items.value.reduce((s, i) => s + i.qty, 0),);const total = computed(() => items.value.reduce((s, i) => s + i.price * i.qty, 0),);const free = computed(() => total.value >= 30);
function more(item) { item.qty++; savedTotal.value += item.price;}
function remove(item) { items.value = items.value.filter((i) => i.id !== item.id);}</script>Стъпка по стъпка, с думи
- Вляво е състоянието: кошницата items. Под нея има второ състояние, savedTotal: същата сума, пазена отделно.
- Вдясно са count, total и free: computed стойности. Vue помни, че четат items, и ги смята наново, когато items се сменят: 26,90 €, остават 3,10 € до безплатна доставка.
- more(сирене): items се сменя и трите стрелки светват. Сумата е 34,80 €, доставката е безплатна. more() обновява на ръка и savedTotal, затова копието още е вярно.
- remove(сирене): items се сменя, а count, total и free следват сами: 3,20 €. Ред за savedTotal в remove() никой не е написал.
- Копието казва 34,80 €, а кошницата е 3,20 €. Грешка няма, има невярно число: състоянието и копието му са се разминали.
- Правилото: пази само това, което не може да се изчисли. Всичко, което следва от състоянието, се смята от него, и копие за забравяне няма.
Изчислените стойности следват кошницата при всяка промяна, защото нямат собствена памет. Копието следва само докато някой се сеща да го обнови. Първата функция, която забрави, го прави невярно.
Пробвай
Изчисли броя и сумата
Кошницата пази count и total като отделни състояния и remove() забравя сумата. Махни двете състояния и изчисли броя и сумата от items. Проверката натиска „+“ на хляба, после маха млякото и гледа броя и сумата.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import { money } from './money.js';
const items = ref([
{ id: 1, name: 'Бял хляб', price: 1.2, qty: 2 },
{ id: 5, name: 'Прясно мляко', price: 1.6, qty: 1 },
]);
// Брой и сума: изчислени при всяко рисуване.
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 more(item) {
item.qty++;
}
function remove(item) {
items.value = items.value.filter((i) => i.id !== item.id);
}
</script>
<template>
<main>
<h1>Кошницата</h1>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`Още ${item.name}`" @click="more(item)">+</button>
<button type="button" :aria-label="`Махни ${item.name}`" @click="remove(item)">Махни</button>
</li>
</ul>
<p>Продукти: {{ count }}</p>
<p>Общо: {{ money(total) }}</p>
</main>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
Остава само items. Броят е items.reduce((sum, i) => sum + i.qty, 0), сумата е същото с i.price * i.qty. Във Vue са computed: смятат се наново, когато items се сменят. more и remove вече сменят само кошницата.
Капан: const total = items.value.reduce(…) без computed. <script setup> се пуска веднъж, затова сумата остава 4,00 € завинаги.
Пробвай
Попълни
Броят се изчислява от кошницата. Какво стои на празното място?
const items = ref(START);
const count = (() => items.value.reduce((sum, i) => sum + i.qty, 0));computed: Vue пуска функцията наново, когато items се сменят, и пази резултата между рисуванията.Идеята
Кога да пазиш резултата
computed вече пази резултата. Той се смята, когато го прочетеш за първи път. После, докато данните, които е прочел, не се сменят, връща запомнената стойност. Затова два реда в шаблона с {{ total }} смятат сумата веднъж.
const sorted = computed(() => products.value.toSorted(byPrice));Функция, извикана в шаблона ({{ totalOf(items) }}), също дава вярна сума. Разликата е, че тя се пуска при всяко рисуване на компонента, а computed само при смяна на зависимостите. Функцията в computed само чете и връща. Не сменя състояние и не вика fetch.
Идеята
Филтърът също е изчислен
Каталогът има бутони „Всички“, „Хляб“ и „Мляко“. Изкушението е да пазиш два масива: всички продукти и видимите. Но видимите следват от два други факта: пълния списък и избраната категория. Пазиш само категорията:
const category = ref('all');const visible = computed(() => category.value === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category.value),);Същото е с подреждането: пазиш „по цена“ или „по име“, а подреденият списък го изчисляваш. Внимавай с sort(): той подрежда масива на място, тоест сменя състоянието. toSorted() връща нов масив и оставя стария.
Пробвай
Само категорията
Бутоните сменят категорията, но списъкът винаги показва всичко. Изчисли видимите продукти от категорията и покажи „Показани: 3 от 5“ над списъка. Проверката избира „Мляко“, после „Хляб“, после „Всички“.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import ProductCard from './ProductCard.vue';
const PRODUCTS = [
{ id: 1, name: 'Бял хляб', price: 1.2, category: 'bread' },
{ id: 2, name: 'Пълнозърнест хляб', price: 2.1, category: 'bread' },
{ id: 5, name: 'Прясно мляко', price: 1.6, category: 'milk' },
{ id: 6, name: 'Кисело мляко', price: 0.9, category: 'milk' },
{ id: 7, name: 'Сирене', price: 7.9, category: 'milk' },
];
const CATEGORIES = [
{ id: 'all', name: 'Всички' },
{ id: 'bread', name: 'Хляб' },
{ id: 'milk', name: 'Мляко' },
];
const category = ref('all');
const visible = computed(() =>
category.value === 'all' ? PRODUCTS : PRODUCTS.filter((p) => p.category === category.value),
);
</script>
<template>
<main>
<h1>Продукти</h1>
<button v-for="c in CATEGORIES" :key="c.id" type="button" :aria-pressed="category === c.id" @click="category = c.id">
{{ c.name }}
</button>
<p>Показани: {{ visible.length }} от {{ PRODUCTS.length }}</p>
<ul>
<ProductCard v-for="p in visible" :key="p.id" :product="p" />
</ul>
</main>
</template>
ProductCard.vue<script setup>
import { money } from './money.js';
defineProps(['product']);
</script>
<template>
<li>
<span>{{ product.name }}</span> {{ money(product.price) }}
</li>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
visible се изчислява от category и PRODUCTS: при „Всички“ е целият списък, иначе filter по категория. Броят е visible.length от PRODUCTS.length.
Без computed (const visible = PRODUCTS.filter(…)) сметката се прави веднъж и списъкът не следва бутоните.
Идеята
Копие на prop
Същата грешка има и между компоненти. App дава намаление 20% и подава новите цени на всяка ProductCard. Картата обаче е запомнила цената, която е получила първия път:
Конзола
Натисни „Намаление 20%“. Бутонът е натиснат, а хлябът още е 1,20 €.
const price = ref(props.product.price) копира числото веднъж, когато <script setup> се пуска. После props.product е нов, но price е отделна кутия, която никой не сменя.
Поправката е да не копираш: показвай product.price направо. Ако картата трябва да покаже нещо, изчислено от prop (цена с ДДС, цена на брой), изчисли го при рисуване с computed(() => props.product.price * …).
Пробвай
Намери грешката
Купувачът натиска „+“: количеството на реда става 3, а сумата на реда остава като за 2. Кой ред е виновен?
Избери реда с грешката.
props.item.price * props.item.qty в ref веднъж. Трябва const subtotal = computed(() => props.item.price * props.item.qty);.По-точно, за любопитните
Кога копието е нарочно. Понякога искаш стойност отвън само за начало: формуляр за редакция започва с текущото име, а после човекът пише. Тогава копието е правилно, но го кажи в името: prop initialName, състояние name. Който чете кода, вижда, че промените отвън не се следят.
computed със запис. computed({ get, set }) дава стойност, която може и да се присвои: set превежда новата стойност към истинското състояние. Ползва се рядко, например за поле, свързано с част от обект.
computed е мързелив. Ако никой не чете total, сметката не се прави. Затова вътре няма странични ефекти: не знаеш кога и колко пъти ще се пусне. За действие при промяна има watch, следващия модул.
Options API. В по-стария запис изчислените стойности са в обекта computed: { total() { … } }. Идеята е същата.
Същото важи за данни от сървъра. Ако сървърът връща продуктите, а ти пазиш отделно „брой продукти“, след ново зареждане двете могат да се разминат. Пази отговора, а броя изчислявай.
Упражнение
Безплатна доставка
Кошницата има бутони „−“ и „+“ за всеки ред (при „−“ на последната бройка редът изчезва). Под списъка покажи „Продукти: 4“, „Общо: 18,20 €“ и „До безплатна доставка: още 11,80 €“, а над 30 € (FREE_FROM) само „Доставката е безплатна“. Всичко се изчислява от items. Проверката добавя сирене до 34,00 €, после маха до под 30 € и маха хляба.
Конзола
Решение
App.vue<script setup>
import { ref, computed } from 'vue';
import { money } from './money.js';
const FREE_FROM = 30;
const items = ref([
{ id: 7, name: 'Сирене', price: 7.9, qty: 2 },
{ id: 1, name: 'Бял хляб', price: 1.2, qty: 2 },
]);
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));
const free = computed(() => total.value >= FREE_FROM);
function more(item) {
item.qty++;
}
function less(item) {
if (item.qty === 1) items.value = items.value.filter((i) => i.id !== item.id);
else item.qty--;
}
</script>
<template>
<main>
<h1>Кошницата</h1>
<ul>
<li v-for="item in items" :key="item.id">
<span>{{ item.name }} × {{ item.qty }}</span>
<button type="button" :aria-label="`По-малко ${item.name}`" @click="less(item)">−</button>
<button type="button" :aria-label="`Още ${item.name}`" @click="more(item)">+</button>
</li>
</ul>
<p>Продукти: {{ count }}</p>
<p>Общо: {{ money(total) }}</p>
<p v-if="free">Доставката е безплатна</p>
<p v-else>До безплатна доставка: още {{ money(FREE_FROM - total) }}</p>
</main>
</template>
money.jsexport const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
style.cssbody { font-family: system-ui, sans-serif; margin: 0; padding: 0 16px; }
ul { list-style: none; padding: 0; }
li { display: flex; gap: 8px; align-items: center; margin: 6px 0; }
li span { flex: 1; }
button { font: inherit; }
button[aria-pressed="true"] { background: #2f6f4f; color: white; }
Броят, сумата и free са изчислени от items; съобщението за доставка е условно показване: free или останалата сума FREE_FROM - total. Проверката минава и над 30 €, и обратно под тях. Затова „безплатна“, пазена като отделно състояние, се хваща: тя става true при „+“, но никой не я връща при „−“.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци и ключове · Условия: празно, грешка, зарежда · Състояние · Обновяване на обекти и масиви
- Продължава в: Вдигане на състоянието · Ефекти · Общо състояние · Рутиране
- Задачи по темата: Състояние