12k
All articles

Создание оглавления из заголовков на JavaScript

Создайте оглавление на JavaScript из заголовков: безопасные ID, доступная навигация и подсветка активного раздела через IntersectionObserver.

OpenReplay Team
OpenReplay Team
Создание оглавления из заголовков на JavaScript

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

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

Ключевые моменты

  • Используйте ограниченный селектор для выборки заголовков, чтобы не захватить посторонние заголовки из шапки, сайдбара или подвала.
  • Генерируйте безопасные и URL-совместимые ID, преобразуя текст заголовка в slug и отслеживая дубликаты с помощью счётчика.
  • Оборачивайте сгенерированный список в элемент <nav> с атрибутом aria-label, создавая доступный навигационный ориентир.
  • Используйте IntersectionObserver вместо обработчиков события прокрутки для эффективной подсветки активного раздела.
  • Включите плавную прокрутку одним CSS-правилом: scroll-behavior: smooth.

Как работает навигация по заголовкам DOM

Основная идея проста:

  1. Получить все элементы заголовков из DOM.
  2. Сгенерировать безопасный id для каждого заголовка.
  3. Построить список якорных ссылок, указывающих на эти ID.
  4. Вставить список в заданный контейнер.

Никакого парсинга сырого HTML регулярными выражениями. Никакого jQuery. Только querySelectorAll и стандартные DOM-методы.

Выбор и подготовка заголовков

const headings = document.querySelectorAll('article h2, article h3, article h4');

Ограничьте селектор областью контента — article, main или конкретным контейнером — чтобы случайно не захватить заголовки из шапки сайта или сайдбара.

Генерация безопасных якорных ID

Текст заголовков часто содержит пробелы, специальные символы или знаки препинания, из-за которых URL-фрагменты становятся менее читаемыми и неудобными для использования в селекторах. Нормализуйте id каждого заголовка перед использованием:

function slugify(text) {
  return text
    .toLowerCase()
    .trim()
    .replace(/\s+/g, '-')
    .replace(/[^\w-]/g, '') || 'section';
}

Обрабатывайте дубликаты, отслеживая использованные slug-и и добавляя счётчик:

const usedIds = new Map();

function uniqueSlug(text) {
  const base = slugify(text);
  const count = usedIds.get(base) ?? 0;
  usedIds.set(base, count + 1);
  return count === 0 ? base : `${base}-${count}`;
}

Пропускайте пустые заголовки или состоящие только из пробелов простой проверкой: if (!heading.textContent.trim()) return;.

Построение оглавления на JavaScript

После присвоения ID создайте список оглавления и вставьте его в страницу:

function buildTOC(containerSelector, headingSelector) {
  const container = document.querySelector(containerSelector);
  const headings = document.querySelectorAll(headingSelector);
  if (!container || !headings.length) return;

  const nav = document.createElement('nav');
  nav.setAttribute('aria-label', 'Table of contents');

  const ol = document.createElement('ol');

  headings.forEach(heading => {
    const text = heading.textContent.trim();
    if (!text) return;

    const id = heading.id || uniqueSlug(text);
    heading.id = id;

    const li = document.createElement('li');
    const a = document.createElement('a');
    a.href = `#${id}`;
    a.textContent = text;
    li.appendChild(a);
    ol.appendChild(li);
  });

  nav.appendChild(ol);
  container.appendChild(nav);
}

document.addEventListener('DOMContentLoaded', () => {
  buildTOC('#toc', 'article h2, article h3');
});

Использование DOMContentLoaded гарантирует, что заголовки уже существуют к моменту запуска скрипта.

Доступная генерация оглавления

Обёртывание списка в элемент <nav> с атрибутом aria-label="Table of contents" создаёт именованный навигационный ориентир. Программы чтения с экрана отображают такие ориентиры напрямую, позволяя пользователям перейти к оглавлению, не пробегая по странице с помощью Tab.

Сохраняйте логическую иерархию заголовков в контенте — не перескакивайте с h2 на h4. Оглавление отражает структуру вашего документа, поэтому пропуски в уровнях заголовков создают запутанную навигацию для всех пользователей.

Подсветка активного раздела с помощью IntersectionObserver

Для подсветки того, какой раздел сейчас находится в видимой области, используйте API IntersectionObserver, а не обработчики события прокрутки:

const observer = new IntersectionObserver(entries => {
  entries.forEach(entry => {
    const id = entry.target.getAttribute('id');
    const link = document.querySelector(`nav a[href="#${id}"]`);
    if (link) link.classList.toggle('active', entry.isIntersecting);
  });
}, { rootMargin: '0px 0px -80% 0px' });

document.querySelectorAll('article h2, article h3').forEach(h => observer.observe(h));

Параметр rootMargin сужает зону обнаружения, так что заголовок отмечается активным только когда он находится у верхнего края области просмотра. Этот подход производительнее, чем обработчики прокрутки с throttling, и для большинства статических страниц не требует очистки. IntersectionObserver поддерживается во всех современных браузерах.

Плавная прокрутка

Добавьте одно CSS-правило, чтобы включить плавную прокрутку по всей странице без какого-либо JavaScript:

html {
  scroll-behavior: smooth;
}

Свойство scroll-behavior поддерживается во всех современных evergreen-браузерах.

Заключение

Рабочему оглавлению на JavaScript нужны четыре вещи: ограниченный выбор заголовков, безопасная генерация ID без коллизий, семантическая обёртка <nav> для доступности и правильный момент запуска через DOMContentLoaded. Улучшение с IntersectionObserver опционально, но добавляет ощутимое улучшение UX при минимуме кода.

Этот шаблон работает на любом статическом сайте, странице документации или в блоге — без шага сборки, без зависимостей, без фреймворков.

Часто задаваемые вопросы

Стоит ли создавать оглавление на сервере или в браузере?

Работают оба подхода, но клиентская генерация проще и сохраняет логику в одном месте. Серверный рендеринг предпочтительнее для страниц, критичных к SEO, или когда JavaScript отключён, поскольку якорные ссылки и ID заголовков будут присутствовать в исходном HTML. Для большинства блогов и сайтов документации клиентской генерации с DOMContentLoaded достаточно.

Как обрабатывать заголовки, у которых уже есть атрибут id?

Проверяйте наличие существующего id перед тем, как генерировать новый. Если у заголовка вручную задан id, повторно используйте его, чтобы внешние ссылки и закладки продолжали работать. Добавьте условие: если heading.id есть — использовать существующее значение, иначе вызывать uniqueSlug. Это сохраняет намерение автора и предотвращает поломку входящих ссылок на конкретные разделы.

Почему стоит использовать IntersectionObserver вместо обработчиков события прокрутки?

События прокрутки срабатывают десятки раз в секунду и заставляют вычислять layout при каждом вызове, что снижает производительность, особенно на мобильных устройствах. IntersectionObserver отслеживает изменения видимости асинхронно и срабатывает только при реальном изменении видимости. Кроме того, проще декларативно настраивать пороги и отступы, не реализуя самостоятельно логику throttle или debounce.

Как создать вложенное оглавление, отражающее уровни заголовков?

Отслеживайте текущий уровень заголовка по мере итерации. Когда следующий заголовок глубже, создавайте вложенный ol внутри предыдущего li. Когда он более высокого уровня — поднимайтесь вверх по цепочке родителей. Хранение стека элементов списка, индексированных уровнем заголовка, сохраняет логику чистой и поддерживает произвольную глубину вложенности без жёсткого кодирования каждого уровня.

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — self-hosted, with full data ownership.

Star on GitHub

We use cookies to improve your experience. By using our site, you accept cookies.