Создание оглавления из заголовков на JavaScript
Длинные страницы без навигации раздражают читателей. Они бесцельно прокручивают, теряют ориентир и уходят. Динамическое оглавление решает эту проблему, превращая существующую структуру заголовков в кликабельные якорные ссылки — автоматически, без библиотек и фреймворков.
В этой статье показано, как создать такое оглавление с помощью современного vanilla JavaScript, обработать типичные краевые случаи, обеспечить доступность генерируемого оглавления и при желании подсвечивать активный раздел по мере прокрутки.
Ключевые моменты
- Используйте ограниченный селектор для выборки заголовков, чтобы не захватить посторонние заголовки из шапки, сайдбара или подвала.
- Генерируйте безопасные и URL-совместимые ID, преобразуя текст заголовка в slug и отслеживая дубликаты с помощью счётчика.
- Оборачивайте сгенерированный список в элемент
<nav>с атрибутомaria-label, создавая доступный навигационный ориентир. - Используйте
IntersectionObserverвместо обработчиков события прокрутки для эффективной подсветки активного раздела. - Включите плавную прокрутку одним CSS-правилом:
scroll-behavior: smooth.
Как работает навигация по заголовкам DOM
Основная идея проста:
- Получить все элементы заголовков из DOM.
- Сгенерировать безопасный
idдля каждого заголовка. - Построить список якорных ссылок, указывающих на эти ID.
- Вставить список в заданный контейнер.
Никакого парсинга сырого 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;.
Discover how at OpenReplay.com.
Построение оглавления на 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.