Перенос jQuery-компонента на стандартные API
Старый сайт часто содержит небольшие фрагменты jQuery: кнопка меняет класс, сообщение обновляется, а события делегируются списку карточек. Такой компонент удобно переносить постепенно, сохраняя его понятное поведение. В последнем уроке серии возьмём отметки курсов и заменим операции библиотеки стандартными API. При этом сначала зафиксируем договор, по которому можно сравнить два варианта.
Кнопка отмечает свой курс только на текущей странице. Сохранения между посещениями здесь нет: хранилище предпочтений разбиралось отдельно. Динамически добавленная карточка должна получать то же поведение без нового подключения обработчика. При отключении компонента слушатель снимается. Именно эти свойства будем переносить, не превращая урок в переписывание всего приложения.
Снимок advanced/lesson-40/ в архиве продолжения содержит основной вариант без библиотеки и отдельный legacy-adapter.js для чтения. Последний модуль сам не подключается: ему нужен явно переданный объект jQuery из старого приложения. При подготовке ни один вариант не запускался; ниже указаны ожидаемые состояния и условия самостоятельного сравнения.
Договор старого компонента
Пусть root — конкретный список, а status — его отдельное сообщение. Для каждого курса есть кнопка с data-course-id и aria-pressed="false". Нажатие переключает значение aria-pressed, класс is-saved и текст сообщения. Атрибут содержит строку, поэтому сравнивать его с логическим true напрямую было бы неверно. Решение сначала вычисляет логическое состояние, затем явно записывает его строковое представление.
Полный исходный адаптер на jQuery:
export function mountLegacyFavorites($, root, status) {
function onClick() {
if (this.closest('[data-favorite-catalog]') !== root) return;
const next = this.getAttribute('aria-pressed') !== 'true';
$(this).attr('aria-pressed', String(next)).toggleClass('is-saved', next);
$(status).text(next ? 'Курс отмечен.' : 'Отметка снята.');
}
const selector = 'button[data-course-id]';
$(root).on('click.pwCatalog', selector, onClick);
return () => $(root).off('click.pwCatalog', selector, onClick);
}
Вызов .on с селектором подключает делегированный обработчик к списку. this внутри обычной функции обозначает подходящий элемент, которому библиотека доставила событие. Пространство имени pwCatalog облегчает снятие нужной подписки, а возвращаемая функция передаёт в .off тот же селектор и тот же обработчик. Эти особенности описаны в документации .on и .off.
Условие с closest ограничивает владельца кнопки ближайшим каталогом. Если внутри списка появится ещё один самостоятельный каталог, внешний адаптер не должен менять его отметки. Наши кнопки не вкладываются друг в друга, поэтому одно пользовательское действие имеет ровно одну подходящую цель. Мы не утверждаем, что новый вариант заменяет все особенности событий jQuery в произвольном DOM: перенос касается именно этого небольшого договора.
Обратите внимание на обычную функцию onClick. Без изменения остального кода заменить её стрелочной нельзя: исчезнет нужное библиотечное значение this. Это пример зависимости, которую легко потерять при механическом редактировании. В стандартном варианте ниже вместо такого контекста явно найдём кнопку из цели события.
Переносим каждую операцию по её смыслу
Для получения элемента раньше использовалась обёртка библиотеки, а теперь достаточно самого DOM-узла. Чтение атрибута остаётся getAttribute, запись выполняется через setAttribute, переключение класса — через classList.toggle. Второй аргумент последнего метода задаёт требуемое состояние, а не просто инвертирует произвольное наличие класса. Поэтому оформление и aria-pressed получают одно вычисленное значение.
Текст сообщения записывается через textContent. Это соответствует нашему договору обычного текста: строки не интерпретируются как HTML. Исходный вариант также использует именно .text, а не вставку разметки. При переносе надо сохранять это различие, иначе случайная замена метода может сделать поведение строки совсем другим. Цвет отметки не несёт смысл в одиночку: нажатое состояние присутствует и в атрибуте кнопки.
Новый полный favorites.js:
export function mountFavorites(root, status) {
const lifetime = new AbortController();
root.addEventListener('click', event => {
if (!(event.target instanceof Element)) return;
const button = event.target.closest('button[data-course-id]');
if (!button || button.closest('[data-favorite-catalog]') !== root) return;
const next = button.getAttribute('aria-pressed') !== 'true';
button.setAttribute('aria-pressed', String(next));
button.classList.toggle('is-saved', next);
status.textContent = next ? 'Курс отмечен.' : 'Отметка снята.';
}, { signal: lifetime.signal });
return () => lifetime.abort();
}
Обработчик получает event, а не библиотечный контекст кнопки. event.target может быть вложенной иконкой или надписью. closest поднимается до нужной кнопки, после чего второй поиск устанавливает ближайшего владельца. Проверка типа цели нужна, чтобы вызывать методы Element на подходящем объекте. Поведение поиска ближайшего элемента приведено в справочнике closest.
Слушатель находится на списке, поэтому он видит всплывающее событие от карточки, добавленной позже. Не требуется перебирать все кнопки после каждого обновления данных. Техника делегирования уже встречалась в уроке 26; теперь её назначение — сохранить конкретный старый компонент при смене API. Предмет сравнения остаётся нажатие и жизненный цикл, а не число строк в переписанном файле.
Вместо пространства имени используется отдельный AbortController. Его сигнал относится только к этой подписке; функция освобождения вызывает abort. Сам список не удаляется, а его классы и атрибуты сохраняются. Отрисовка и отключение интерфейса относятся к владельцу страницы. Поддержку сигналов и правила регистрации объясняет справочник addEventListener.
Подключаем один выбранный вариант
Замените документ снимка 40 целиком:
<!doctype html>
<html lang="ru"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Перенос отметок курса с jQuery</title>
<link rel="stylesheet" href="../style.css">
<style>.is-saved { outline: 2px solid #7650a1; outline-offset: 2px; }</style>
<script type="module" src="./app.js"></script>
</head><body><main>
<h1>Отметки учебных курсов</h1>
<ul id="favorite-catalog" data-favorite-catalog></ul>
<p id="favorite-status" role="status">Курс пока не отмечен.</p>
<button type="button" id="add-course">Добавить карточку Markdown</button>
<button type="button" id="stop-component">Отключить компонент</button>
<noscript><p>Учебные отметки используют JavaScript.</p></noscript>
</main></body></html>
Основной модуль страницы выглядит так:
import { courses } from '../shared/courses.js';
import { mountFavorites } from './favorites.js';
const list = document.querySelector('#favorite-catalog');
const status = document.querySelector('#favorite-status');
function appendCourse(course) {
const item = document.createElement('li');
const link = document.createElement('a');
link.href = course.url;
link.textContent = course.title;
const button = document.createElement('button');
button.type = 'button';
button.dataset.courseId = course.id;
button.setAttribute('aria-pressed', 'false');
button.textContent = `Отметить: ${course.title}`;
item.append(link, document.createTextNode(' '), button);
list.append(item);
}
courses.slice(0, 3).forEach(appendCourse);
const dispose = mountFavorites(list, status);
const add = document.querySelector('#add-course');
add.addEventListener('click', () => {
appendCourse(courses[3]);
add.disabled = true;
}, { once: true });
document.querySelector('#stop-component').addEventListener('click', () => {
dispose();
for (const button of list.querySelectorAll('button')) button.disabled = true;
add.disabled = true;
status.textContent = 'Компонент отключён.';
}, { once: true });
Сначала создаются три карточки, а затем подключается компонент. Кнопка добавления вставит четвёртый курс из того же массива и отключится. Так можно наблюдать главное свойство старой делегированной подписки: новая карточка работает без повторного mountFavorites. Ссылки ведут на локальные учебные страницы внутри снимка; их адреса не предназначены для подмены настоящих URL ProfessorWeb.
Кнопка отключения сначала снимает поведение, затем делает кнопки отметок недоступными и обновляет сообщение. Это действие владельца интерфейса: сам адаптер возвращает только освобождение подписки. Благодаря такому разделению один и тот же компонент можно убрать из DOM, оставить статичным или заменить другим, не встраивая каждое решение в обработчик клика.
Для сравнения со старым приложением замените только импорт и вызов подключения на mountLegacyFavorites(jQuery, list, status), предварительно предоставив доступную библиотеку. Остальные операции создания карточек намеренно стандартные. Два адаптера одновременно подключать не нужно: они будут по очереди переключать одно состояние и скрывать результат друг друга. Это не ошибка нового API, а два владельца одного действия.
Сравниваем поведение, затем убираем зависимость
Самостоятельный разбор начните с первой отметки: aria-pressed должен стать true, класс появиться, а сообщение измениться. Второе нажатие возвращает исходное состояние. Затем добавьте курс Markdown и отметьте его. Клавиатурная активация обычной кнопки должна попасть в тот же договор события, поэтому отдельное действие на keydown здесь не требуется.
После отключения никакая оставшаяся кнопка не должна продолжать менять состояние. Для дополнительного опыта добавьте вложенную надпись в кнопку и нажмите на неё: выбор через closest должен сохранить нужную цель. Если при переносе появляются двойные реакции, сначала проверьте количество подключённых адаптеров, а уже потом меняйте правила события.
Успешный перенос одного компонента ещё не разрешает удалить jQuery со всего старого сайта. Сначала надо найти остальные вызовы библиотеки, плагины и порядок их подключения. В нашем самостоятельном снимке таких зависимостей нет, поэтому основной документ их не загружает. Для ProfessorWeb этот подход полезен как последовательная работа с ограниченными фрагментами: сохраняем видимое действие, устанавливаем его владельца и постепенно меняем реализацию.
Курс завершается компонентом, в котором выбор данных, DOM, события и освобождение ресурсов имеют явные границы. Эти же границы пригодятся при добавлении новых страниц и учебных интерфейсов: менять механизм становится проще, когда заранее понятно, какое поведение он обязан сохранить.