Перейти к содержанию

IntersectionObserver: реагируем на конец каталога

В предыдущем уроке мы оформили каталог как собственный HTML-элемент. Теперь страница может получать его список, статус и маленький служебный элемент в конце. Используем эту последнюю точку: когда читатель дойдёт до конца карточек, покажем пояснение о следующем шаге. Ручная кнопка позволит раскрыть его раньше. Так мы получим осмысленное действие при пересечении области просмотра, не добавляя постоянный обработчик прокрутки.

Останутся четыре учебных курса и обычный отбор по теме. Подсказка не загружает данные, не отправляет статистику и не меняет выбранный курс. Маленького каталога вполне достаточно, чтобы разобрать договор наблюдения; изображать бесконечную ленту из четырёх записей было бы неудобно. Исходники находятся в advanced/lesson-37/ архива продолжения. Описанные ниже состояния являются ожидаемыми: учебный пример при подготовке не запускался.

Выбираем цель наблюдения

IntersectionObserver сообщает о геометрическом пересечении цели с заданной областью. Это отличается от утверждения, что человек прочитал содержимое: вкладка может быть неактивна, а другой элемент — перекрывать карточку. Для нашего пояснения достаточно того, что конец каталога достиг области просмотра. Для рекламного отчёта потребовался бы отдельный, значительно более строгий договор измерений. Основную модель пересечения описывает MDN.

В компоненте 36 служебный div создаётся один раз и сохраняется при повторном рисовании карточек. Его минимальная высота равна одному пикселю, а aria-hidden исключает пустую техническую точку из озвучивания. Наблюдаем именно этот узел. Если бы при каждом отборе мы заменяли весь компонент, старый наблюдатель продолжал бы хранить прежнюю цель. Поэтому свойство sentinelElement здесь является частью договора между компонентом и страницей.

В снимке 37 сохраняются catalog-element.js и catalog-controller.js из 36, а главный модуль заменяется. Общие данные и функции остаются в соседней папке shared. Это самостоятельный снимок, который нужно читать вместе с общими файлами архива. Добавляем новый модуль reading-hint.js; всю логику подсказки разместим в нём, чтобы её жизненный цикл был виден отдельно от отбора курсов.

Обычное содержимое до улучшения

Замените index.html целиком следующим документом:

<!doctype html>
<html lang="ru">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width,initial-scale=1">
  <title>Каталог: наблюдение за пересечением</title>
  <link rel="stylesheet" href="../style.css">
  <script type="module" src="./app.js"></script>
</head>
<body><main>
  <h1>Учебный каталог</h1>
  <div id="catalog-host"></div>
  <button type="button" id="show-next" hidden>Показать следующий шаг</button>
  <section id="next-step" aria-labelledby="next-title">
    <h2 id="next-title">Продолжаем изучение</h2>
    <p>Следующий опыт — наблюдение за размером контейнера.</p>
  </section>
  <button type="button" id="stop-observer">Отключить наблюдение</button>
</main></body>
</html>

Пояснение изначально присутствует в HTML и видно без JavaScript. Скрыта только дополнительная ручная кнопка. После подключения модуля программа сможет временно скрыть пояснение и раскрыть его по наблюдению. Такой порядок не ставит содержимое в зависимость от поддержки конкретного API: отсутствие улучшения оставляет обычный раздел страницы.

Кнопка отключения нужна для учебного опыта. Она позволит проверить смысл освобождения ресурсов без удаления всего каталога. В настоящей странице вызов освобождения обычно принадлежит владельцу компонента, когда тот меняет экран или убирает блок. Если жизненный цикл страницы не требует удаления, не нужно придумывать глобальные обработчики завершения только ради наличия метода.

Создаём наблюдатель и ручной путь

Полный reading-hint.js:

export function mountReadingHint(catalog, note, button) {
  const target = catalog.sentinelElement;
  if (!target || !('IntersectionObserver' in window)) {
    note.hidden = false;
    button.hidden = true;
    return () => {};
  }
  const lifetime = new AbortController();
  let shown = false;
  const observer = new IntersectionObserver(entries => {
    if (entries.some(entry => entry.target === target && entry.isIntersecting)) {
      showNote();
    }
  }, { root: null, rootMargin: '0px', threshold: 0 });
  function showNote() {
    if (shown) return;
    shown = true;
    note.hidden = false;
    button.hidden = true;
    observer.unobserve(target);
  }
  note.hidden = true;
  button.hidden = false;
  button.addEventListener('click', showNote, { signal: lifetime.signal });
  observer.observe(target);
  return function dispose() {
    observer.disconnect();
    lifetime.abort();
    note.hidden = false;
    button.hidden = true;
  };
}

Функция принимает три существующих узла. Сначала она получает сохранённую цель компонента. При отсутствии цели или API пояснение остаётся видимым, кнопка скрывается, а возвращаемая пустая функция позволяет вызывающему коду пользоваться одним договором освобождения. Нам не нужен второй набор обработчиков для неподдерживаемого браузера.

root: null выбирает область просмотра документа. Нулевой rootMargin сохраняет её границы, а threshold: 0 подходит для реакции на начало пересечения. В callback всё равно проверяется isIntersecting: первое уведомление может описывать цель за пределами области. Массив entries не следует считать единственной записью. Наш some явно ищет положительное уведомление именно для выбранной цели.

В showNote находится общее действие для наблюдателя и кнопки. Флаг shown делает его повторный вызов безопасным. Пояснение раскрывается один раз, после чего unobserve прекращает наблюдать эту цель. Она по-прежнему существует в каталоге, однако её последующие перемещения уже не интересуют страницу. В этом примере не требуется непрерывно сообщать, находится ли конец списка на экране.

Если каталог окажется внутри собственной прокручиваемой панели, область документа перестанет описывать нужное условие. Тогда в root передаётся соответствующий предок цели. Такой вариант стоит разбирать отдельно: появление конца внутри панели ещё не означает появление всей панели на экране. Правила выбора корня и расчёта пересечения закреплены в спецификации Intersection Observer; меняя корень, мы меняем смысл наблюдения.

AbortController управляет слушателем ручной кнопки. В dispose выполняются две разные операции: отключается наблюдатель и снимается слушатель. Затем возвращается обычное видимое содержимое. Одного disconnect было бы недостаточно для всего улучшения, поскольку он не управляет событиями кнопок. Флаг не сбрасываем: освобождение заканчивает этот экземпляр поведения; новое подключение создаст новый экземпляр функции.

Подключаем после появления узлов

Замените app.js целиком:

import './catalog-element.js';
import { courses } from '../shared/courses.js';
import { mountReadingHint } from './reading-hint.js';
const catalog = document.createElement('pw-course-catalog');
catalog.courses = courses;
document.querySelector('#catalog-host').append(catalog);
const dispose = mountReadingHint(
  catalog,
  document.querySelector('#next-step'),
  document.querySelector('#show-next')
);
document.querySelector('#stop-observer').addEventListener('click', dispose);

Сначала создаётся каталог, назначаются данные и выполняется вставка в документ. Благодаря connectedCallback к этому моменту существуют его внутренние узлы. Только затем вызывается mountReadingHint. Если переставить эти действия и попросить цель до подключения элемента, функция выберет обычный видимый вариант: она не должна угадывать, когда другой компонент закончит свою подготовку.

Кнопка отключения хранит полученную функцию dispose. Этот же способ можно использовать в коде владельца страницы: получить функцию при подключении и вызвать её перед удалением соответствующего блока. Наш учебный вариант не отслеживает произвольные удаления чужим кодом автоматически. Важно договориться, кто отвечает за освобождение, а не рассчитывать на случайную сборку памяти.

Разбираем ожидаемые состояния

При достаточно высокой области просмотра конец четырёх карточек может оказаться видимым сразу. Тогда пояснение раскроется без прокрутки — это правильное выполнение условия, а не преждевременная ошибка. При меньшей высоте оно остаётся скрытым до достижения конца. Ручная кнопка должна раскрыть его независимо от положения страницы; после этого она исчезнет, а дальнейшая прокрутка ничего не изменит.

Для самостоятельного опыта уменьшите высоту окна, выберите только publishing и примените отбор. Список станет короче, сохранённая цель переместится вверх, и условие пересечения может выполниться. Наблюдатель не сообщает об изменении темы как таковом: причиной реакции является новое геометрическое положение. Эта разница помогает разбирать неожиданные раскрытия без добавления лишних исключений в обработчик формы.

Затем попробуйте отключение до достижения конца. Пояснение должно стать обычным видимым разделом, а наблюдение — закончиться. Наконец, временно измените rootMargin на 0px 0px 160px 0px. Положительное расширение снизу позволит действию наступить раньше фактического появления цели. Это полезный отдельный опыт для предварительной подготовки данных, но уже другой договор: «приближается», а не «появилось».

Если задача заключается только в отложенной загрузке изображения, сначала рассмотрите штатный loading="lazy". Наблюдатель нужен тогда, когда требуется своё действие и собственные условия. В следующем уроке сохраним понятный каталог, но сменим источник сигнала: будем реагировать на размер его контейнера.

Оглавление курса