Перейти к содержанию

ES-модули без сборщика

В одном app.js уже находятся данные, проверка, отбор, создание DOM и форма. Эти части работают вместе, но имеют разные обязанности. Модули позволяют хранить их в отдельных файлах и явно обозначить связи через export и import. Для небольшого браузерного проекта можно использовать ES-модули напрямую, без стороннего сборщика.

Сохраним поведение прошлого урока: четыре курса, параметры темы и порядка, применение по кнопке. Пока данные остаются частью JavaScript; сетевую загрузку добавим позже. Цель этого шага — изменить устройство файлов, не менять одновременно интерфейс и смысл операций.

Границы файлов

Создайте папку js рядом с index.html. В ней будут data.js, model.js, view.js и новый app.js. Первый хранит записи, второй проверяет и выбирает данные, третий создаёт карточки, четвёртый связывает элементы страницы с действием формы.

Скопируйте массив четырёх курсов из урока об объектах в js/data.js, добавив export к объявлению:

export const courses = [
  { id: 'javascript', title: 'Современный JavaScript', topic: 'frontend',
    lessons: 20, url: './courses/javascript.html' },
  { id: 'html-css', title: 'HTML и CSS', topic: 'frontend',
    lessons: 16, url: './courses/html-css.html' },
  { id: 'performance', title: 'Производительность сайта', topic: 'frontend',
    lessons: 12, url: './courses/performance.html' },
  { id: 'markdown', title: 'Статический сайт из Markdown', topic: 'publishing',
    lessons: 12, url: './courses/markdown.html' },
];

Экспорт не копирует массив в каждый потребляющий файл. Он делает именованную связь доступной другим модулям. Содержимое массива всё ещё изменяемое, поэтому договорённость о чтении исходных записей сохраняется. Модульная граница организует зависимости, но не является глубокой защитой объекта.

В js/model.js перенесите полную validateCourses из урока об ошибках, добавив export перед function. После неё запишите две функции:

export function selectCourses(courses, { topic = 'all', sort = 'original' } = {}) {
  const selected = courses.filter(course => topic === 'all' || course.topic === topic);
  if (sort === 'lessons') selected.sort((a, b) => a.lessons - b.lessons);
  return selected;
}
export function countLessons(courses) {
  return courses.reduce((total, course) => total + course.lessons, 0);
}

model.js не обращается к document. Он получает данные через параметры и возвращает результат. Это позволяет позже использовать его при загрузке JSON и не создавать карточки ради одной проверки. В js/view.js перенесите полную функцию renderCourses из урока о создании DOM и также добавьте export перед объявлением.

Явные зависимости запуска

Следующий листинг представляет весь js/app.js на этом этапе:

import { courses } from './data.js';
import { validateCourses, selectCourses, countLessons } from './model.js';
import { renderCourses } from './view.js';

const form = document.querySelector('#catalog-controls');
const list = document.querySelector('#catalog-list');
const status = document.querySelector('#catalog-status');
if (!form || !list || !status) throw new Error('Нет обязательного элемента каталога');
const checkedCourses = validateCourses(courses);
let selection = { topic: 'all', sort: 'original' };
function showSelection() {
  const visible = selectCourses(checkedCourses, selection);
  renderCourses(list, visible);
  status.textContent = visible.length === 0
    ? 'По выбранной теме курсов нет.'
    : `Курсов: ${visible.length}. Уроков: ${countLessons(visible)}.`;
}
form.addEventListener('submit', event => {
  event.preventDefault();
  const values = new FormData(form);
  selection = {
    topic: String(values.get('topic') ?? 'all'),
    sort: String(values.get('sort') ?? 'original'),
  };
  showSelection();
});
showSelection();

Имена в фигурных скобках должны совпадать с именованными экспортами. Если файл экспортирует countLessons, импорт totalLessons без переименования не получит ту же функцию автоматически. Такой отказ возникает до обычного запуска программы и отличается от неверной записи курса.

Пути импортов вычисляются относительно импортирующего файла. Для js/app.js путь ./model.js указывает на соседний модуль. URL ссылок карточек, присваиваемые элементу a, относятся к документу страницы, поэтому остаются ./courses/.... Перенос JavaScript в подпапку не требует случайно добавлять ../ ко всем предметным ссылкам.

Подключение и HTTP

В index.html замените прежний обычный скрипт одной строкой:

<script type="module" src="./js/app.js"></script>

Не оставляйте рядом прежнее подключение app.js: иначе можно получить два набора обработчиков и разные версии данных. Для модуля не требуется наш прежний defer; обычное модульное подключение учитывает завершение разбора документа. Правила загрузки и областей описаны в MDN об ES-модулях.

Теперь покажите папку по HTTP. Следующая команда предназначена для ручного запуска читателем из catalog-lab; при подготовке материала сервер не запускался:

python3 -m http.server 8000 --bind 127.0.0.1

Откройте http://127.0.0.1:8000/ и остановите сервер сочетанием Ctrl+C после работы. Нужен установленный Python3. Простое открытие через file:// не является выбранным способом проверки модулей: ограничения браузера могут остановить загрузку. Сервер здесь нужен только для локального показа, а не для развертывания сайта в интернете.

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

Область модуля и направление связей

Верхнеуровневые имена модуля не становятся обычными глобальными свойствами window. Поэтому отсутствие переменной courses при прямом вводе в консоль не доказывает, что модуль не загрузился. Смотрите сообщения страницы, сетевую загрузку файлов и конкретную ошибку импорта. Консольное окружение и область модуля не следует смешивать.

Модель не должна импортировать приложение ради доступа к его состоянию. Наши связи направлены от запуска к модели и представлению; данные передаются параметрами. Циклические зависимости иногда допустимы языком, но усложняют момент инициализации и для этой задачи не нужны. Не создавайте их лишь ради экономии одного параметра функции.

Загрузка модуля и тип ресурса

Браузер получает модули как отдельные ресурсы. Если путь неверен, локальный сервер может вернуть страницу ошибки вместо JavaScript. В таком случае сообщение иногда говорит о неподходящем типе ресурса, хотя корень проблемы — имя файла или адрес. Сначала сравните реальный файл с импортом, затем статус и содержимое ответа, а уже после ищите ошибку в теле функции.

Сервер должен отдавать JavaScript с подходящим типом содержимого. Для нашего локального показа стандартные файлы .js обслуживаются обычным HTTP-сервером Python. На настоящем хостинге настройки ресурсов являются отдельной частью развертывания. Наличие расширения в ссылке помогает указать нужный путь, но не заставляет произвольный сервер автоматически принять правильную конфигурацию.

Имена файлов различаются регистром там, где это различает среда хранения. Рабочий вариант на одном компьютере не даёт права писать Model.js, если файл назван model.js. Выбираем строчные имена и одинаковые пути везде. Не импортируйте один и тот же учебный файл под несколькими похожими адресами с разными параметрами: для этого шага нужен один понятный граф зависимостей.

Модуль запуска инициирует отображение, а импортируемые файлы предоставляют операции. Поэтому в view.js нет верхнеуровневого вызова, который внезапно создаёт карточки при импорте. Человек, читающий app.js, должен видеть, где выполняется действие. Отсутствие скрытого запуска также помогает следующему уроку: Promise станет новым способом получить данные, но не заставит переносить все побочные действия между файлами.

Импорт модели не выполняет сетевой запрос к данным курса. Браузер загружает сам файл программы, а содержание четырёх карточек пока является его экспортом. Это отличает модульную загрузку от будущего fetch JSON: оба механизма используют ресурсы, но выполняют разные обязанности и проверяют разные форматы.

Перенос функции в модуль не меняет способ передачи аргументов. renderCourses по-прежнему получает конкретный список и массив; ему не нужно заново искать все элементы страницы внутри файла. Это сохраняет ответственность запуска за выбор узлов и ответственность представления за карточки. Параметр делает такую связь видимой при чтении вызова.

Файл данных после следующего этапа будет заменён JSON-источником. Поэтому его задача намеренно ограничена экспортом массива, без регистрации обработчиков и обращения к DOM. Тогда смена источника изменит место получения данных, а существующие функции модели и представления останутся пригодны. Это проверяемый смысл разделения, а не просто распределение строк по папкам.

Для ручного опыта можно временно изменить импортируемое имя и ожидаемо получить ошибку до появления карточек, затем восстановить экспорт и импорт. Не добавляйте запасной глобальный вызов только ради исчезновения сообщения. Правильное исправление согласует явную зависимость. Когда все файлы связаны по именам и адресам, следующий урок сможет менять только способ получения результата, сохраняя известные операции.

Проверьте названия и пути чтением файлов перед запуском: четыре модуля должны существовать, каждый импортируемый символ должен быть экспортирован. Затем ручная HTTP-проверка подтвердит реальную загрузку и поведение. В следующем уроке локальные данные станут результатом Promise, чтобы разобраться с отложенной обработкой до подключения настоящего запроса.

Оглавление курса