Создание оглавления статей на сайте с помощью JavaScript

В этой статье создадим простой скрипт на JavaScript, который будет при выполнении автоматически генерировать оглавления статей на сайте.
Создание оглавления из заголовков h2
Процесс написания скрипта для формирования оглавления из тегов <h2>
представим в виде следующих шагов:
1. Создадим HTML обёртку для оглавления и присвоим её переменной tpl
:
const tpl = '<section class="table-of-contents"><div style="font-weight: bold;">Содержание:</div><ul>{{contents}}</ul></section>';
Плейсхолдер {{contents}}
впоследствии заменим на HTML код оглавления.
2. Объявим переменную contents
с помощью ключевого слова let
и присвоим ей в качестве значения пустую строку:
let contents = '';
В эту переменную мы будем собирать HTML код оглавления.
3. Создадим переменную elHeaders
и поместим в неё все найденные теги <h2>
в элементе с классом article
:
const elHeaders = document.querySelectorAll('.article h2');
Для выбора элементов используется метод querySelectorAll()
.
4. Сформируем HTML код оглавления на основе <h2>
:
elHeaders.forEach((el, index) => {
if (!el.id) {
el.id = `id-${index}`;
}
const url = `${location.href.split('#')[0]}#${el.id}`;
contents += `<li><a href="${url}">${el.textContent}</a></li>`;
});
В этом коде для перебора выбранных <h2>
используется метод forEach()
. Внутри forEach()
мы сначала устанавливаем заголовку <h2>
значение атрибута id
, если у него его конечно нет. После этого формируем URL и сохраняем его в переменную url
. Затем добавляем к значению переменной contents
элемент оглавления.
5. Вставим HTML содержимое оглавления внутрь <aside>
перед первым элементом с помощью метода insertAdjacentHTML
:
document.querySelector('aside').insertAdjacentHTML('afterbegin', tpl.replace('{{contents}}', contents));
Конечный HTML получим взяв значение переменной tpl
, в которой {{contents}}
заменим на содержимое переменной contents
.
В итоге мы получим следующий код на чистом JavaScript для автоматической генерации содержания статей:
const tpl = '<section class="table-of-contents"><div style="font-weight: bold;">Содержание:</div><ul>{{contents}}</ul></section>';
let contents = '';
const elHeaders = document.querySelectorAll('.article h2');
elHeaders.forEach((el, index) => {
if (!el.id) {
el.id = `id-${index}`;
}
const url = `${location.href.split('#')[0]}#${el.id}`;
contents += `<li><a href="${url}">${el.textContent}</a></li>`;
});
document.querySelector('aside').insertAdjacentHTML('afterbegin', tpl.replace('{{contents}}', contents));

Подсветка пунктов оглавления по мере прокрутки страницы
В предыдущий скрипт дополнительно добавим ещё функционал для выделения активного элемента содержания, т.е. пункта, показывающего пользователю, где он сейчас находится на странице.
Отметку активного пункта в оглавлении будем выполнять посредством добавления класса active
.
Код на чистом JavaScript:
window.addEventListener('scroll', () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elHeaders = document.querySelectorAll('h2, h3, h4');
let headerId = '';
for (let i = elHeaders.length - 1; i >= 0; i--) {
if (elHeaders[i].getBoundingClientRect().top + window.pageYOffset - 200 < scrollTop) {
headerId = elHeaders[i].id;
break;
}
}
document.querySelectorAll('.table-of-contents li.active').forEach(el => {
el.classList.remove('active');
});
if (headerId) {
document.querySelector(`a[href$="#${headerId}"]`).parentElement.classList.add('active');
}
});

В этом коде определение активного пункта по мере прокрутки страницы основано на обработке события scroll
.
В обработчике мы сначала получаем число пикселей, на которые пользователь прокрутил текущий документ и сохраняем это значение в переменную scrollTop
. На следующей строке получаем все заголовки <h2>
, расположенные в элементе с классом article
.
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elHeaders = document.querySelectorAll('h2, h3, h4');
После этого объявляется переменная headerId
и присваивается ей пустая строка. В эту переменную будет сохраняться значение атрибута id
тега <h2>
, который в данный момент изучает пользователь:
let headerId = '';
Далее с помощью цикла for
перебираются заголовки и высчитывается среди них тот в котором сейчас находится пользователь. Значение id
найденного <h2>
сохраняется в переменную headerId
:
for (let i = elHeaders.length - 1; i >= 0; i--) {
if (elHeaders[i].getBoundingClientRect().top + window.pageYOffset - 200 < scrollTop) {
headerId = elHeaders[i].id;
break;
}
}
Затем у элементов с классом active
удаляется этот класс:
document.querySelectorAll('.table-of-contents li.active').forEach(el => {
el.classList.remove('active');
});
Завершаем код установкой класса active
элементу <li>
. Для этого выбирается <a>
с атрибутом href
значение которого заканчивается на #${headerId}
. После этого получаем родительский элемент и устанавливаем ему класс active
:
document.querySelector(`a[href$="#${headerId}"]`).parentElement.classList.add('active');
Скрипт для создания многоуровневого оглавления
Изменим скрипт, приведённый выше, для генерирования многоуровневого меню на основе тегов <h2>
, <h3>
и <h4>
:
const headers = [];
const indexes = [0];
// функция для получения предыдущего header
const getPrevHeader = (diff = 0) => {
if ((indexes.length - diff) === 0) {
return null;
}
let header = headers[indexes[0]];
for (let i = 1, length = indexes.length - diff; i < length; i++) {
header = header.contains[indexes[i]];
}
return header;
}
// функция для добавления item в headers
const addItemToHeaders = (el, diff) => {
let header = headers;
if (diff === 0) {
header = indexes.length > 1 ? getPrevHeader(1).contains : header;
indexes.length > 1 ? indexes[indexes.length - 1]++ : indexes[0]++;
} else if (diff > 0) {
header = getPrevHeader().contains;
indexes.push(0);
} else if (diff < 0) {
const parentHeader = getPrevHeader(Math.abs(diff) + 1);
for (let i = 0; i < Math.abs(diff); i++) {
indexes.pop();
}
header = parentHeader ? parentHeader.contains : header;
parentHeader ? indexes[indexes.length - 1]++ : indexes[0]++;
}
header.push({ el, contains: [] });
}
// добавим заголовки в headers
document.querySelectorAll('h2, h3, h4').forEach((el, index) => {
if (!el.id) {
el.id = `id-${index}`;
}
if (!index) {
addItemToHeaders(el);
return;
}
const diff = el.tagName.substring(1) - getPrevHeader().el.tagName.substring(1);
addItemToHeaders(el, diff);
});
// сформируем оглавление страницы для вставки его на страницу
let html = '';
const createTableOfContents = (items) => {
html += '<ol>';
for (let i = 0, length = items.length; i < length; i++) {
const url = `${location.href.split('#')[0]}#${items[i].el.id}`;
html += `<li><a href="${url}">${items[i].el.textContent}</a>`;
if (items[i].contains.length) {
createTableOfContents(items[i].contains);
}
html += '</li>';
}
html += '</ol>';
}
createTableOfContents(headers);
html = `<section class="table-of-contents"><div style="font-weight: bold;">Содержание:</div>${html}</section>`;
// вставим оглавление в тег <aside>
document.querySelector('aside').insertAdjacentHTML('afterbegin', html);

Многоуровневое меню с выделением активных пунктов
Для выделения оглавления при нахождении пользователя в соответствующем разделе необходимо в скрипт добавить ещё следующий код:
window.addEventListener('scroll', () => {
const scrollTop = window.pageYOffset || document.documentElement.scrollTop;
const elHeaders = document.querySelectorAll('h2, h3, h4');
let headerId = '';
for (let i = elHeaders.length - 1; i >= 0; i--) {
if (elHeaders[i].getBoundingClientRect().top + window.pageYOffset - 200 < scrollTop) {
headerId = elHeaders[i].id;
break;
}
}
document.querySelectorAll('.table-of-contents a.active').forEach(el => {
el.classList.remove('active');
});
if (headerId) {
document.querySelector(`a[href$="#${headerId}"]`).classList.add('active');
}
});

Но хорошо бы доработать штуку так, что бы в нем уже был и мягкий скролл и может там подсветка какая-то пунктов прикрепляемого меню)))