Е

Техническое руководство проекта secserpr.ru

Как собирался сайт — по шагам, с кодом и пояснениями
← На сайт
Руководство › Часть III. Скрипты › 09. script.js

09. script.js — три модуля

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

Структура файла

Три модуля script.js
script.js
  |
  +-- МОДУЛЬ 1. Бургер-меню
  |     Открывает и закрывает навигацию на мобильных
  |
  +-- МОДУЛЬ 2. Автоопределение активной ссылки
  |     Подсвечивает текущий раздел в меню
  |
  +-- МОДУЛЬ 3. Кнопки «Скачать» / «Распечатать»
        Обрабатывает кнопки в шаблоне статьи блога

Каждый модуль изолирован. Если убрать один — остальные продолжат работать. Это осознанное решение: модульность. Проще поддерживать, проще искать ошибки.

Почему в одном файле, а не в трёх

Могли бы сделать burger.js, active-link.js, tool-buttons.js — и подключать все три на каждой странице. Но это лишние HTTP-запросы, а модулей немного. Пока файл меньше 100 строк — дешевле держать всё вместе.

Общий каркас

Все три модуля построены по одному шаблону:

JavaScript — общий шаблон
document.addEventListener('DOMContentLoaded', function () {
  // ... код модуля ...
});

document — объект страницы. Через него получаем доступ ко всему: элементам, событиям, стилям.

addEventListener('DOMContentLoaded', …) — «подпишись на событие». Событие называется DOMContentLoaded — «DOM-дерево построено». Это значит: HTML-страница загружена, браузер разобрал все теги и создал объекты, к которым можно обращаться.

Порядок событий при загрузке
1. Начало загрузки HTML
2. Браузер читает теги и строит DOM
3. DOM готов  -->  срабатывает DOMContentLoaded
4. Браузер догружает картинки, шрифты, стили
5. Полная загрузка --> срабатывает window.load

Мы подписываемся именно на DOMContentLoaded, а не на window.load. Причина: DOM готов раньше, чем картинки и шрифты. Если подождать до window.load, пользователь увидит «задержку» перед тем, как заработает бургер.

function () { … } — анонимная функция-обработчик. Она выполнится, когда сработает событие. Внутри неё живёт весь код модуля.

Модуль 1: бургер-меню

Отвечает за открытие и закрытие навигации на мобильных. Пользователь тапает по бургеру — меню выезжает. Тапает ещё раз — меню прячется.

JavaScript — модуль бургера
document.addEventListener('DOMContentLoaded', function () {
  const burger = document.querySelector('.burger');
  const nav = document.querySelector('.nav');
  if (burger && nav) {
    burger.addEventListener('click', function () {
      burger.classList.toggle('active');
      nav.classList.toggle('open');
    });
  }
});

Разберём построчно.

Поиск элементов

JavaScript
const burger = document.querySelector('.burger');
const nav = document.querySelector('.nav');

document.querySelector('.burger') — «найди первый элемент с классом burger». Точка в начале строки означает класс. Если бы искали по ID — писали бы '#header'.

const — «константа». Значение переменной не изменится после присвоения. Мы сохранили ссылку на элемент — она же сама и не поменяется. const защищает от случайного переприсвоения.

Почему querySelector, а не getElementByClassName

Есть старые методы: getElementById, getElementsByClassName, getElementsByTagName. Работают и они. Но querySelector универсальнее: использует те же селекторы, что и CSS. Хочешь найти .header .icon? Просто пишешь querySelector('.header .icon'). Не надо запоминать другие правила.

Защита от отсутствия элементов

JavaScript
if (burger && nav) {
  // ...
}

if (burger && nav) — «если и бургер, и nav существуют». Логический оператор && («и») требует, чтобы оба условия были истинными.

В чём смысл? Элемент .burger есть на всех страницах — он подключается через partials/header.html. Но что, если однажды кто-то создаст страницу без этого partial? Тогда querySelector вернёт null. А вызов null.addEventListener — ошибка в консоли, и все остальные скрипты упадут.

Проверка защищает: если элементов нет — тихо выходим из модуля, ничего не ломая.

Обработчик клика

JavaScript
burger.addEventListener('click', function () {
  burger.classList.toggle('active');
  nav.classList.toggle('open');
});

addEventListener('click', …) — подписаться на клик. Когда пользователь кликает по бургеру, сработает функция.

classList.toggle('active') — «переключи класс active». Если его не было — добавит. Если был — уберёт.

Одновременно переключаются два класса:

Схема переключения
Клик по бургеру
     |
     +--> burger.classList.toggle('active')
     |      Если был "active" - убирает
     |      Если не было - добавляет
     |
     +--> nav.classList.toggle('open')
            То же самое, но для меню

Состояние «открыто»             Состояние «закрыто»
--------------------            ------------------
burger: class="burger active"    burger: class="burger"
nav:    class="nav open"         nav:    class="nav"

Модуль 2: активная ссылка в меню

Подсвечивает ту ссылку, которая соответствует текущей странице. Пользователь на «Услугах» — в меню подсвечено «Услуги» зелёной линией.

JavaScript — модуль активной ссылки
document.addEventListener('DOMContentLoaded', function () {
  const links = document.querySelectorAll('.nav a');
  if (!links.length) return;

  let current = window.location.pathname
    .replace(/\/index\.html$/, '/')
    .replace(/\/$/, '');
  if (current === '') current = '/';

  links.forEach(function (link) {
    let href = link.getAttribute('href')
      .replace(/\/index\.html$/, '/')
      .replace(/\/$/, '');
    if (href === '') href = '/';
    if (href === current) link.classList.add('active');
  });
});

Находим все ссылки в меню

JavaScript
const links = document.querySelectorAll('.nav a');
if (!links.length) return;

querySelectorAll (с «All» на конце) возвращает все элементы, подходящие под селектор. Здесь — все a внутри .nav.

Результат — не массив, а NodeList. Список узлов. У него есть свои особенности, но метод forEach работает, как у массива.

if (!links.length) return; — «если длина списка 0, выйти». Проверка на защиту от отсутствия меню на странице. Восклицательный знак ! — «не». Читается: «если НЕ длина — то есть если длина нулевая — выходим».

Определяем текущий путь

JavaScript
let current = window.location.pathname
  .replace(/\/index\.html$/, '/')
  .replace(/\/$/, '');
if (current === '') current = '/';

Три строки, которые выглядят сложно, но делают простую вещь: приводят текущий URL к «нормальному виду».

window.location.pathname — часть URL после домена. Например:

Примеры pathname
URL                                     pathname
---------------------------------       ------------------
https://secserpr.ru/                    /
https://secserpr.ru/index.html          /index.html
https://secserpr.ru/services.html       /services.html
https://secserpr.ru/blog/               /blog/

Проблема: если пользователь открыл /index.html, а ссылка в меню указывает на / — они не совпадут. Но по сути это одна и та же страница. То же с /services.html и /services.html/.

Решение: убрать из URL то, что не влияет на смысл. Что делает код:

/…/ — это регулярное выражение. Способ искать и заменять по шаблону. \/ — слэш (экранирован), $ — конец строки.

После всех замен: и /index.html, и / приведутся к пустой строке. А if (current === '') current = '/'; вернёт туда слэш для единообразия.

Пример нормализации

URL: https://secserpr.ru/index.html → pathname /index.html → после замен / → пустая строка → /.
URL: https://secserpr.ru/ → pathname / → после замен пустая строка → /.
URL: https://secserpr.ru/services.html → pathname /services.html → не меняется.
Итог: обе формы главной приводятся к одному виду — /.

Перебор ссылок и сравнение

JavaScript
links.forEach(function (link) {
  let href = link.getAttribute('href')
    .replace(/\/index\.html$/, '/')
    .replace(/\/$/, '');
  if (href === '') href = '/';
  if (href === current) link.classList.add('active');
});

links.forEach(…) — «пробеги по всем элементам списка и выполни для каждого функцию».

Для каждой ссылки повторяем ту же нормализацию, что и для URL. Приводим href к «чистому» виду: убираем /index.html и завершающий слэш.

link.getAttribute('href') — читает атрибут href из HTML. То же самое, что link.href, но без автоматической подстановки домена. Нам нужна именно «сырая» строка из кода.

И финальная проверка: if (href === current) link.classList.add('active'). Если нормализованные значения совпали — добавляем ссылке класс active. CSS тут же подсветит её зелёной линией.

Пример на странице /services.html
current = "/services.html"

Перебор ссылок:
  "/index.html"     -->  "/"               не совпало
  "/services.html"  -->  "/services.html"  СОВПАЛО - active
  "/about.html"     -->  "/about.html"     не совпало
  ...

Итог: подсветится только ссылка «Услуги»

Модуль 3: кнопки «Скачать» и «Распечатать»

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

JavaScript — модуль 3
document.addEventListener('click', function (e) {
  const btn = e.target.closest('.tool-btn');
  if (!btn) return;

  const action = btn.dataset.action;
  const postId = btn.dataset.post || 'unknown';

  if (action === 'print') {
    window.print();
    return;
  }

  if (action === 'download') {
    const article = document.querySelector('.article-full');
    if (!article) {
      alert('Не найден блок статьи для скачивания.');
      return;
    }
    if (typeof html2pdf === 'undefined') {
      alert('Библиотека для создания PDF не загружена.');
      return;
    }
    const opt = {
      margin: 1,
      filename: postId + '.pdf',
      image: { type: 'jpeg', quality: 0.98 },
      html2canvas: { scale: 2, letterRendering: true, useCORS: true },
      jsPDF: { unit: 'in', format: 'a4', orientation: 'portrait' }
    };
    html2pdf().set(opt).from(article).save();
  }
});

Модуль принципиально отличается от первых двух. Разбираем ключевые отличия.

Слушаем не кнопку, а документ

JavaScript
document.addEventListener('click', function (e) {

Мы подписываемся на клик по всему документу, а не по конкретной кнопке. Причина — делегирование событий.

В шаблоне статьи может быть сколько угодно статей. Нельзя заранее знать, сколько на странице кнопок .tool-btn. Делегирование решает эту проблему: слушаем «глобально», а внутри проверяем, попал ли клик в нужный элемент.

Что такое делегирование событий

Клик по кнопке «всплывает» — сначала событие происходит на кнопке, потом на её родителе, потом на деде, и так до document. Мы ловим его на верхнем уровне и проверяем: «а клик был по нужной кнопке?». Если да — обрабатываем.

Параметр e — объект события. Внутри него лежит вся информация о клике: куда попал, какую кнопку нажали, координаты.

Поиск «целевой» кнопки

JavaScript
const btn = e.target.closest('.tool-btn');
if (!btn) return;

e.target — элемент, по которому реально кликнули. Может быть самой кнопкой, а может — SVG-иконкой внутри неё или её строкой SVG.

.closest('.tool-btn') — «найди ближайшего предка с классом tool-btn, включая сам элемент». Если кликнули по иконке внутри кнопки — closest поднимется до кнопки. Если кликнули мимо — вернёт null.

Как работает .closest()
Ситуация 1: клик по иконке внутри кнопки

  button class="tool-btn" data-action="print"
    svg ...                  <-- e.target (кликнули сюда)
  /button                     <-- .closest('.tool-btn') найдёт это

Ситуация 2: клик по тексту абзаца

  p Текст статьи ...         <-- e.target
                              <-- .closest('.tool-btn') = null
                              выходим

Проверка if (!btn) return; — если кнопка не найдена, выходим. Не обрабатываем клики мимо.

Чтение data-атрибутов

JavaScript
const action = btn.dataset.action;
const postId = btn.dataset.post || 'unknown';

btn.dataset — доступ ко всем data-* атрибутам элемента. Если в HTML написано <button data-action="print">, то через JS это btn.dataset.action === "print".

btn.dataset.post || 'unknown' — если у кнопки нет атрибута data-post, btn.dataset.post вернёт undefined. Логический оператор || («или») даст второе значение — строку 'unknown'. Это «запасное имя файла», если основной не задан.

Ветка «Печать»

JavaScript
if (action === 'print') {
  window.print();
  return;
}

Простейший случай. Если кнопка помечена как «печать» — вызываем встроенный метод браузера window.print(). Он открывает системный диалог печати — так же, как Ctrl+P.

return — «выйти из функции». После печати дальше идти не нужно.

Ветка «Скачать PDF»

JavaScript
const article = document.querySelector('.article-full');
if (!article) {
  alert('Не найден блок статьи для скачивания.');
  return;
}
if (typeof html2pdf === 'undefined') {
  alert('Библиотека для создания PDF не загружена.');
  return;
}

Две последовательные проверки. Первая — есть ли блок статьи на странице. Вторая — подключена ли библиотека html2pdf.js.

typeof html2pdf === 'undefined' — «если переменная html2pdf не определена». Оператор typeof возвращает тип значения. Если библиотека не подключена — JS скажет 'undefined'.

Почему нельзя написать просто if (!html2pdf)? Потому что если переменная не определена — это уже ошибка ReferenceError, скрипт упадёт. А typeof безопасен: он не падает, а возвращает строку.

Настройки PDF

JavaScript
const opt = {
  margin:       1,
  filename:     postId + '.pdf',
  image:        { type: 'jpeg', quality: 0.98 },
  html2canvas:  { scale: 2, letterRendering: true, useCORS: true },
  jsPDF:        { unit: 'in', format: 'a4', orientation: 'portrait' }
};

Объект с настройками PDF. Библиотека html2pdf.js понимает такой формат. Разберём ключи.

Почему margin = 1

Число 1 здесь — один дюйм, потому что единицы заданы как unit: 'in' в настройках jsPDF. Если бы единицы были миллиметры — 1 мм был бы слишком маленьким отступом. Дюйм ≈ 2.54 см. Стандартные поля документа.

Запуск генерации

JavaScript
html2pdf().set(opt).from(article).save();

Цепочка методов. Читается как английская фраза: «html2pdf установи настройки opt, от элемента article, сохрани».

Такой стиль называется fluent interface — «плавный интерфейс». Каждый метод возвращает объект, к которому можно применить следующий метод. Одна строка вместо пяти. Читается как обычное предложение.

Итог

В следующем разделе — scroll-top.js. Там меньше кода, но есть интересные детали: как отслеживать прокрутку и почему кнопка «Наверх» вынесена в отдельный файл.