Списък от данни
Накратко: Рисуването от данни е една функция render(), която строи списъка в страницата от масив, така че всички места показват едно и също.
Проблем
Кошницата лъже
Купувачът добавя хляб и мляко. Редовете в кошницата са верни, сумата е вярна, а горе пише „Кошница (0)“. Натисни двата бутона „Добави“ и виж брояча.
Конзола
Конзолата е празна. Всяко място на страницата е обновено с отделен ред код в add, и за брояча такъв ред няма. Браузърът не знае, че трите места показват едно и също. Затова никой не забелязва грешката, освен купувача.
Идеята
От масив към списък
Продуктите идват като данни: масив от обекти с име и цена. Страницата трябва да ги покаже като елементи от списък. Пътят е един и същ за всеки списък:
function productItem(product) { const li = document.createElement('li'); li.textContent = `${product.name}, ${money(product.price)}`; return li;}
list.replaceChildren(...products.map(productItem));document.createElement('li')създава нов елемент. Той още не е в страницата.textContentслага текста. Името на продукта е текст, а не HTML, затова никой символ в него не става таг.products.map(productItem)прави от всеки продукт по един<li>: масив от елементи.list.replaceChildren(...)маха старите деца на списъка и слага новите....разпъва масива на отделни аргументи.
Това се казва рисуване на списък от данни: данните са в JavaScript, а страницата се строи от тях с функция.
Виж го
Каталогът
Конзола
Смени цената на хляба в масива и натисни „Изпълни“. Не пипаш HTML-а: списъкът следва данните. Добави пети продукт в масива и той се появява като пети ред.
Пробвай
Само наличните
Покажи в списъка само продуктите, които са в наличност (stock е по-голямо от 0). Под списъка, в #count, напиши колко са: „Налични: 3 от 5“.
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Хляб и мляко</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<main>
<h1>Хляб и мляко</h1>
<ul id="products"></ul>
<p id="count"></p>
</main>
</body>
</html>
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; }
li { margin: 4px 0; }
button { font: inherit; margin-left: 8px; }
script.jsconst products = [
{ id: 1, name: 'Бял хляб', price: 1.2, stock: 40 },
{ id: 4, name: 'Баница със сирене', price: 2.5, stock: 0 },
{ id: 5, name: 'Прясно мляко', price: 1.6, stock: 35 },
{ id: 13, name: 'Краставици', price: 2.8, stock: 0 },
{ id: 10, name: 'Ябълки', price: 2.1, stock: 80 },
];
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
function productItem(product) {
const li = document.createElement('li');
li.textContent = `${product.name}, ${money(product.price)}`;
return li;
}
const list = document.querySelector('#products');
const count = document.querySelector('#count');
const inStock = products.filter((p) => p.stock > 0);
list.replaceChildren(...inStock.map(productItem));
count.textContent = `Налични: ${inStock.length} от ${products.length}`;
products.filter((p) => p.stock > 0) е нов масив само с наличните. От него се прави списъкът, а дължините на двата масива дават двете числа. Масивът products остава цял: филтърът не го променя.
Идеята
Едни данни, три места
Кошницата е масив cart. Страницата го показва три пъти: броя горе, редовете и сумата. При всяка промяна на cart трите места трябва да се сменят заедно.
На ръка това значи: всяка функция, която променя cart, пипа всяко място, което го показва. add пипа редовете и сумата. remove пипа редовете, сумата и брояча. Утре идва четвърто място, например „Безплатна доставка над 30 €“, и трябва да се сети всяка функция.
Когато едно място е забравено, данните и екранът се разминават: в cart има два продукта, а страницата казва нула. Грешка няма. Има страница, която показва нещо невярно.
Виж го
Виж разминаването
const cart = [];
function addByHand(product) { cart.push(product); cartList.append(cartItem(product)); total.textContent = `Общо: ${money(sum())}`;}
function render() { badge.textContent = `Кошница (${cart.length})`; cartList.replaceChildren(...cart.map(cartItem)); total.textContent = `Общо: ${money(sum())}`;}
function add(product) { cart.push(product); render();}Стъпка по стъпка, с думи
- Вляво са данните: масивът cart. Вдясно са трите места на страницата, които го показват: броячът, редовете и сумата.
- addByHand(хляб). Първо се сменят данните: масивът има един продукт.
- После функцията пипа екрана на ръка: нов ред и нова сума. Всяко място е отделен ред код.
- Ред за брояча никой не е написал. Данните казват 1, броячът казва 0. В конзолата няма грешка: страницата показва нещо невярно.
- Още един продукт, пак на ръка. Разликата расте: 2 срещу 0.
- Сега add(): тя само сменя данните. За миг целият екран е назад, защото още нищо не е нарисувано.
- render() рисува трите места наново от cart: брояча, всеки ред и сумата. Не я интересува какво е имало на екрана преди.
- Правилото: всяка промяна е първо данните, после render(). Екранът следва от данните и забравено място няма.
addByHand пипа две места от три. render пипа и трите, всеки път, от данните. Тя не пита какво е имало на екрана преди и затова не може да се обърка от него.
Идеята
Една render()
Правилото е кратко: функциите, които променят данните, не пипат екрана. Те сменят данните и викат render(). Само render() пише в страницата и пише всичко от данните:
function render() { badge.textContent = `Кошница (${cart.length})`; cartList.replaceChildren(...cart.map(cartItem)); total.textContent = `Общо: ${money(sum())}`;}Така всяко място е описано веднъж. Новото място се добавя на един ред в render() и важи за add, за remove и за всяка бъдеща функция. Редовете се рисуват наново целите: replaceChildren маха старите и слага нови от cart.map(cartItem).
Пробвай
Една render()
add и remove пипат екрана на ръка и всяка забравя по едно място. Напиши render() така, че да рисува брояча, редовете и сумата от cart, а add и remove само да сменят cart и да викат render().
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Кошница</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<header>
<a href="/">Магазинът</a>
<a href="/cart" id="badge">Кошница (0)</a>
</header>
<main>
<h1>Хляб и мляко</h1>
<ul id="products"></ul>
<h2>Кошницата</h2>
<ul id="cart"></ul>
<p id="total">Общо: 0,00 €</p>
</main>
</body>
</html>
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; }
li { margin: 4px 0; }
button { font: inherit; margin-left: 8px; }
script.jsconst products = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
{ id: 10, name: 'Ябълки', price: 2.1 },
];
const cart = [];
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
const badge = document.querySelector('#badge');
const cartList = document.querySelector('#cart');
const total = document.querySelector('#total');
const sum = () => cart.reduce((s, p) => s + p.price, 0);
function productItem(product) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Добави';
button.setAttribute('aria-label', `Добави ${product.name}`);
button.addEventListener('click', () => add(product));
li.append(`${product.name}, ${money(product.price)}`, button);
return li;
}
document.querySelector('#products').replaceChildren(...products.map(productItem));
function cartItem(product, index) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Махни';
button.setAttribute('aria-label', `Махни ${product.name}`);
button.addEventListener('click', () => remove(index));
li.append(product.name, button);
return li;
}
function add(product) {
cart.push(product);
render();
}
function remove(index) {
cart.splice(index, 1);
render();
}
function render() {
badge.textContent = `Кошница (${cart.length})`;
cartList.replaceChildren(...cart.map(cartItem));
total.textContent = `Общо: ${money(sum())}`;
}
add прави cart.push(product) и render(). remove прави cart.splice(index, 1) и render(). render() пише брояча, сменя всички редове с replaceChildren и пише сумата. Ако редовете се добавят с append, без да се махнат старите, след три клика има шест реда: 1 + 2 + 3.
Пробвай
Намери грешката
Броячът винаги е с един продукт назад: след първия клик казва 0, след втория 1.
Избери реда с грешката.
render() е на ред 2, преди cart.push(product). Тя рисува данните такива, каквито са в момента: без новия продукт. Първо се сменят данните, после се рисува.
Идеята
Цената на „всичко наново“
render() не знае какво се е променило, затова сменя всичко. Обикновено това е невидимо. Понякога не е. В кошницата долу всеки ред има поле за брой и при всяка промяна на полето се вика render(). Купувачът иска 15 млека: трие 2, пише 1 и 5.
Конзола
След цифрата 1 render() маха старото поле и слага ново със стойност 1. Фокусът беше на старото поле и вече го няма, затова 5 не стига никъде. Същото става с курсора в текстово поле, с позицията на превъртане в дълъг списък и с отворено падащо меню. А при 1000 реда браузърът строи 1000 нови елемента за всеки клик.
Двата пътя са лоши: на ръка забравяш места, а „всичко наново“ губи фокус и е бавно при голям списък. Решението е трето: пишеш една функция, която казва как изглежда екранът при тези данни, както render(). Някой друг сравнява новия резултат със стария и сменя в страницата само разликата: текста на брояча, един ред, нищо по полето. Това правят React и Vue, и това е следващият модул: компоненти.
По-точно, за любопитните
innerHTML вместо createElement. list.innerHTML = products.map((p) => `<li>${p.name}</li>`).join('') е по-кратко, но името на продукта влиза като HTML. Име с <img onerror=…> става код в страницата (XSS). textContent и createElement пазят текста като текст.
replaceChildren. Без аргументи изпразва елемента: list.replaceChildren(). Преди него се пишеше list.innerHTML = '' и после append.
Слушателите. Всеки нов бутон получава нов слушател в productItem. Старите бутони изчезват заедно със слушателите си. Друг път е делегирането: един слушател на <ul> и event.target.closest('li'), а в елемента пише кой продукт е (data-id).
hidden. element.hidden = true е като атрибута hidden в HTML: елементът не се показва. Ако CSS правило даде на елемента display, то печели над hidden, и елементът пак се вижда.
Да пазиш фокуса на ръка. Може да запомниш кое поле е било на фокус и къде е бил курсорът и след render() да ги върнеш. Работи, но е още едно място за забравяне. Библиотеките го правят вместо теб, защото не сменят полето изобщо.
Упражнение
Кошницата
Направи каталога и кошницата от данните. Всеки продукт е ред с бутон „Добави“ (името за екранния четец: „Добави Бял хляб“). Всеки ред в кошницата има бутон „Махни“ („Махни Бял хляб“). Броячът горе е „Кошница (2)“, сумата е „Общо: 10,00 €“, а „Кошницата е празна“ се вижда само когато кошницата е празна (свойството hidden).
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Кошница</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<header>
<a href="/">Магазинът</a>
<a href="/cart" id="badge">Кошница (0)</a>
</header>
<main>
<h1>Хляб и мляко</h1>
<ul id="products"></ul>
<h2>Кошницата</h2>
<p id="empty">Кошницата е празна</p>
<ul id="cart"></ul>
<p id="total">Общо: 0,00 €</p>
</main>
</body>
</html>
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; }
li { margin: 4px 0; }
button { font: inherit; margin-left: 8px; }
script.jsconst products = [
{ id: 1, name: 'Бял хляб', price: 1.2 },
{ id: 5, name: 'Прясно мляко', price: 1.6 },
{ id: 7, name: 'Сирене', price: 7.9 },
{ id: 10, name: 'Ябълки', price: 2.1 },
];
const cart = [];
const money = (x) => x.toFixed(2).replace('.', ',') + ' €';
const badge = document.querySelector('#badge');
const cartList = document.querySelector('#cart');
const total = document.querySelector('#total');
const empty = document.querySelector('#empty');
const sum = () => cart.reduce((s, p) => s + p.price, 0);
function productItem(product) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Добави';
button.setAttribute('aria-label', `Добави ${product.name}`);
button.addEventListener('click', () => add(product));
li.append(`${product.name}, ${money(product.price)}`, button);
return li;
}
function cartItem(product, index) {
const li = document.createElement('li');
const button = document.createElement('button');
button.type = 'button';
button.textContent = 'Махни';
button.setAttribute('aria-label', `Махни ${product.name}`);
button.addEventListener('click', () => remove(index));
li.append(product.name, button);
return li;
}
function add(product) {
cart.push(product);
render();
}
function remove(index) {
cart.splice(index, 1);
render();
}
function render() {
badge.textContent = `Кошница (${cart.length})`;
empty.hidden = cart.length > 0;
cartList.replaceChildren(...cart.map(cartItem));
total.textContent = `Общо: ${money(sum())}`;
}
document.querySelector('#products').replaceChildren(...products.map(productItem));
render();
Решението е с една render(): броячът, empty.hidden = cart.length > 0, редовете с replaceChildren и сумата. add и remove само сменят cart и викат render(). Най-долу render() се вика веднъж и при зареждане, за да е страницата вярна още преди първия клик. Проверката добавя два продукта, маха ги и гледа, че съобщението за празна кошница се връща.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Текст, връзки, картинки, таблици · Достъпност · DOM: страницата от JavaScript · Събития
- Продължава в: Данни от API · Първият компонент · Props: данни отгоре · Списъци и ключове
- Задачи по темата: JavaScript в страницата