Наблюдатель (observer) — это объект, который следит за состоянием определенного элемента и регистрирует происходящие в нем изменения. Элемент, который находится под наблюдением (чуть не написал «за которым организована слежка»), называется целевым. Наблюдатель может следить за состоянием как одного, так и нескольких элементов, а в некоторых случаях также и за потомками целевого элемента.
В JavaScript существует три основных вида наблюдателей:
- ResizeObserver
- IntersectionObserver
- MutationObserver
В данной статье я предлагаю сосредоточиться на практической реализации каждого наблюдателя.
Resize Observer
Назначение
Наблюдение за изменением размеров целевого элемента.
Теория
MDN
Моя статья на Хабре
Поддержка
Пример
В следующем примере мы наблюдаем за шириной контейнера с идентификатором «box». При ширине контейнера, меньшей 768px, мы меняем цвет фона контейнера и цвет текста (на противоположные с помощью «filter: invert(100%)»), уменьшаем размер шрифта заголовка и основного текста, а также скрываем дополнительную информацию.
Разметка выглядит следующим образом:
<div id="box" class="box">
<h1 id="title" class="title">Some Awesome Title</h1>
<p id="text" class="text">Some Main Text</p>
<span id="info" class="info">Some Secondary Info</span>
</div>
Стили:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
.box,
.title,
.text,
.info {
transition: 0.3s;
}
.box {
background: #ddd;
color: #222;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: space-between;
align-items: center;
}
.title,
.info {
margin: 1rem;
}
.title {
font-size: 2rem;
}
.text {
font-size: 1.25rem;
}
Скрипт:
// вспомогательная функция для изменения стилей
const changeStyles = (elements, properties, values) =>
elements.forEach((element, index) => {
element.style[properties[index]] = values[index];
});
// создаем экземпляр ResizeObserver
const observer = new ResizeObserver((entries) => {
// при каждом изменении размеров (ширины и высоты)
for (const entry of entries) {
// получаем ширину целевого элемента
const width = entry.contentRect.width;
// осуществляем проверку
// если ширина целевого элемента меньше 768px
// меняем соответствующие стили
if (width < 768) {
changeStyles(
[box, title, text, info],
["filter", "fontSize", "fontSize", "opacity"],
["invert(100%)", "1.5rem", "1rem", "0"]
);
} else {
// если ширина целевого элемента больше 768px
// восстанавливаем стили
changeStyles(
[box, title, text, info],
["filter", "fontSize", "fontSize", "opacity"],
["invert(0%)", "2rem", "1.25rem", "1"]
);
}
}
});
// устанавливаем наблюдение за контейнером с идентификатором "box"
observer.observe(box);
Песочница:
IntersectionObserver
Назначение
Наблюдение за пересечением целевого элемента с вышестоящим элементом или областью просмотра страницы (viewport).
Теория
MDN
Моя статья на Хабре
Поддержка
Пример
В следующем примере мы наблюдаем за всеми секциями (sections) на странице и записываем номер текущей секции (ее идентификатор) в локальное хранилище. Это делается для того, чтобы по возвращении пользователя на страницу прокрутить область просмотра до секции, на которой он остановился. Обратите внимание, что в примере реализована плавная прокрутка: на страницах с большим количеством информации прокрутку лучше делать моментальной.
Разметка:
<main id="main">
<section id="1" class="section">
<h3 class="title">First Section Title</h3>
<p class="text">
Lorem ipsum dolor sit amet, consectetur adipisicing elit. Ipsam nostrum ex delectus distinctio reprehenderit facere vitae beatae ab dolores, aliquam maiores officia mollitia unde et! Quaerat odit in minus dolor corrupti nemo nam beatae. Ex consequatur rem laborum necessitatibus omnis, soluta fuga maiores repellendus eveniet? Blanditiis quae officiis maiores vitae nobis in voluptate, dicta voluptas rerum. Et laudantium consequuntur vitae tenetur doloremque accusantium tempora quos magni repudiandae voluptatem perferendis velit reprehenderit laborum libero soluta quis id, quidem assumenda nihil obcaecati expedita, aliquam suscipit nesciunt facere. Voluptate rem perferendis ab iste? Maxime, earum quos! Modi, aut quis nihil quidem accusamus vero sunt debitis architecto soluta repellendus fugit suscipit aspernatur labore a est sit dolores in necessitatibus ea tenetur corporis. Exercitationem mollitia impedit qui nemo voluptate numquam perspiciatis repellendus repellat a odio fugit dolor ducimus labore ex veritatis pariatur aliquam enim distinctio libero doloremque saepe quaerat consectetur, ut sapiente. Laboriosam dignissimos iure praesentium modi ab perferendis at molestias maiores suscipit, expedita aut aperiam nam voluptates similique optio minus quam! Voluptas ullam sunt, a officiis accusamus adipisci sed saepe voluptatem minima maxime est assumenda cum quibusdam voluptates provident in quasi vitae. Corrupti voluptatibus laborum ipsum quia, cupiditate adipisci assumenda dolores sunt distinctio, recusandae nesciunt aliquid, explicabo ullam eligendi perspiciatis rerum architecto? Cumque numquam blanditiis, magnam delectus velit laudantium aliquid quibusdam excepturi vero nihil necessitatibus, sed officiis, molestias hic autem modi consequuntur iusto sapiente dolore. Voluptates tenetur provident eius distinctio iure rerum minima eum eaque. Ea autem, deleniti atque magnam eius modi dicta assumenda tempore ducimus molestias. Aperiam enim tenetur, hic blanditiis velit quod odio deserunt sequi quisquam dignissimos animi amet magnam excepturi dicta quidem error quis officia natus. Temporibus nobis dolores veritatis eius illo quas perspiciatis reiciendis dolorum optio, commodi, animi quos at! Amet praesentium totam ab error esse optio quo, quis iusto!
</p>
</section>
<section id="2" class="section">
<h3 class="title">Second Section Title</h3>
<p class="text">
...
</p>
</section>
<section id="3" class="section">
<h3 class="title">Third Section Title</h3>
<p class="text">
...
</p>
</section>
</main>
Стили:
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #eee;
color: #222;
text-align: center;
}
main {
max-width: 768px;
margin: auto;
}
.section {
padding: 1rem;
}
.title {
font-size: 1.5rem;
margin: 1rem;
}
.text {
font-size: 1.25rem;
}
Скрипт:
// функция нахождения последней секции, которую просматривал пользователь
const findLastSection = () => {
// получаем номер секции из локального хранилища
// номер секции - это ее идентификатор
const number = localStorage.getItem("numberOfSection") || 1;
// находим нужную секцию
const section = document.getElementById(number);
// определяем отступ от верхнего края всей страницы (не области просмотра)
const position = Math.round(section.offsetTop);
// прокручиваем область просмотра до нужной позиции
scrollTo({
top: position,
// плавно
behavior: "smooth",
});
};
findLastSection();
// функция создания наблюдателя и установки наблюдения
const createObserver = () => {
// создаем экземпляр IntersectionObserver
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
// если целевой элемент находится в зоне видимости
if (entry.isIntersecting) {
// записываем его идентификатор в локальное хранилище
localStorage.setItem("numberOfSection", entry.target.id);
}
});
},
{
// процент пересечения целевого элемента с областью просмотра
// 10%
threshold: 0.1,
}
);
// находим все секции
const sections = main.querySelectorAll("section");
// начинаем за ними наблюдать
sections.forEach((section) => observer.observe(section));
};
createObserver();
Песочница:
MutationObserver
Назначение
Наблюдение за изменением атрибутов, текстового содержимого целевого элемента и его потомков. Пожалуй, с точки зрения функционала, это самый интересный из рассматриваемых нами наблюдателей.
Теория
MDN
Современный учебник по JavaScript
Поддержка
Пример
В следующем примере мы реализуем простую «тудушку», в которой наблюдатель следит за количеством задач в списке. По своему функционалу наш наблюдатель будет похож на «useEffect» в React.js или «watch» в Vue.js.
Разметка:
<div id="box" class="box"></div>
Стили:
@import url("https://fonts.googleapis.com/css2?family=Stylish&display=swap");
* {
margin: 0;
padding: 0;
box-sizing: border-box;
font-family: stylish;
font-size: 1rem;
color: #222;
}
.box {
max-width: 512px;
margin: auto;
text-align: center;
}
.counter {
font-size: 2.25rem;
margin: 0.75rem;
}
.form {
display: flex;
margin-bottom: 0.25rem;
}
.input {
flex-grow: 1;
border: none;
border-radius: 4px;
box-shadow: 0 0 1px inset #222;
text-align: center;
font-size: 1.15rem;
margin: 0.5rem 0.25rem;
}
.input:focus {
outline-color: #5bc0de;
}
.btn {
border: none;
outline: none;
background: #337ab7;
padding: 0.5rem 1rem;
border-radius: 4px;
box-shadow: 0 1px 1px rgba(0, 0, 0, 0.5);
color: #eee;
margin: 0.5rem 0.25rem;
cursor: pointer;
user-select: none;
width: 92px;
text-shadow: 0 0 1px rgba(0, 0, 0, 0.5);
}
.btn:active {
box-shadow: 0 0 1px rgba(0, 0, 0, 0.5) inset;
}
.btn.danger {
background: #d9534f;
}
.list {
list-style: none;
}
.item {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
align-items: center;
}
.item + .item {
border-top: 1px dashed rgba(0, 0, 0, 0.5);
}
.text {
flex: 1;
font-size: 1.15rem;
margin: 0.5rem;
padding: 0.5rem;
background: #eee;
border-radius: 4px;
}
Скрипт:
// задачи
const todos = [
{
id: "1",
text: "Learn HTML",
},
{
id: "2",
text: "Learn CSS",
},
{
id: "3",
text: "Learn JavaScript",
},
{
id: "4",
text: "Stay alive",
},
];
// шаблон элемента списка
const Item = (todo) => `
<li
class="item"
id="${todo.id}"
>
<span class="text"}">
${todo.text}
</span>
<button
class="btn danger"
data-type="delete"
>
Delete
</button>
</li>
`;
// общий шаблон
const Template = `
<form id="form" class="form">
<input
type="text"
class="input"
id="input"
>
<button
class="btn"
data-type="add"
>
Add
</button>
</form>
<ul id="list" class="list">
${todos.reduce(
(html, todo) =>
(html += `
${Item(todo)}
`),
""
)}
</ul>
`;
// оборачиваем код в IIFE
(() => {
// вставляем шаблон в контейнер с идентификатором "box"
box.innerHTML = `
<h1 id="counter" class="counter">
${todos.length} todo(s) left
</h1>
${Template}
`;
// фокусируемся на поле для ввода текста
input.focus();
// создаем экземпляр MutationObserver
const observer = new MutationObserver(() => {
// получаем количество задач в списке
const count = todos.length;
// сообщаем либо о том, сколько задач осталось, либо о том, что задач не осталось
counter.textContent = `
${count > 0 ? `${count} todo(s) left` : "There are no todos"}
`;
});
// начинаем наблюдать за списком и его дочерними элементами
observer.observe(list, {
childList: true,
});
// функция добавления новой задачи в список
const addTodo = () => {
if (!input.value.trim()) return;
const todo = {
id: Date.now().toString().slice(-4),
text: input.value,
};
list.insertAdjacentHTML("beforeend", Item(todo));
todos.push(todo);
input.value = "";
input.focus();
};
// функция удаления задачи из списка
const deleteTodo = (item) => {
const index = todos.findIndex((todo) => todo.id === item.id);
item.remove();
todos.splice(index, 1);
};
// отключаем обработку отправки формы браузером
form.onsubmit = (e) => e.preventDefault();
// обрабатываем нажатие кнопок
box.addEventListener("click", (e) => {
if (e.target.tagName !== "BUTTON") return;
// получаем тип кнопки и элемент списка
const { type } = e.target.dataset;
const item = e.target.parentElement;
// в зависимости от типа кнопки вызываем ту или иную функцию
switch (type) {
case "add":
addTodo();
break;
default:
deleteTodo(item);
break;
}
});
})();
Песочница:
Надеюсь, вы нашли для себя что-нибудь интересное. Благодарю за внимание.