Главная/Статьи/JavaScript: автоматическое оглавление статьи

JavaScript: автоматическое оглавление статьи

Генерация содержания на основе заголовков H1-H6. Якорные ссылки, нумерация, smooth scroll, подсветка активного пункта.

ДМ
Дмитрий Мещеряков
📅 15 июня 2020 г.📖 7 мин чтения

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

Разберём реализацию, где эти три случая обработаны, и почему подсветка сделана через IntersectionObserver, а не через обработчик прокрутки.

Задача

Автоматически генерировать оглавление статьи на основе заголовков. При клике — плавный скролл к нужному разделу, с подсветкой текущего пункта.

Базовая реализация

javascript
class TableOfContents {
    constructor(options = {}) {
        this.contentSelector = options.contentSelector || '.article-content';
        this.tocSelector = options.tocSelector || '.table-of-contents';
        this.headingSelector = options.headingSelector || 'h2, h3, h4';
        this.smoothScroll = options.smoothScroll ?? true;
        this.offset = options.offset || 80; // Отступ для fixed header
        
        this.headings = [];
        this.tocElement = null;
        
        this.init();
    }

    init() {
        const content = document.querySelector(this.contentSelector);
        this.tocElement = document.querySelector(this.tocSelector);
        
        if (!content || !this.tocElement) {
            return;
        }

        this.headings = Array.from(
            content.querySelectorAll(this.headingSelector)
        );

        if (this.headings.length === 0) {
            this.tocElement.style.display = 'none';
            return;
        }

        this.generateIds();
        this.renderToc();
        this.setupScrollSpy();
    }

    /**
     * Генерация ID для заголовков
     */
    generateIds() {
        const usedIds = new Set();

        this.headings.forEach((heading, index) => {
            if (!heading.id) {
                let baseId = this.slugify(heading.textContent);
                let id = baseId;
                let counter = 1;

                // Уникальность ID
                while (usedIds.has(id)) {
                    id = `${baseId}-${counter++}`;
                }

                heading.id = id;
            }
            
            usedIds.add(heading.id);
        });
    }

    /**
     * Преобразование текста в slug
     */
    slugify(text) {
        return text
            .toLowerCase()
            .trim()
            .replace(/[^\w\sа-яё-]/gi, '')
            .replace(/[\s_]+/g, '-')
            .replace(/^-+|-+$/g, '')
            .substring(0, 50) || `heading-${Date.now()}`;
    }

    /**
     * Рендер оглавления
     */
    renderToc() {
        const list = document.createElement('ul');
        list.className = 'toc-list';

        this.headings.forEach((heading, index) => {
            const item = document.createElement('li');
            const level = parseInt(heading.tagName[1]);
            
            item.className = `toc-item toc-item--level-${level}`;
            item.innerHTML = `
                <a href="#${heading.id}" class="toc-link" data-index="${index}">
                    ${heading.textContent}
                </a>
            `;

            list.appendChild(item);
        });

        this.tocElement.innerHTML = '';
        this.tocElement.appendChild(list);

        // Обработчики кликов
        this.tocElement.querySelectorAll('.toc-link').forEach(link => {
            link.addEventListener('click', (e) => this.handleClick(e));
        });
    }

    /**
     * Обработка клика по ссылке
     */
    handleClick(e) {
        if (!this.smoothScroll) return;

        e.preventDefault();
        const targetId = e.target.getAttribute('href').slice(1);
        const target = document.getElementById(targetId);

        if (target) {
            const top = target.getBoundingClientRect().top + window.scrollY - this.offset;
            
            window.scrollTo({
                top,
                behavior: 'smooth'
            });

            // Обновляем URL без перезагрузки
            history.pushState(null, '', `#${targetId}`);
        }
    }

    /**
     * Отслеживание скролла для подсветки активного пункта
     */
    setupScrollSpy() {
        const observer = new IntersectionObserver(
            (entries) => {
                entries.forEach(entry => {
                    const id = entry.target.id;
                    const link = this.tocElement.querySelector(`a[href="#${id}"]`);
                    
                    if (entry.isIntersecting) {
                        // Убираем активный класс со всех
                        this.tocElement.querySelectorAll('.toc-link').forEach(l => {
                            l.classList.remove('toc-link--active');
                        });
                        
                        // Добавляем активному
                        if (link) {
                            link.classList.add('toc-link--active');
                        }
                    }
                });
            },
            {
                rootMargin: `-${this.offset}px 0px -80% 0px`,
                threshold: 0
            }
        );

        this.headings.forEach(heading => observer.observe(heading));
    }
}

// Инициализация
document.addEventListener('DOMContentLoaded', () => {
    new TableOfContents({
        contentSelector: '.article-content',
        tocSelector: '.table-of-contents',
        headingSelector: 'h2, h3',
        offset: 100
    });
});

Почему IntersectionObserver, а не scroll

Классическая реализация подсветки активного пункта — обработчик на scroll, который на каждом событии перебирает заголовки и считает их позиции. Работает, но плохо: событие прокрутки срабатывает десятки раз в секунду, а getBoundingClientRect() внутри обработчика заставляет браузер пересчитывать раскладку. На мобильных это заметно как подтормаживающая прокрутка — то есть ровно там, где плавность важнее всего.

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

💡 Совет

Про генерацию идентификаторов. Два момента, которые ломают наивную реализацию якорей.

Повторяющиеся заголовки. «Итоги» в двух разделах дадут два одинаковых id, и обе ссылки уведут к первому. В коде выше это решено счётчиком с суффиксом.

Кириллица. Русский заголовок при транслитерации может дать пустую строку (если состоял из символов, которые правило не покрывает) — и вы получите id="", то есть неработающую ссылку. Запасной вариант вида heading-{index} при пустом результате обязателен.

И третий момент, о котором стоит помнить: идентификаторы попадают в адресную строку, а значит в закладки и во внешние ссылки. Меняя правило их формирования, вы ломаете все ранее сохранённые ссылки на разделы.

CSS стили

css
.table-of-contents {
    position: sticky;
    top: 100px;
    padding: 1.5rem;
    background: var(--bg-secondary);
    border-radius: 8px;
    max-height: calc(100vh - 140px);
    overflow-y: auto;
}

.toc-list {
    list-style: none;
    padding: 0;
    margin: 0;
}

.toc-item {
    margin: 0;
}

.toc-item--level-2 {
    padding-left: 0;
}

.toc-item--level-3 {
    padding-left: 1rem;
}

.toc-item--level-4 {
    padding-left: 2rem;
}

.toc-link {
    display: block;
    padding: 0.5rem 0;
    color: var(--text-muted);
    text-decoration: none;
    font-size: 0.875rem;
    line-height: 1.4;
    border-left: 2px solid transparent;
    padding-left: 0.75rem;
    transition: all 0.2s ease;
}

.toc-link:hover {
    color: var(--text-primary);
    border-left-color: var(--border);
}

.toc-link--active {
    color: var(--accent);
    border-left-color: var(--accent);
    font-weight: 500;
}

/* Плавная анимация при скролле */
html {
    scroll-behavior: smooth;
}

/* Компенсация fixed header при переходе по якорю */
:target::before {
    content: '';
    display: block;
    height: 100px;
    margin-top: -100px;
}

Расширенная версия с нумерацией

javascript
class NumberedTableOfContents extends TableOfContents {
    renderToc() {
        const list = document.createElement('ol');
        list.className = 'toc-list toc-list--numbered';

        const counters = { 2: 0, 3: 0, 4: 0 };
        
        this.headings.forEach((heading, index) => {
            const level = parseInt(heading.tagName[1]);
            
            // Сбрасываем счётчики нижних уровней
            for (let l = level + 1; l <= 4; l++) {
                counters[l] = 0;
            }
            
            counters[level]++;
            
            // Формируем номер (1.2.3)
            const number = Object.keys(counters)
                .filter(l => l <= level && counters[l] > 0)
                .map(l => counters[l])
                .join('.');

            const item = document.createElement('li');
            item.className = `toc-item toc-item--level-${level}`;
            item.innerHTML = `
                <a href="#${heading.id}" class="toc-link" data-index="${index}">
                    <span class="toc-number">${number}</span>
                    <span class="toc-text">${heading.textContent}</span>
                </a>
            `;

            list.appendChild(item);
        });

        this.tocElement.innerHTML = '<h4 class="toc-title">Содержание</h4>';
        this.tocElement.appendChild(list);

        this.tocElement.querySelectorAll('.toc-link').forEach(link => {
            link.addEventListener('click', (e) => this.handleClick(e));
        });
    }
}

HTML разметка

html
<article class="article">
    <header class="article-header">
        <h1>Заголовок статьи</h1>
    </header>
    
    <div class="article-layout">
        <aside class="article-sidebar">
            <nav class="table-of-contents" aria-label="Содержание статьи">
                <!-- Генерируется автоматически -->
            </nav>
        </aside>
        
        <div class="article-content">
            <h2>Первый раздел</h2>
            <p>Контент...</p>
            
            <h3>Подраздел 1.1</h3>
            <p>Контент...</p>
            
            <h2>Второй раздел</h2>
            <p>Контент...</p>
        </div>
    </div>
</article>

React-версия

tsx
import { useEffect, useState, useRef } from 'react';

interface Heading {
    id: string;
    text: string;
    level: number;
}

interface TableOfContentsProps {
    contentRef: React.RefObject<HTMLElement>;
    offset?: number;
}

export function TableOfContents({ contentRef, offset = 100 }: TableOfContentsProps) {
    const [headings, setHeadings] = useState<Heading[]>([]);
    const [activeId, setActiveId] = useState<string>('');

    useEffect(() => {
        if (!contentRef.current) return;

        const elements = contentRef.current.querySelectorAll('h2, h3, h4');
        const items: Heading[] = [];

        elements.forEach((el, i) => {
            if (!el.id) {
                el.id = `heading-${i}`;
            }
            items.push({
                id: el.id,
                text: el.textContent || '',
                level: parseInt(el.tagName[1]),
            });
        });

        setHeadings(items);
    }, [contentRef]);

    useEffect(() => {
        const observer = new IntersectionObserver(
            (entries) => {
                entries.forEach((entry) => {
                    if (entry.isIntersecting) {
                        setActiveId(entry.target.id);
                    }
                });
            },
            { rootMargin: `-${offset}px 0px -80% 0px` }
        );

        headings.forEach(({ id }) => {
            const el = document.getElementById(id);
            if (el) observer.observe(el);
        });

        return () => observer.disconnect();
    }, [headings, offset]);

    const handleClick = (e: React.MouseEvent<HTMLAnchorElement>, id: string) => {
        e.preventDefault();
        const el = document.getElementById(id);
        if (el) {
            const top = el.offsetTop - offset;
            window.scrollTo({ top, behavior: 'smooth' });
            history.pushState(null, '', `#${id}`);
        }
    };

    if (headings.length === 0) return null;

    return (
        <nav className="table-of-contents" aria-label="Table of contents">
            <h4 className="toc-title">Contents</h4>
            <ul className="toc-list">
                {headings.map(({ id, text, level }) => (
                    <li key={id} className={`toc-item toc-item--level-${level}`}>
                        <a
                            href={`#${id}`}
                            className={`toc-link ${activeId === id ? 'toc-link--active' : ''}`}
                            onClick={(e) => handleClick(e, id)}
                        >
                            {text}
                        </a>
                    </li>
                ))}
            </ul>
        </nav>
    );
}

Итоги

Генерируйте идентификаторы устойчиво: счётчик для дублей, запасной вариант для пустого результата транслитерации. Помните, что якоря — это публичные ссылки, и менять правило их формирования дорого.

Подсветку — через IntersectionObserver. Обработчик scroll с чтением геометрии портит плавность прокрутки на мобильных.

Учитывайте фиксированную шапку при прокрутке к якорю — иначе заголовок раздела окажется под ней. В коде это параметр offset; в CSS ту же задачу решает scroll-margin-top на заголовках, и это более надёжный способ, потому что работает и при переходе по прямой ссылке с якорем, минуя ваш JavaScript.

Не рисуйте оглавление, если заголовков меньше трёх. Содержание из двух пунктов занимает место и не помогает: проверка на количество — одна строка.

⚠️ Важно

Доступность здесь не формальность, а половина смысла компонента. Оглавление — навигация, и она обязана работать с клавиатуры: обернуть в <nav> с aria-label, использовать список и настоящие ссылки <a href="#...">, а не div с обработчиком клика. Отдельно про scroll-behavior: smooth — часть пользователей отключает анимации на уровне системы, и это стоит уважать через @media (prefers-reduced-motion: reduce): для них плавная прокрутка не украшение, а причина головокружения.

🚀

Хотите такое же решение?

Настрою окружение под ваш проект, учту специфику инфраструктуры и обучу команду.

Обсудить проект →
Бесплатная консультация · Ответ в течение дня

Комментарии

Система комментариев скоро будет подключена. А пока вы можете написать мне в Telegram или на email.