Back

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

Создание оглавления из заголовков на 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, повторно используйте его, чтобы внешние ссылки и закладки продолжали работать. Добавьте условие: если heading.id есть — использовать существующее значение, иначе вызывать uniqueSlug. Это сохраняет намерение автора и предотвращает поломку входящих ссылок на конкретные разделы.

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

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

Understand every bug

Uncover frustrations, understand bugs and fix slowdowns like never before with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay