Предисловие
Для вашего удобства текстовый курс перенесен на отдельную платформу. Ссылка для поступления: тык
Мы настоятельно рекомендуем пользоваться именно этой платформой по ходу курса. Ссылка на текущее занятие: тык
⚠️Прежде чем переходить к заданиям - обязательно зарегистрируйтесь на курс. Иначе вас закинет на общий поток и вы не сможете взаимодействовать с сверстниками во втором модуле курса. Это не смертельно и вполне решаемо, но тем не менее.
JavaScript и DOM: поиск и изменение элементов, события, состояние и рендер
Как JavaScript видит страницу
На втором занятии мы узнали, что браузер строит из HTML дерево элементов — DOM. Из JavaScript это дерево доступно через объект document. Каждый элемент страницы в JavaScript — это объект со свойствами и методами: у него можно прочитать текст, поменять классы, добавить дочерние элементы.
<h1>Каталог</h1>
<p>Товары для учёбы и работы.</p>
<script>
const heading = document.querySelector('h1');
console.log(heading.textContent);
heading.textContent = 'Каталог техники';
console.log(heading.textContent);
</script>
В этом примере скрипт находит заголовок, выводит в консоль его текст, меняет текст и выводит его ещё раз. В предпросмотре вы увидите уже изменённый заголовок: скрипт выполнился раньше, чем страница появилась на экране.
Когда JavaScript меняет DOM, браузер сразу перерисовывает страницу. HTML-файл при этом не меняется: после перезагрузки страница снова будет такой, как описано в файле, и скрипт выполнится заново.
Работа с DOM строится из трёх действий:
найти нужный элемент;
прочитать или изменить его текст, классы, атрибуты;
отреагировать на действия пользователя: нажатия, ввод текста, отправку формы.
Разберём каждое действие по очереди.
Почитать ещё
Дока: DOM
Когда скрипт видит элементы
Браузер читает HTML сверху вниз и выполняет обычный скрипт в тот момент, когда до него дошёл. Если скрипт стоит в <head>, элементов из <body> в DOM ещё нет, и найти их не получится.
<!DOCTYPE html>
<html lang="ru">
<head>
<!-- Скрипт выполнится до того, как браузер прочитает body -->
<script src="script.js"></script>
</head>
<body>
<h1>Каталог</h1>
</body>
</html>
В таком документе document.querySelector('h1') внутри script.js вернёт null, и попытка поменять текст заголовка закончится ошибкой.
Есть два способа это исправить:
добавить атрибут
defer:<script defer src="script.js"></script>. Браузер скачает скрипт, но выполнит его только после того, как построит весь документ;поставить
<script src="script.js"></script>последним элементом внутри<body>. К моменту выполнения скрипта все элементы выше уже будут в DOM.
В курсе мы подключаем скрипты через defer в <head>. В примерах этого занятия скрипт стоит в конце примера, после разметки, по той же причине.
Почитать ещё
Дока:
deferиasync
Поиск элементов
Элементы находят по CSS-селекторам — тем же, что вы писали в стилях.
<h1 id="page-title">Каталог</h1>
<article class="card">Laptop 14</article>
<article class="card">Наушники Studio</article>
<article class="card">Мышь Click</article>
<script>
const title = document.querySelector('#page-title');
const firstCard = document.querySelector('.card');
const cards = document.querySelectorAll('.card');
const missing = document.querySelector('.banner');
console.log(title.textContent);
console.log(firstCard.textContent);
console.log(cards.length);
console.log(missing);
cards.forEach((card) => {
console.log('Карточка:', card.textContent);
});
</script>
document.querySelector(селектор)возвращает первый элемент, который подходит под селектор. Если такого элемента нет, метод возвращаетnull.document.querySelectorAll(селектор)возвращает список всех подходящих элементов. Если элементов нет, список будет пустым.
Список из querySelectorAll похож на массив: у него есть length, по нему можно пройти циклом for...of или методом forEach. Но методов map и filter у него нет. Чтобы их использовать, список превращают в массив: [...cards].map(...).
Искать можно не только во всём документе, но и внутри конкретного элемента. card.querySelector('.price') найдёт цену внутри этой карточки, а не первую цену на странице.
Если querySelector вернул null, хотя элемент на странице есть, проверьте две вещи: правильно ли записан селектор (точка перед классом, решётка перед id) и не выполняется ли скрипт раньше, чем появился элемент.
Почитать ещё
Дока:
querySelector(),querySelectorAll()
Текст и разметка элемента
Свойство textContent содержит текст элемента. Если присвоить ему новое значение, всё содержимое элемента заменится этим текстом.
Свойство innerHTML содержит разметку внутри элемента. Если присвоить ему строку, браузер разберёт её как HTML и создаст новые элементы.
<p id="as-text"></p>
<p id="as-html"></p>
<script>
const userInput = 'Скидка <strong>50%</strong>';
document.querySelector('#as-text').textContent = userInput;
document.querySelector('#as-html').innerHTML = userInput;
</script>
В первом абзаце теги показаны как обычный текст, во втором браузер превратил их в элемент <strong>.
Это различие важно для безопасности. Если вставить через innerHTML текст, который ввёл пользователь, в нём могут оказаться теги с кодом. Например, строка <img src="x" onerror="..."> создаст картинку, которая не загрузится, и браузер выполнит код из атрибута onerror. Злоумышленник может так украсть данные других пользователей. Такая уязвимость называется XSS, и это одна из самых частых уязвимостей сайтов.
Правило: текст, который пришёл от пользователя или с сервера, вставляйте через textContent. Если строку с таким текстом всё-таки нужно собрать в разметку для innerHTML, замените в тексте специальные символы на безопасные коды:
function escapeHtml(text) {
return text
.replaceAll('&', '&')
.replaceAll('<', '<')
.replaceAll('>', '>')
.replaceAll('"', '"');
}
После такой замены браузер покажет теги как текст и не создаст из них элементы.
Почитать ещё
Дока:
textContent,innerHTML
Классы и стили
Внешний вид элемента из JavaScript обычно меняют через классы: стили описаны в CSS, а скрипт только добавляет или убирает класс.
<article class="card">
<h2>Laptop 14</h2>
<p>89 990 ₽</p>
</article>
<button id="highlight" type="button">Выделить карточку</button>
<style>
.card {
padding: 12px;
border: 2px solid #d0d7de;
border-radius: 12px;
}
.card--active {
border-color: #4f46e5;
background: #eef2ff;
}
</style>
<script>
const card = document.querySelector('.card');
const button = document.querySelector('#highlight');
button.addEventListener('click', () => {
card.classList.toggle('card--active');
console.log('Выделена:', card.classList.contains('card--active'));
});
</script>
Нажмите кнопку в предпросмотре несколько раз. Обработчик нажатия записан через addEventListener, его подробно разберём в разделе про события.
У свойства classList есть методы:
add(класс)добавляет класс;remove(класс)удаляет класс;toggle(класс)добавляет класс, если его нет, и удаляет, если он есть;contains(класс)проверяет, есть ли класс у элемента.
Стиль можно задать и напрямую через свойство style: card.style.backgroundColor = '#eef2ff'. Названия CSS-свойств из нескольких слов в style пишут в camelCase: background-color превращается в backgroundColor. Такой стиль записывается в атрибут style элемента и перебивает стили из CSS-файла. Используйте style, когда значение вычисляется в коде, например ширина полоски прогресса. Во всех остальных случаях переключайте классы.
Почитать ещё
Дока:
classList,style
Атрибуты и data-атрибуты
Атрибуты элемента читают и меняют методами getAttribute и setAttribute.
<a id="details" href="https://doka.guide/">Подробнее</a>
<img id="photo" src="https://picsum.photos/seed/laptop/240/150" alt="Ноутбук" />
<article class="card" data-product-id="42" data-category="laptops">Laptop 14</article>
<script>
const link = document.querySelector('#details');
const photo = document.querySelector('#photo');
const card = document.querySelector('.card');
console.log(link.getAttribute('href'));
link.setAttribute('target', '_blank');
photo.setAttribute('src', 'https://picsum.photos/seed/headphones/240/150');
photo.alt = 'Наушники';
console.log(card.dataset.productId, typeof card.dataset.productId);
console.log(card.dataset.category);
</script>
getAttribute(имя)возвращает значение атрибута илиnull, если атрибута нет.setAttribute(имя, значение)задаёт атрибуту значение.removeAttribute(имя)удаляет атрибут.
У стандартных атрибутов есть одноимённые свойства: photo.alt, link.href, input.disabled. Через свойство удобно читать и менять значение без вызова методов.
Атрибуты, которые начинаются с data-, хранят произвольные данные для скриптов. Их значения доступны через объект dataset. Имя атрибута в dataset записывается без приставки data- и в camelCase: data-product-id превращается в dataset.productId.
Значения атрибутов всегда строки. Если в data-product-id записано число, из dataset.productId придёт строка '42'. Перед сравнением с числом её превращают в число: Number(card.dataset.productId).
Через data- атрибуты связывают элемент на странице с данными в JavaScript. Например, карточке товара ставят data-product-id, а при нажатии на кнопку в карточке по этому значению находят товар в массиве.
Почитать ещё
Дока:
getAttribute(),dataset, Data-атрибуты
Создание и удаление элементов
Новый элемент создают методом document.createElement, настраивают и добавляют на страницу.
<ul id="cart"></ul>
<button id="add" type="button">Добавить товар</button>
<button id="remove-first" type="button">Удалить первый</button>
<script>
const cart = document.querySelector('#cart');
let counter = 0;
document.querySelector('#add').addEventListener('click', () => {
counter++;
const item = document.createElement('li');
item.textContent = `Товар номер ${counter}`;
item.classList.add('cart__item');
cart.append(item);
});
document.querySelector('#remove-first').addEventListener('click', () => {
const first = cart.querySelector('li');
if (first) {
first.remove();
}
});
</script>
document.createElement(тег)создаёт элемент. Пока его не добавили в документ, на странице его нет.родитель.append(элемент)добавляет элемент в конец родителя,родитель.prepend(элемент)— в начало.элемент.before(другой)иэлемент.after(другой)вставляют элемент перед текущим или после него.элемент.remove()удаляет элемент со страницы.
Чтобы удалить всё содержимое элемента, ему присваивают пустую разметку: cart.innerHTML = ''.
Созданный через createElement элемент безопасен: текст, записанный в textContent, никогда не превратится в разметку. Поэтому этот способ подходит для вывода пользовательских данных.
Почитать ещё
Вывод списка из массива
Данные для страницы часто хранятся в массиве объектов. Чтобы показать их, для каждого объекта создают разметку и добавляют её на страницу.
Удобный способ — собрать разметку строкой через map и join и присвоить её innerHTML контейнера:
<section id="catalog" class="cards"></section>
<style>
.cards {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
}
.card {
padding: 12px;
border: 1px solid #d0d7de;
border-radius: 12px;
}
</style>
<script>
const products = [
{ id: 1, title: 'Laptop 14', price: 89990 },
{ id: 2, title: 'Наушники Studio', price: 12490 },
{ id: 3, title: 'Мышь Click', price: 2490 },
];
const catalog = document.querySelector('#catalog');
catalog.innerHTML = products
.map((product) => `
<article class="card" data-product-id="${product.id}">
<h3>${product.title}</h3>
<p>${product.price.toLocaleString('ru-RU')} ₽</p>
</article>
`)
.join('');
</script>
map превращает каждый товар в строку с разметкой карточки, а join('') склеивает строки в одну без разделителя. Без join браузер превратил бы массив в строку через запятые, и между карточками появились бы лишние запятые.
Этот способ короткий и наглядный, но у него есть условие безопасности из раздела про innerHTML: если в данных может оказаться текст от пользователя, пропускайте его через escapeHtml перед подстановкой в шаблон.
Если элементы списка нужно создавать по одному, например чтобы сразу навесить на них обработчики, используйте createElement и append в цикле for...of.
Почитать ещё
Дока:
innerHTML,.map()
События и обработчики
Событие — сигнал о том, что на странице что-то произошло: пользователь нажал на кнопку, ввёл символ в поле, отправил форму, нажал клавишу. Обработчик — функция, которую браузер вызывает, когда происходит событие.
Обработчик назначают методом addEventListener:
<button id="like" type="button">♡ В избранное</button>
<p id="likes">В избранном: 0</p>
<script>
const likeButton = document.querySelector('#like');
const likesText = document.querySelector('#likes');
let likes = 0;
function handleLike() {
likes++;
likesText.textContent = `В избранном: ${likes}`;
}
likeButton.addEventListener('click', handleLike);
</script>
Первый аргумент — тип события, второй — функция-обработчик. Функцию передают без круглых скобок: handleLike, а не handleLike(). Со скобками функция выполнится сразу, а в addEventListener попадёт её результат.
На одно событие элемента можно назначить несколько обработчиков, они выполнятся по очереди. Метод removeEventListener с теми же аргументами удаляет обработчик.
Событие | Когда происходит |
|---|---|
| Пользователь нажал на элемент мышью, пальцем или клавишей Enter на кнопке |
| Значение поля изменилось: после каждого введённого или удалённого символа |
| Пользователь закончил изменение: вышел из текстового поля, выбрал вариант в списке, переключил флажок |
| Форму отправили кнопкой или клавишей Enter. Событие происходит на |
| Пользователь нажал клавишу |
Событие click на кнопке происходит и при нажатии клавиши Enter или пробела, когда кнопка в фокусе. Поэтому для действий используют <button>, а не <div> с обработчиком: с кнопкой можно работать и без мыши.
Почитать ещё
Дока:
addEventListener(), События
Объект события
Браузер передаёт в обработчик объект события. В нём лежат подробности о том, что произошло.
<input id="search" type="text" placeholder="Начните печатать" />
<p id="typed"></p>
<button id="buy" type="button">Купить <strong>сейчас</strong></button>
<script>
const search = document.querySelector('#search');
const typed = document.querySelector('#typed');
search.addEventListener('input', (event) => {
typed.textContent = `Вы ввели: ${event.target.value}`;
});
search.addEventListener('keydown', (event) => {
if (event.key === 'Escape') {
search.value = '';
typed.textContent = '';
}
});
document.querySelector('#buy').addEventListener('click', (event) => {
console.log('target:', event.target.tagName);
console.log('currentTarget:', event.currentTarget.tagName);
});
</script>
Попробуйте ввести текст в поле, затем нажать Escape. После этого нажмите на слово «сейчас» внутри кнопки и посмотрите в консоль.
event.type— тип события, например'click'.event.target— элемент, на котором событие произошло. Если нажать на<strong>внутри кнопки,targetбудет<strong>.event.currentTarget— элемент, которому назначен обработчик. В примере это кнопка, куда бы внутри неё ни нажали.event.key— нажатая клавиша в событиях клавиатуры:'Enter','Escape','a'.event.preventDefault()отменяет действие браузера по умолчанию. Например, отменяет переход по ссылке или отправку формы.
У поля ввода текущее значение хранится в свойстве value, а у флажка состояние хранится в свойстве checked. Значение value всегда строка.
Почитать ещё
Дока: Объект события,
preventDefault(), Событиеinput
Отправка формы
Когда пользователь отправляет форму, браузер по умолчанию отправляет данные на адрес из action и загружает ответ как новую страницу. В приложении на JavaScript отправку перехватывают: отменяют действие по умолчанию и обрабатывают данные сами.
<form id="add-form">
<label for="title">Название товара</label>
<input id="title" name="title" type="text" required />
<label for="price">Цена</label>
<input id="price" name="price" type="number" min="1" required />
<button type="submit">Добавить</button>
</form>
<ul id="products"></ul>
<script>
const form = document.querySelector('#add-form');
const list = document.querySelector('#products');
form.addEventListener('submit', (event) => {
event.preventDefault();
const title = form.elements.title.value.trim();
const price = Number(form.elements.price.value);
const item = document.createElement('li');
item.textContent = `${title} — ${price} ₽`;
list.append(item);
form.reset();
});
</script>
Обработчик назначают на событие
submitформы, а не наclickкнопки. Тогда он сработает и при нажатии кнопки, и при нажатии Enter в поле.event.preventDefault()отменяет отправку формы браузером. Без этого вызова страница перезагрузится, и всё, что сделал скрипт, пропадёт.form.elementsсодержит поля формы по ихname:form.elements.title— поле сname="title".Значения полей — строки, поэтому цену превращают в число через
Number.form.reset()возвращает полям начальные значения.
Встроенная проверка полей работает и при перехвате отправки: событие submit не произойдёт, пока обязательные поля не заполнены.
Кнопка внутри формы без атрибута type считается кнопкой отправки. Если кнопка в форме должна делать что-то другое, например очищать поле, ей задают type="button".
Почитать ещё
Дока: Событие
submit, Работа с формами
Всплытие событий
Когда событие происходит на элементе, оно не останавливается на нём, а поднимается по дереву DOM ко всем родителям: от элемента к родителю, от родителя к его родителю и так до document. Это называется всплытием.
<section id="catalog">
<article class="card">
<h3>Laptop 14</h3>
<button type="button">В корзину</button>
</article>
</section>
<script>
document.querySelector('button').addEventListener('click', () => {
console.log('1. Обработчик кнопки');
});
document.querySelector('.card').addEventListener('click', () => {
console.log('2. Обработчик карточки');
});
document.querySelector('#catalog').addEventListener('click', (event) => {
console.log('3. Обработчик каталога, target:', event.target.tagName);
});
</script>
Нажмите на кнопку в предпросмотре: в консоли появятся три строки, потому что нажатие на кнопку — это одновременно нажатие на карточку и на каталог. Теперь нажмите на заголовок: сработают только обработчики карточки и каталога.
Во всех обработчиках event.target указывает на элемент, на котором событие произошло изначально. Поэтому обработчик на родителе может узнать, по какому именно дочернему элементу нажали.
Всплытие можно остановить вызовом event.stopPropagation() в обработчике: родители событие не получат. Используют это редко, потому что другие части страницы, которые ждут события, перестанут работать.
Не все события всплывают. Например, события фокуса focus и blur не всплывают. Для событий из таблицы в разделе про обработчики всплытие работает.
Почитать ещё
Делегирование событий
Всплытие позволяет назначить один обработчик на общего родителя вместо обработчика на каждый дочерний элемент. Этот приём называется делегированием.
<ul id="products">
<li data-product-id="1">Laptop 14 <button class="like" type="button">♡</button></li>
<li data-product-id="2">Наушники Studio <button class="like" type="button">♡</button></li>
<li data-product-id="3">Мышь Click <button class="like" type="button">♡</button></li>
</ul>
<button id="add" type="button">Добавить товар</button>
<script>
const list = document.querySelector('#products');
list.addEventListener('click', (event) => {
const likeButton = event.target.closest('.like');
if (!likeButton) {
return;
}
const item = likeButton.closest('li');
const id = Number(item.dataset.productId);
likeButton.textContent = likeButton.textContent === '♡' ? '♥' : '♡';
console.log('Нажали на товар с id', id);
});
document.querySelector('#add').addEventListener('click', () => {
const id = list.children.length + 1;
list.insertAdjacentHTML('beforeend', `<li data-product-id="${id}">Новый товар ${id} <button class="like" type="button">♡</button></li>`);
});
</script>
Нажмите «Добавить товар», а затем на сердечко у нового товара: оно тоже работает, хотя обработчик назначили до того, как товар появился.
Метод closest(селектор) ищет ближайший элемент, подходящий под селектор, начиная с самого элемента и поднимаясь по родителям. Если такого элемента нет, метод возвращает null. В обработчике он решает две задачи:
event.target.closest('.like')проверяет, нажали ли на кнопку-сердечко или на что-то внутри неё. Если нажали мимо, обработчик ничего не делает;likeButton.closest('li')находит элемент товара, в котором лежит нажатая кнопка, чтобы прочитать егоdata-product-id.
Метод insertAdjacentHTML('beforeend', разметка) в примере добавляет разметку в конец списка, не пересоздавая уже существующие элементы.
У делегирования два преимущества. Обработчик один, сколько бы элементов ни было в списке. И он работает для элементов, которые добавили позже, например после загрузки данных или перерисовки списка.
Почитать ещё
Дока:
closest()
Состояние и рендер
В примерах выше обработчики сами меняли элементы страницы: текст кнопки, счётчик, список. Пока действий мало, это удобно. Когда от одного действия зависят несколько мест страницы, легко забыть обновить одно из них, и данные на экране перестанут совпадать между собой.
Есть другой подход. Все данные хранят в одном месте, которое называют состоянием. Функция рендера строит страницу целиком из состояния. Обработчики не трогают страницу: они меняют состояние и вызывают рендер.
<p id="counter"></p>
<ul id="products"></ul>
<script>
let products = [
{ id: 1, title: 'Laptop 14', liked: false },
{ id: 2, title: 'Наушники Studio', liked: true },
{ id: 3, title: 'Мышь Click', liked: false },
];
const list = document.querySelector('#products');
const counter = document.querySelector('#counter');
function render() {
list.innerHTML = products
.map((product) => `
<li data-product-id="${product.id}">
${product.title}
<button class="like" type="button">${product.liked ? '♥ В избранном' : '♡ В избранное'}</button>
</li>
`)
.join('');
const likedCount = products.filter((product) => product.liked).length;
counter.textContent = `В избранном: ${likedCount}`;
}
function toggleLike(id) {
products = products.map((product) =>
product.id === id ? { ...product, liked: !product.liked } : product,
);
render();
}
list.addEventListener('click', (event) => {
const button = event.target.closest('.like');
if (button) {
toggleLike(Number(button.closest('li').dataset.productId));
}
});
render();
</script>
У кода три части:
Состояние — массив
products. Только в нём хранится информация о том, какие товары в избранном.Рендер — функция
render. Она не знает, что изменилось, и каждый раз рисует список и счётчик заново из состояния.Действия — функция
toggleLike. Она меняет состояние и вызываетrender.
Обработчик нажатия не меняет текст кнопки и не пересчитывает счётчик. Он только сообщает, какой товар нужно переключить. Текст кнопки и счётчик обновятся сами, потому что render построит их из нового состояния. Если позже появится фильтр «только избранное», его добавят в состояние и в render, а обработчики трогать не придётся.
Этот подход лежит в основе React. React тоже строит интерфейс из состояния, но умеет обновлять на странице только то, что изменилось, а не перерисовывать всё.
Почитать ещё
Дока: Реактивность
Обновление состояния без изменения старых данных
В функции toggleLike из прошлого раздела мы не написали product.liked = !product.liked. Вместо этого map создал новый массив, а для нужного товара — новый объект { ...product, liked: !product.liked }. Такое обновление называется иммутабельным: старые данные не меняются, а заменяются новыми.
Для трёх основных действий со списком есть привычные записи:
let products = [
{ id: 1, title: 'Laptop 14', price: 89990 },
{ id: 2, title: 'Наушники Studio', price: 12490 },
];
const original = products;
// Добавить товар
products = [...products, { id: 3, title: 'Мышь Click', price: 2490 }];
// Удалить товар
products = products.filter((product) => product.id !== 2);
// Изменить один товар
products = products.map((product) =>
product.id === 1 ? { ...product, price: 79990 } : product,
);
console.log(products);
console.log(original);
После всех действий в products новый список, а массив original остался таким же, каким был в начале.
Добавление через
[...products, новыйТовар]создаёт новый массив со старыми товарами и новым в конце.Удаление через
filterсоздаёт массив без товара с нужнымid.Изменение через
mapсоздаёт массив, в котором нужный товар заменён его изменённой копией, а остальные товары остались прежними объектами.
Методы push, splice и присваивание свойства меняют старые данные на месте. В коде на чистом JavaScript разницу пока не видно. Но во втором модуле React будет сравнивать старое и новое состояние по ссылке: если изменить старый массив на месте, ссылка не поменяется, и React не заметит изменений. Привычку создавать новые данные лучше выработать уже сейчас.
Почитать ещё
Дока: Spread-синтаксис