Web Worker: считаем сводку каталога сообщениями
В предыдущих уроках основной модуль получал данные, менял DOM и считал сводку каталога. Пока записей четыре, эти операции невелики. Но при обработке большого файла, построении поискового индекса или сложном преобразовании часть работы может задерживать ответы интерфейса. Рассмотрим способ передать вычисление отдельному исполнителю — выделенному Web Worker — и вернуть результат сообщением.
Будем считать число курсов и сумму уроков для выбранной темы. Четыре записи нужны для прозрачного разбора протокола, а не для доказательства ускорения. Создание исполнителя и передача данных тоже стоят ресурсов; в таком небольшом примере выигрыш не обещается. Снимок advanced/lesson-39/ находится в архиве продолжения. Программа при подготовке не запускалась, измерения времени не выполнялись.
В библиотеке сохранился урок о фоновых вычислениях и поиске простых чисел. Наш новый шаг развивает эту тему на каталоге: добавляет рабочий модуль, проверяемый договор сообщений, номер актуального задания и явное завершение исполнителя.
Два участника и один договор сообщений
Страница отвечает за форму и статус. Исполнитель получает обычные объекты, проверяет их, выбирает записи и считает сумму. DOM ему не передаётся: отрисовка остаётся на странице. Выделенный worker имеет отдельную среду выполнения и общается с создавшим его кодом через сообщения. Основное устройство такого исполнителя описывает HTML Standard.
Для задания используем объект с полями kind, id, topic, courses. Ответ будет иметь тот же id и признак result либо failure. Идентификатор принадлежит запросу страницы, а не курсу. Он позволяет понимать, к какому действию относится результат. Внутри нашего контролируемого примера номер увеличивается на единицу; принимать произвольный строковый номер из пользовательского поля не требуется.
Договор ошибки отделяет неверные входные данные от поломки самого исполнителя. Проверка неизвестной темы вернёт failure, после которого можно послать другое задание. Ошибка загрузки рабочего модуля приведёт к событию error на странице, и этот экземпляр будет остановлен. Если свести обе причины к единой надписи «ничего не найдено», читатель не поймёт, следует ли менять тему или восстанавливать программу.
Создаём самостоятельную страницу
В снимке 39 прежний canvas и компонент каталога не используются. Общие courses.js и model.js сохраняются, а форма служит отдельным клиентом сводки. Полный 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="./app.js"></script>
</head><body><main>
<h1>Сводка учебного каталога</h1>
<form id="summary-form">
<label>Тема <select name="topic">
<option value="all">Все темы</option>
<option value="frontend">Фронтенд</option>
<option value="publishing">Публикация</option>
</select></label>
<button type="submit">Рассчитать</button>
</form>
<p id="worker-status" role="status">Расчёт ещё не выполнялся.</p>
<button type="button" id="stop-worker">Остановить исполнителя</button>
<p>Учебные данные: JavaScript 20, HTML/CSS 16, производительность 12, Markdown 12.</p>
<noscript><p>Учебный расчёт использует JavaScript.</p></noscript>
</main></body></html>
Здесь нет автоматического расчёта при изменении каждого поля. Пользователь выбирает тему и отправляет форму. Можно делать несколько отправок подряд; ниже покажем, почему страница принимает только результат последнего запроса. Кнопка остановки имеет отдельный смысл: она завершает жизнь исполнителя, а не просто скрывает текущую надпись.
Пример надо открывать через локальный HTTP-сервер из корня advanced/, чтобы соседняя папка shared оставалась доступной. Открытие файла напрямую не является договором этого урока. Также реальная политика CSP должна разрешать нужный рабочий модуль. При подготовке команды запуска не выполнялись; здесь фиксируется условие, которое понадобится читателю при самостоятельном опыте.
Рабочий модуль
Создайте полный файл summary-worker.js:
import { validateCourses } from '../shared/model.js';
self.addEventListener('message', event => {
const message = event.data;
if (!message || message.kind !== 'summarize' ||
!Number.isSafeInteger(message.id) || message.id < 1) return;
try {
const courses = validateCourses(message.courses);
if (!['all', 'frontend', 'publishing'].includes(message.topic)) {
throw new TypeError('Неизвестная тема');
}
const selected = courses.filter(course =>
message.topic === 'all' || course.topic === message.topic);
const lessons = selected.reduce((sum, course) => sum + course.lessons, 0);
self.postMessage({ kind: 'result', id: message.id,
count: selected.length, lessons });
} catch (error) {
self.postMessage({ kind: 'failure', id: message.id,
message: error instanceof Error ? error.message : 'Ошибка вычисления' });
}
});
Исполнитель импортирует уже известную проверку модели. Через сообщение можно передать только данные, поэтому импорт функции выполняется внутри его собственного модуля. Для сообщения неправильного вида мы не начинаем вычисление. Для распознанного задания проверяем массив курсов и тему, после чего выполняем обычные filter и reduce. Меняется место исполнения алгоритма, но его правила остаются прежними.
self здесь относится к глобальной среде рабочего модуля. После успешного вычисления отправляются только два числа и идентификатор. Возвращать все карточки было бы лишним, поскольку странице нужна сводка. В ветке ошибки передаём простой текст, а не объект исключения с неявным набором полей. Так форма ответа остаётся маленькой и понятной.
Сообщения проходят через структурированное клонирование. Простые массивы и объекты подходят для нашего договора; функции и DOM-узлы для такой передачи не годятся. Подробные ограничения перечисляет описание алгоритма клонирования. Копирование массива не делает две среды обладателями одной общей изменяемой модели: изменение объекта на странице после отправки не должно использоваться как способ управления уже отправленным заданием.
Для большого бинарного файла можно исследовать передаваемые буферы и другое владение памятью, но это отдельный договор. Сначала важно правильно определить данные, результат и ошибку. Передача сотен тысяч объектов на каждое нажатие тоже может стать дорогой; наличие worker не освобождает от выбора разумной границы задания.
Клиент исполнителя
Полный app.js:
import { courses } from '../shared/courses.js';
const form = document.querySelector('#summary-form');
const submit = form.querySelector('button');
const stop = document.querySelector('#stop-worker');
const status = document.querySelector('#worker-status');
const lifetime = new AbortController();
let worker = null;
let latestId = 0;
function finish(message) {
worker?.terminate();
worker = null;
lifetime.abort();
submit.disabled = true;
stop.disabled = true;
status.textContent = message;
}
try {
worker = new Worker(new URL('./summary-worker.js', import.meta.url),
{ type: 'module' });
worker.addEventListener('message', event => {
const reply = event.data;
if (!reply || reply.id !== latestId) return;
if (reply.kind === 'result') {
status.textContent = `Курсов: ${reply.count}. Уроков: ${reply.lessons}.`;
} else if (reply.kind === 'failure') {
status.textContent = `Расчёт не выполнен: ${reply.message}`;
}
}, { signal: lifetime.signal });
worker.addEventListener('error', () => finish('Исполнитель не смог продолжить работу.'),
{ signal: lifetime.signal });
worker.addEventListener('messageerror', () => finish('Сообщение исполнителя не удалось прочитать.'),
{ signal: lifetime.signal });
form.addEventListener('submit', event => {
event.preventDefault();
const topic = new FormData(form).get('topic');
latestId += 1;
status.textContent = 'Вычисление передано исполнителю.';
try {
worker.postMessage({ kind: 'summarize', id: latestId, topic, courses });
} catch {
finish('Данные не удалось отправить исполнителю.');
}
}, { signal: lifetime.signal });
stop.addEventListener('click', () => finish('Исполнитель остановлен. Перезагрузите страницу для нового опыта.'),
{ signal: lifetime.signal });
} catch {
finish('Исполнитель недоступен. Проверьте HTTP-доступ и правила страницы.');
}
new URL строит адрес рабочего файла относительно текущего модуля. Опция type: 'module' позволяет использовать импорт внутри исполнителя. Такой способ адресации и параметры конструктора приведены в документации Worker. Создание заключено в try, а ошибки последующей загрузки отслеживаются отдельным событием: не все проблемы проявляются синхронно в конструкторе.
При отправке формы увеличивается latestId, а статус получает сообщение о передаче задания. postMessage не возвращает готовую сумму. Ответ приходит позже в слушатель message. Сравнение идентификатора отбрасывает ответы для прежнего выбора; это применение правила актуальности из урока 23, теперь к границе между двумя исполнителями.
В нашей программе один worker последовательно обрабатывает синхронные задания, поэтому это сравнение не превращает их в параллельные вычисления. Оно сохраняет смысл интерфейса, если пользователь успел изменить намерение прежде, чем страница приняла старый ответ.
Функция finish завершает worker, снимает слушатели через AbortController, отключает обе кнопки и оставляет понятный статус. terminate не обещает закончить текущий расчёт и отправить последний ответ. Поэтому остановленная форма больше не ждёт результата. Для нового учебного опыта предусмотрена перезагрузка страницы; скрытое автоматическое создание второго исполнителя сейчас усложнило бы жизненный цикл.
Ожидаемый результат и границы решения
Для всех тем ожидаются четыре курса и 60 уроков. Для frontend — три курса и 48 уроков, для publishing — один курс и 12 уроков. Эти суммы выводятся непосредственно из учебного массива. Они позволяют разбирать корректность выбора отдельно от времени исполнения. Успешное получение тех же чисел ещё не доказывает, что страница стала быстрее.
Для самостоятельной проверки отправьте разные темы, затем нажмите остановку и убедитесь, что новая отправка недоступна. После перезагрузки временно испортите значение темы в рабочем задании: должна появиться ошибка расчёта, а не нулевой результат. Отдельно переименуйте рабочий файл, чтобы исследовать ошибку загрузки. Эти сценарии показывают разные ветки протокола, а не служат готовым отчётом о прогоне.
Переносить стоит независимую от DOM работу, стоимость которой установлена измерением. Добавление ещё одного потока не исправит тысячи лишних DOM-узлов или неправильную отрисовку. Отличие выделенного worker от service worker также принципиально: в этом уроке исполнитель принадлежит открытой странице и считает сводку, а жизненный цикл service worker связан с другой моделью работы сайта. Следующий урок вернётся к обычным событиям страницы и переносу небольшого компонента с jQuery.