DOM: страницата от JavaScript
Накратко: DOM е дървото от обекти, през което JavaScript чете и мени страницата: querySelector намира елемент, textContent сменя текста, а createElement и append добавят нови.
Проблем
Цената остава „—“
Цената и наличността на продукта идват от JavaScript. Колега е сложил скрипта най-горе, „за да тръгне по-рано“. Страницата показва „—“ вместо цена.
Конзола
Конзолата казва две неща. console.log(price) е отпечатал null, а следващият ред пада с TypeError: Cannot set properties of null. Елементът <strong id="price"> го има в HTML-а. Защо JavaScript не го намира?
Идеята
Документът като обект
В модула за HTML видя, че браузърът строи от текста дърво. JavaScript работи със същото дърво: за него страницата е обект с име document. Всеки елемент в дървото е обект с полета и методи. Затова дървото се казва DOM: Document Object Model.
Елемент се намира със селектор, както в CSS:
const price = document.querySelector('#price'); // по idconst title = document.querySelector('h1'); // по тагconst first = document.querySelector('.product'); // по клас: първиятconst links = document.querySelectorAll('nav a'); // всички връзки в navquerySelectorобхожда дървото отгоре надолу и връща първия елемент, който пасва. Ако няма такъв, връщаnull.querySelectorAllвръща всички като NodeList: списък, който имаlengthиforEach, но нямаmapиfilter. За тях го превърни в масив:[...links]. Масивите иforEachса от курса по JavaScript: масиви и ламбди.- NodeList-ът е снимка: елемент, добавен по-късно, не влиза в него.
null значи „няма такъв елемент“, а не „празен елемент“. Грешката идва после: null няма textContent, и кодът пада на следващия ред.
Идеята
Кога върви скриптът
Парсерът чете HTML отгоре надолу. Когато стигне до <script>, той спира, пуска скрипта и чак после продължава. В проблема скриптът е преди <article>. Когато върви, в дървото има само <body>, а цената още я няма. Затова querySelector връща null.
Поправката е скриптът да чака дървото:
<head> <meta charset="utf-8"> <script src="script.js" defer></script></head>deferказва: изтегли скрипта веднага, но го пусни, когато целият HTML е прочетен. Тогава всички елементи са в дървото.- По-старият начин е
<script>най-долу, точно преди</body>. Работи по същата причина. deferважи само за скрипт сsrc. На скрипт с код между таговете няма ефект.
Пробвай
Поправи цената
Поправи страницата така, че да показва цената и наличността. Не пипай script.js: проблемът е кога върви, а не какво прави.
Конзола
Решение
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>
<article>
<h1>Хляб „Добруджа“</h1>
<p>Цена: <strong id="price">—</strong></p>
<p id="stock">—</p>
<button type="button">Добави в кошницата</button>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
button { font-size: 16px; padding: 8px 16px; }
script.jsconst price = document.querySelector('#price');
console.log(price);
price.textContent = '1,80 €';
document.querySelector('#stock').textContent = 'В наличност: 12 бр.';
<script src="script.js" defer></script> в <head> чака дървото да е готово. Тогава #price и #stock са в него и querySelector ги намира. Без defer скриптът в <head> върви още по-рано и пак намира null. Проверка if (price) в script.js скрива грешката, но цената пак не се показва: тя пази от null, а не го поправя.
Идеята
Текстът: textContent и innerHTML
Намереният елемент се променя през полетата му. За текста има две:
price.textContent = '1,80 €'; // текстът, както еnote.innerHTML = 'Цена: <strong>1,80 €</strong>'; // текстът се чете като HTMLtextContentслага текст. Ако в него има<strong>, той се показва като знаци, а не като таг.innerHTMLдава текста на парсера и той строи от него нови елементи. Старите деца на елемента изчезват.
innerHTML с данни от потребител (име, отзив, търсене) е опасен. Ако в тях има HTML с код, той тръгва в браузъра на всеки, който отвори страницата: това е XSS атака, за която е урокът „Недоверен вход“. Затова текстът е с textContent, а нови елементи се правят с createElement (по-долу).
След промяната браузърът сам рисува наново. Не рисува цялата страница, а само каквото се е променило.
Виж го
Виж как се променя страницата
Вляво е дървото, вдясно е страницата, която браузърът рисува от него. Кодът намира кошницата, сменя ѝ текста и класа и добавя нов ред в списъка.
const cart = document.querySelector('#cart');cart.textContent = 'Кошница: 1';cart.classList.add('full');const li = document.createElement('li');li.textContent = 'Мляко';document.querySelector('ul').append(li);Стъпка по стъпка, с думи
- Парсерът вече е построил дървото от HTML. Вдясно е страницата, която браузърът рисува от него. JavaScript вижда същото дърво през document.
- querySelector('#cart') обхожда дървото отгоре и сравнява всеки елемент със селектора. Връща първия, който пасва: cart вече сочи този възел. Ако няма такъв, връща null.
- textContent сменя текста на възела. Браузърът забелязва промяната и рисува наново само тази част от страницата. Останалото си стои.
- classList.add слага клас на елемента. CSS правило за .full му дава нов цвят и пак се рисува наново само кошницата.
- createElement прави нов <li>, а textContent му дава текст. Той още не е в дървото, затова страницата не се променя.
- append го слага като последно дете на <ul>. Сега е в дървото и списъкът на страницата расте с един ред.
- JavaScript не рисува страницата сам. Той променя дървото, а браузърът рисува каквото се е променило. Страницата не се зарежда наново.
JavaScript не рисува нищо сам. Той променя дървото, а браузърът рисува промяната. Страницата не се зарежда наново, затова текстът в полетата и мястото, до което си превъртял, остават.
Пробвай
Остават 3 бр.
Покажи наличността в #stock: „Остават 3 бр.“. Ако са под 5, сложи на елемента класа low (CSS-ът го оцветява в червено). Удебеляването от класа stock трябва да остане.
Конзола
Решение
index.html<!doctype html>
<html lang="bg">
<head>
<meta charset="utf-8">
<title>Прясно мляко 3%</title>
<link rel="stylesheet" href="style.css">
<script src="script.js" defer></script>
</head>
<body>
<article>
<h1>Прясно мляко 3%</h1>
<p id="stock" class="stock">—</p>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
.stock { font-weight: bold; }
.low { color: #c2452a; }
script.jsconst stock = 3;
const text = document.querySelector('#stock');
text.textContent = `Остават ${stock} бр.`;
if (stock < 5) {
text.classList.add('low');
}
text.textContent = `Остават ${stock} бр.` слага текста, а text.classList.add('low') добавя класа до stock. className = 'low' също оцветява, но сменя всички класове: stock изчезва и текстът вече не е удебелен. А text.class = 'low' не прави нищо видимо: това е ново поле на обекта, а не класът на елемента.
Идеята
Атрибути, свойства и класове
В HTML елементът има атрибути: value="1", disabled, data-id="7". В JavaScript той има свойства: полета на обекта. Повечето атрибути имат свойство със същото име, но двете не са едно и също нещо.
input.valueе това, което е в полето сега. Атрибутътvalueе началната стойност и не се мени, докато купувачът пише. Атрибутът се чете сgetAttribute('value').button.disabled = trueизключва бутона. Свойството еtrueилиfalse.- Атрибутите
data-…са за твои данни. Четат се презdataset:data-stockеdataset.stock, аdata-product-idеdataset.productId. Стойността винаги е низ. - Класовете се пипат през
classList:add,remove,toggle(слага класа, ако го няма, и го маха, ако го има) иcontains.
Конзола
Конзолата показва, че свойството е 3, а атрибутът си е 1. dataset.stock е "12", низ: за сметка първо го превърни в число с Number(...).
Пробвай
Намери грешката
Намалението трябва да оцвети всички цени, но конзолата казва Cannot read properties of undefined (reading 'add').
Избери реда с грешката.
querySelectorAll връща NodeList, а той няма classList: prices.classList е undefined. Класът се слага на всеки елемент: prices.forEach((p) => p.classList.add('sale')).
Идеята
Нови елементи
Нов елемент се прави в три стъпки: създаваш го, даваш му съдържание и го слагаш в дървото.
const li = document.createElement('li'); // нов <li>, още не е на страницатаli.textContent = 'Мляко';li.dataset.id = '2';document.querySelector('ul').append(li); // вече е последното дете на <ul>Докато не го сложиш с append, елементът го няма на страницата. append приема и няколко елемента наведнъж: li.append(title, button). prepend слага в началото, а remove() маха елемента от дървото.
По-точно, за любопитните
Още начини за търсене. getElementById('price') е като querySelector('#price'). getElementsByClassName и getElementsByTagName връщат „жив“ списък: той се мени, когато се мени дървото. querySelectorAll връща снимка. След append на нов .price живият списък има един елемент повече, а снимката не.
Търсене вътре в елемент. querySelector го има и всеки елемент, не само document: card.querySelector('.price') търси само в картата.
setAttribute('disabled', 'false') изключва бутона. При disabled има значение само дали атрибутът е там, а не стойността му. Затова се пише button.disabled = false или removeAttribute('disabled').
innerHTML и <script>. Скрипт, вкаран с innerHTML, не тръгва. Атрибут като onerror на картинка обаче тръгва. Затова innerHTML с чужди данни е опасен и без <script>.
className срещу classList. className е целият атрибут class като един низ. Присвояването му сменя всички класове наведнъж.
Упражнение
Картата на продукт
В script.js има обект product. Попълни картата от него: името е заглавието (#name), цената е „8,90 €“ (две цифри след запетаята), а наличността е „Изчерпан“, когато stock е 0. Тогава картата получава класа sold-out, а бутонът е изключен. На картата сложи data-id с id-то на продукта. За всеки етикет от badges добави <li> в #badges.
Конзола
Решение
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>
<article class="product">
<h2 id="name"></h2>
<p id="price"></p>
<p id="stock"></p>
<ul id="badges" aria-label="Етикети"></ul>
<button type="button" id="add">Добави в кошницата</button>
</article>
</body>
</html>
style.cssbody { font-family: system-ui, sans-serif; padding: 16px; }
.product { border: 1px solid #ccc; border-radius: 8px; padding: 12px 16px; max-width: 320px; }
.sold-out { opacity: 0.6; }
#badges { list-style: none; padding: 0; display: flex; gap: 8px; }
#badges li { background: #f3efe6; border-radius: 4px; padding: 2px 8px; }
button { font-size: 16px; padding: 8px 16px; }
script.jsconst product = {
id: 7,
name: 'Кашкавал „Витоша“',
price: 8.9,
stock: 0,
badges: ['ново', 'българско'],
};
const card = document.querySelector('.product');
card.dataset.id = product.id;
document.querySelector('#name').textContent = product.name;
document.querySelector('#price').textContent = `${product.price.toFixed(2).replace('.', ',')} €`;
const stock = document.querySelector('#stock');
const add = document.querySelector('#add');
if (product.stock === 0) {
stock.textContent = 'Изчерпан';
card.classList.add('sold-out');
add.disabled = true;
} else {
stock.textContent = `В наличност: ${product.stock} бр.`;
}
const badges = document.querySelector('#badges');
product.badges.forEach((badge) => {
const li = document.createElement('li');
li.textContent = badge;
badges.append(li);
});
card.dataset.id = product.id пише атрибута data-id="7". Цената е product.price.toFixed(2).replace('.', ','): toFixed(2) дава "8.90", а точката става запетая. При stock === 0 три неща вървят заедно: текстът, класът и add.disabled = true. Етикетите са forEach по масива с createElement('li') и append за всеки. badges.textContent = product.badges.join(', ') показва същите думи, но като един текст: списъкът няма нито един елемент.
Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: HTML документът · Формуляри · Селектори и каскада
- Продължава в: Събития · Списък от данни · Данни от API · Първият компонент
- Задачи по темата: JavaScript в страницата