Собираем каталог из модулей и состояний
К этому моменту мы умеем описывать курсы объектами, выбирать записи, создавать DOM и получать JSON по HTTP. Соединим эти действия в одном приложении. После загрузки читатель сможет выбрать тему и порядок карточек; отдельная кнопка повторит получение данных. Главное требование — каждое действие должно соответствовать понятному состоянию каталога.
Итоговый пример остаётся небольшим catalog-lab без сборщика и сторонних библиотек. Используются четыре записи прежних уроков. Сумма числа уроков равна 60, отбор frontend содержит три курса и 48 уроков, publishing — один курс и 12 уроков. Это данные учебного приложения. Исходники можно скачать в архиве catalog-lab. Учебное приложение при подготовке не запускалось; его состояния ниже описаны как ожидаемые.
Где живёт каждое действие
В проекте есть HTML-документ, stylesheet, JSON и четыре модуля. js/api.js получает данные и использует проверку модели. js/model.js содержит validateCourses, selectCourses, countLessons. js/view.js экспортирует renderCourses. Главный модуль js/app.js хранит состояние и соединяет действия пользователя с этими функциями.
Такое разделение не требует переносить каждую строку в собственный файл. Оно помогает увидеть договор: API возвращает проверенные записи, модель отбирает их, представление создаёт элементы, приложение решает, когда вызвать остальные части. Если загрузка сломалась, исправляем источник и сообщение состояния, а не начинаем менять правила сортировки.
В архиве имеются четыре небольшие страницы в courses/. Это самостоятельные учебные заглушки переходов, а не адреса новых курсов на ProfessorWeb. Они позволяют проверить ссылку карточки в рамках одного проекта. После переноса приложения в настоящий продукт данные и политика URL должны соответствовать этому продукту; выбранная в серии регулярная проверка пути ограничивает только наш fixture.
Замените index.html полным документом:
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Учебный каталог</title>
<link rel="stylesheet" href="./style.css">
<script type="module" src="./js/app.js"></script>
</head>
<body>
<main>
<h1>Учебный каталог</h1>
<p>Четыре учебные карточки для изучения JavaScript.</p>
<noscript><p>Для этого учебного интерфейса включите JavaScript.</p></noscript>
<form id="catalog-controls">
<fieldset disabled>
<legend>Отбор курсов</legend>
<label>Тема <select name="topic">
<option value="all">Все темы</option>
<option value="frontend">Фронтенд</option>
<option value="publishing">Публикация</option>
</select></label>
<label>Порядок <select name="sort">
<option value="original">Исходный</option>
<option value="lessons">По числу уроков</option>
</select></label>
<button type="submit">Применить</button>
</fieldset>
</form>
<button type="button" id="catalog-reload">Загрузить заново</button>
<p id="catalog-status" role="status" aria-live="polite">Каталог ещё не загружен.</p>
<ul id="catalog-list" aria-label="Курсы"></ul>
</main>
</body>
</html>
Отключённый fieldset обозначает начальное состояние: данных пока нет, поэтому отбор не готов. Кнопка повторной загрузки находится вне этой группы. После неудачной попытки она должна оставаться доступной, иначе пользователь потеряет способ восстановиться. Статус расположен отдельно от списка, поэтому пустой список и ошибка могут иметь разные объяснения.
Состояние и отбор уже загруженных записей
Весь итоговый js/app.js приведён ниже. Модули модели, представления и асинхронный загрузчик из предыдущих уроков сохраняются:
import { loadCourses } from './api.js';
import { selectCourses, countLessons } from './model.js';
import { renderCourses } from './view.js';
const form = document.querySelector('#catalog-controls');
const fieldset = form?.querySelector('fieldset');
const list = document.querySelector('#catalog-list');
const status = document.querySelector('#catalog-status');
const reload = document.querySelector('#catalog-reload');
if (!form || !fieldset || !list || !status || !reload) {
throw new Error('В HTML отсутствует обязательный элемент каталога');
}
const state = { courses: [], selection: { topic: 'all', sort: 'original' }, loading: false };
function showSelection() {
const visible = selectCourses(state.courses, state.selection);
renderCourses(list, visible);
status.textContent = visible.length === 0
? 'По выбранной теме курсов нет.'
: `Курсов: ${visible.length}. Уроков: ${countLessons(visible)}.`;
}
form.addEventListener('submit', event => {
event.preventDefault();
if (state.loading) return;
const values = new FormData(form);
state.selection = {
topic: String(values.get('topic') ?? 'all'),
sort: String(values.get('sort') ?? 'original'),
};
showSelection();
});
async function refreshCatalog() {
if (state.loading) return;
state.loading = true;
reload.disabled = true;
fieldset.disabled = true;
status.textContent = 'Загружаем каталог…';
try {
state.courses = await loadCourses();
showSelection();
} catch (error) {
state.courses = [];
renderCourses(list, []);
status.textContent = 'Каталог не удалось загрузить. Попробуйте ещё раз.';
console.error('Ошибка загрузки каталога:', error);
} finally {
state.loading = false;
reload.disabled = false;
fieldset.disabled = state.courses.length === 0;
}
}
reload.addEventListener('click', refreshCatalog);
refreshCatalog();
state.courses хранит полный проверенный массив. state.selection хранит последний применённый отбор, а state.loading показывает незавершённую попытку. Представление не становится источником данных: для нового отбора не читаем текст карточек обратно из HTML. Используем прежний массив и выбранные параметры.
Нажатие «Применить» обрабатывается через submit, поэтому сохраняется обычное действие формы, включая отправку клавиатурой. preventDefault останавливает переход браузера на другой документ. FormData читает элементы по их name; именно поэтому значения topic и sort должны совпадать с договором модели. Функция showSelection выполняет локальную операцию и не обращается к сети.
Если выбрать тему публикации, ожидаем единственную карточку Markdown и сумму 12. После возврата к «Все темы» снова должны отображаться четыре курса. Сортировка по урокам переставит карточки в порядке 12, 12, 16, 20; полный исходный массив не меняется, потому что selectCourses сначала создаёт отобранную копию. Это позволяет вернуть исходный порядок отдельным выбором формы.
Одна попытка загрузки одновременно
Перед await главный модуль устанавливает признак занятости и отключает кнопки. Повторный вход в refreshCatalog при state.loading завершается сразу. Проверка защищает правило приложения, а отключение кнопки объясняет его пользователю. Эти два действия дополняют друг друга: изменение HTML-состояния само по себе не заменяет договор функции.
После успеха заменяется массив и отображается сохранённый отбор. Поэтому повторное получение JSON не сбрасывает тему на «Все». Если новая версия данных больше не содержит выбранную тему, появляется успешный пустой результат. Если источник недоступен или модель отвергла данные, массив очищается и показывается сообщение о сбое. Эти состояния не следует смешивать.
Завершение снимает признак занятости и возвращает кнопку повторной загрузки. Группа отбора включается только при непустом полученном массиве. Для допустимого пустого JSON [] отбор остаётся отключённым, но повтор доступен. Это простая продуктовая политика данного приложения; в более сложном каталоге форма может иметь смысл даже при отсутствии записей.
Мы сознательно не выполняем несколько загрузок параллельно. Позднее такие интерфейсы могут потребовать отмены устаревшего запроса или номера актуальной операции. Добавлять их только ради сложности этого примера нет необходимости: выбранный режим сериализует попытки. Правила сетевого результата по-прежнему соответствуют Fetch API.
Чтение итогового проекта
Распакуйте архив и используйте инструкции его README для локального HTTP-просмотра. Если собирали проект вручную, проверьте наличие index.html, style.css, data/courses.json, четырёх JS-модулей и страниц переходов. Прежний data.js больше не импортируется: работающий источник теперь задан модулем API. Полная структура архива помогает увидеть эту границу без догадок.
Для ручной сверки сначала нужен успешный сценарий: четыре карточки, отбор каждой темы, оба порядка и переход по карточке. Затем по одному изменяются условия источника: отсутствующий JSON, неверная запятая, повторный id. После сбоя должна быть доступна повторная попытка; после восстановления файла возвращаются карточки. Числа и результаты в этом описании не являются протоколом выполненного запуска.
Добавление пятого курса
Расширение начните с новой записи в JSON. Выберите уникальный строчный id, одну из двух известных тем, положительное целое число уроков и допустимый путь в courses/. Затем создайте соответствующую HTML-страницу: проверка формата URL не доказывает, что файл действительно существует. Модель проверяет договор значения, а доступность перехода требует отдельного наблюдения. После обновления страницы ожидаются пять карточек и новая сумма уроков; это следствие данных, поэтому в app.js не нужно менять число четыре на пять.
Если новый курс требует третьей темы, изменение уже затрагивает договор. Понадобится согласовать допустимые значения в validateCourses, новый вариант формы и поведение отбора. Нельзя добавить пункт только в HTML и ожидать, что проверка источника автоматически примет неизвестное значение. Такая маленькая задача показывает пользу разделения файлов: данные, правила модели и элементы управления видны отдельно. У прежних двух тем сохраняется их смысл, а расширение становится последовательным изменением нескольких связанных частей.
Так заканчивается первый блок современного JavaScript. Мы получили небольшой каталог, где язык, DOM, модули и асинхронные операции имеют ясные обязанности. Дальнейшее развитие можно строить по самостоятельным задачам: состояние в URL, отмена запросов, сложная форма или хранение предпочтений. У каждого следующего шага уже есть конкретная модель, которую можно изменять и объяснять.