Comlink: Web Worker без зависаний — мастерская и 7 живых опытов
Выносим тяжёлые вычисления из интерфейса: семь живых опытов с Comlink, прогресс, transfer, отмена и защита от поздних ответов. Исходники и запуск на Windows/Linux.

Comlink: зачем выносить вычисления из интерфейса
Вы открываете большую выгрузку, выбираете фильтр и видите, что страница перестала отвечать. Кнопка уже нажата, но не меняет состояние; индикатор замер; поле ввода пропускает символы. Сервер может работать быстро: задержку создаёт JavaScript, который разбирает и пересчитывает данные в том же потоке, где браузер обрабатывает взаимодействие с человеком.
Web Worker позволяет выполнять подходящую работу в отдельном потоке. Comlink делает обмен с ним похожим на вызов обычных асинхронных функций: мы передаём параметры, ждём Promise и получаем результат или ошибку. Внутри по-прежнему работают сообщения и правила передачи данных. Библиотека уменьшает количество служебного кода, но не отменяет стоимость вычисления и не решает автоматически управление заданиями.
Здесь семь интерактивных опытов: первый вызов, сравнение потоков, фильтрация продаж, прогресс, передача буфера, отмена и защита от поздних ответов. В мастерской выполняется настоящий Comlink 4.4.2 с отдельным Web Worker. Исходники содержат тот же интерфейс, локальные зависимости, проверяемый расчёт и инструкции для Windows и Linux.
Где это пригодится в реальном сервисе
Первый сценарий — импорт CSV или JSON перед отправкой. Пользователь выбирает файл, а приложение определяет колонки, проверяет строки, ищет дубли и строит предварительную сводку. Во время обработки человек должен видеть прогресс, иметь возможность отменить импорт и продолжать взаимодействовать с остальными частями страницы. Worker хорошо подходит для вычислительной части такого процесса.
Второй сценарий — аналитическая панель с уже загруженными данными. Изменение периода или группы может требовать пересчитать десятки тысяч записей. Если отправить в worker весь набор один раз и затем передавать только параметры фильтра, повторные запросы могут обходиться дешевле. Возвращать в UI разумно компактную сводку и ограниченное число строк, а не заново копировать весь массив.
Третий сценарий — обработка изображений, геометрии, поискового индекса или бинарного формата. Здесь полезны типизированные массивы и передача владения буфером. Перед внедрением измерьте, где действительно тратится время: worker не поможет, если основная задержка связана с сетью, тяжёлой версткой или тысячами одновременно созданных DOM-элементов.
Worker, async, сервер и виртуализация решают разные задачи
Ключевое слово async не создаёт новый поток. Если внутри async-функции выполнить длинный синхронный цикл, главный поток останется занят до завершения цикла. Promise полезен для ожидания, но сам по себе не превращает вычисление в параллельную работу. Web Worker предоставляет отдельный контекст выполнения JavaScript.
Worker в браузере использует устройство посетителя. Он не заменяет серверный расчёт, которому нужны секреты, доступ к базе или централизованная проверка результата. Для тяжёлых задач Node.js есть отдельная статья о пуле потоков Piscina. Решение зависит от того, где должны находиться данные и кто отвечает за окончательный результат.
Если проблема в отрисовке очень длинного списка, начните с TanStack Virtual. Worker может посчитать фильтр, а виртуализация — показать небольшой видимый фрагмент. Эти подходы дополняют друг друга: один уменьшает блокировку вычислениями, второй ограничивает объём работы с DOM.
async function notParallel() {
// Этот цикл всё ещё выполняется в вызывающем потоке.
return expensiveCalculation();
}
const result = await workerApi.calculate(input); // отдельный Worker
Мини-мастерская: что попробовать по порядку
Начните с первого опыта: измените имя и вызовите функцию. Затем переключитесь на сравнение потоков. Нажмите кнопку запуска worker и следите за UI-счётчиком; отдельная кнопка главного потока выполняет короткую ограниченную нагрузку. Длительность измеряется на вашем устройстве, поэтому значения не обязаны совпадать между браузерами.
В сводке продаж измените число строк и регион. Выбор «Нет» показывает пустую выборку без искусственной ошибки. В опыте прогресса видны сообщения от worker после каждой порции. В опыте буфера сравните byteLength отправителя до и после transfer. В отмене сначала попробуйте мягкую остановку, затем завершение worker и новый запуск.
Последний опыт отправляет два запроса с разной задержкой: ответ старого запроса приходит позже, но не перезаписывает актуальный. Рядом есть кнопка намеренного исключения. Скачиваемый JSON содержит фактический результат опыта, а раскрывающийся блок объясняет основные строки кода. Кнопка сброса завершает созданный worker и возвращает исходные значения.
Установка и состав готового проекта
В исходниках закреплены Comlink 4.4.2, Bootstrap 5.3.9 и esbuild 0.28.2. Bootstrap отвечает только за оформление формы; Comlink — за сообщения; esbuild собирает браузерные модули в локальные app.js и worker.js. На странице не требуется CDN и не загружается удалённый код в момент выполнения опыта.
Папка src содержит чистое ядро расчёта, API worker и UI. В public находятся HTML, CSS и собранные JavaScript-файлы. В scripts — сборка и небольшой HTTP-сервер для локального знакомства. Папка test содержит проверки результата и настоящего обмена Comlink с Node worker_threads через официальный адаптер. Этот тест проверяет RPC, но не подменяет браузерную проверку Web Worker.
Для воспроизводимости храните package-lock.json вместе с проектом и используйте npm ci. Установка из lock повторяет выбранное дерево зависимостей; она не означает, что обновления больше никогда не нужны. Обновляйте библиотеку отдельным изменением и повторяйте проверки важных сценариев.
npm install --save-exact comlink@4.4.2
# В готовом архиве:
npm ci --ignore-scripts
npm run build
npm test
npm start
Первый worker: expose на одной стороне, wrap на другой
Worker получает собственный глобальный контекст и загружает отдельный модуль. В этом модуле мы создаём объект с разрешёнными методами и передаём его в Comlink.expose. В главном потоке оборачиваем созданный Worker с помощью Comlink.wrap. Вызов remote.hello возвращает Promise, даже если исходная функция внутри worker синхронная.
Хороший интерфейс worker состоит из небольшого набора понятных операций: загрузить данные, посчитать сводку, отменить задание, получить диагностическую информацию. Не стоит передавать произвольный JavaScript для выполнения. Явное API проще тестировать, ограничивать и сопровождать, а ожидаемые входные данные можно проверить до дорогостоящего расчёта.
В первом опыте worker возвращает значение typeof document. Результат undefined напоминает о границе: менять элементы страницы из worker нельзя. Он возвращает данные, а интерфейс решает, как их показать. При этом worker может использовать многие другие веб-API, если они доступны в выбранном браузере.
// worker.js
import { expose } from "comlink";
expose({ hello: name => ({message: `Привет, ${name}`, dom: typeof document}) });
// app.js
import { wrap } from "comlink";
const worker = new Worker("worker.js", {type:"module"});
const remote = wrap(worker);
console.log(await remote.hello("Анна"));
Почему переданный объект не становится общим состоянием
По умолчанию данные проходят через механизм structured clone. Полученный обычный объект представляет собой независимую копию. Если изменить массив в интерфейсе после передачи, это не означает, что worker увидит изменение. Обратный результат также следует рассматривать как отдельное сообщение, а не как ссылку на общую память.
Не все значения копируются одинаково. Функция, DOM-узел или объект с важным поведением прототипа не являются универсальным транспортным форматом. Для публичного API удобно использовать простые структуры, числа, строки, массивы и специально оговорённые бинарные данные. Передавайте только то, что нужно конкретной операции.
Если набор большой и используется много раз, можно явно загрузить его в worker, сохранить там и обращаться к нему по идентификатору. Тогда понадобится протокол освобождения набора, ограничение памяти и защита от ситуации, когда пользователь уже выбрал другой файл. В нашей небольшой мастерской данные генерируются детерминированно внутри worker, поэтому лишняя большая копия вообще не требуется.
Опыт 2: сравниваем отзывчивость, а не обещаем ускорение
В мастерской есть один и тот же короткий CPU-расчёт для главного потока и worker. Он работает примерно заданное время с верхним ограничением параметра, а затем возвращает фактическую длительность, число итераций и контрольное значение. Запуск в главном потоке сделан отдельной заметной кнопкой: посетитель сам выбирает этот опыт.
Смотрите прежде всего на возможность нажать кнопку отклика и на обновление счётчика. Работа в worker может занять столько же времени или даже немного больше из-за создания контекста и обмена сообщениями. Пользовательская польза заключается в том, что основной интерфейс сохраняет возможность отвечать на действия, пока идёт вычисление.
CSS-анимация отдельно не доказывает свободный главный поток: некоторые анимации браузер умеет обслуживать иначе. Поэтому рядом есть обновляемый JavaScript-счётчик. И он тоже не является полноценным измерителем производительности: фоновые вкладки, энергосбережение и планировщик ОС влияют на частоту обновления. Для анализа используйте профиль производительности и реальные данные своего продукта.
const before = performance.now();
const result = await remote.burn(150);
console.log({roundTripMs: performance.now() - before, result});
Опыт 3: фильтрация и сводка синтетических продаж
Каждая запись мастерской вычисляется из номера: регион циклически выбирается из трёх значений, сумма определяется фиксированной формулой. Это делает результат воспроизводимым. Вы можете сравнить worker с синхронным ядром и проверить, что перенос в другой поток не изменил бизнес-результат. Никакие реальные клиентские данные в примере не используются.
Worker возвращает число просмотренных и подходящих строк, общую сумму и среднее значение. В интерфейсе эти показатели можно превратить в карточки KPI, набор данных для графика или предварительную сводку импорта. Передавать одну компактную сводку обычно выгоднее, чем возвращать все исходные записи для повторного вычисления в UI.
Пустой фильтр — полноценный результат. Если ничего не найдено, matched равен нулю, а среднее в нашем контракте равно нулю. В другом продукте корректнее может быть null с подписью «Нет данных». Главное — договориться об этом явно и не делить сумму на ноль. Ошибка входа и отсутствие совпадений требуют разных сообщений.
const n = 100000;
const summary = await remote.aggregate(n, "Казань", jobId, onProgress);
// {scanned, matched, amount, average, cancelled}
Проверяем размер и форму задания на границе
Даже локальный инструмент нуждается в ограничениях. Ошибочно введённый миллиард строк может надолго занять устройство и память. Наш пример принимает только целое число от 1 до 200000. Ограничение повторяется в ядре, вызываемом worker: атрибуты min и max на HTML-поле полезны для человека, но не являются достаточной проверкой значения.
В прикладном импорте полезно отдельно ограничить размер файла, число строк, длину поля и максимальный объём результата. Разбор сжатого файла способен дать гораздо больше данных, чем исходный размер. Решение о допустимой нагрузке должно учитывать слабые устройства посетителей и цель интерфейса, а не только возможности компьютера разработчика.
Сообщение ошибки должно подсказывать следующий шаг. «Количество: целое число от 1 до 200000» помогает исправить поле. Общая надпись «Ошибка worker» не объясняет, виноват ввод, отсутствие файла модуля или неожиданное исключение. В проекте ошибки ввода перехватываются и показываются рядом с результатом, после чего можно повторить запуск.
export function count(value) {
const n = Number(value);
if (!Number.isInteger(n) || n < 1 || n > 200000) {
throw new RangeError("Количество: целое число от 1 до 200000");
}
return n;
}
Опыт 4: прогресс через Comlink.proxy
Обычную функцию нельзя просто скопировать в другой поток. Comlink.proxy создаёт способ вызывать её через канал сообщений. Главный поток передаёт callback, worker сообщает количество обработанных строк, а UI обновляет progress. Сам DOM остаётся в главном потоке; worker знает только о контракте сообщения.
Не отправляйте уведомление на каждую запись большого массива. Тысячи сообщений способны занять UI сильнее, чем сам полезный результат. В мастерской уведомление приходит после порции из 2000 строк, а worker ждёт завершения callback. В большом проекте частоту можно ограничить по времени, например обновлять видимый прогресс несколько раз в секунду.
Прогресс должен означать конкретную величину. Мы показываем обработанные строки относительно известного количества. Если длительность этапов сильно различается, 90% строк не означает, что осталось 10% времени. Для таких процессов лучше показывать отдельные этапы и их состояние, а оценку времени обозначать как приблизительную.
const onProgress = Comlink.proxy(({done,total}) => {
progress.value = done / total * 100;
label.textContent = `${done} / ${total}`;
});
await remote.aggregate(n, region, jobId, onProgress);
Почему работа разбита на порции
Длинный синхронный цикл блокирует цикл событий самого worker. Интерфейс при этом может оставаться отзывчивым, но команда отмены не будет обработана, пока цикл не закончится. Для управляемого задания нужно периодически отдавать управление и проверять состояние остановки. В примере после порции есть небольшой setTimeout.
Пауза в мастерской также делает прогресс и отмену наблюдаемыми на маленьком наборе. Это учебный выбор, а не рекомендация добавлять фиксированную задержку в каждый производственный расчёт. В рабочем проекте измерьте стоимость порции и подберите частоту уступки так, чтобы отмена приходила достаточно быстро без лишних накладных расходов.
Сам факт await не гарантирует обработку очереди внешних сообщений в удобный момент: важно, что именно ожидается. Понятный таймер между порциями даёт следующей задаче цикла событий возможность выполниться. Вход и результат порции должны оставаться согласованными, чтобы частичная остановка не выдавала неполную сумму за окончательную.
for (let from = 0; from < total; from += 2000) {
if (job.cancelled) return {cancelled:true, scanned:from};
processChunk(from, Math.min(total, from + 2000));
await progress({done:Math.min(total, from + 2000), total});
await new Promise(resolve => setTimeout(resolve, 12));
}
Опыт 5: копирование ArrayBuffer
Двоичные данные часто выгодно хранить в ArrayBuffer и читать через типизированное представление, например Uint8Array. Это подходит для изображения, числовой матрицы или собственного формата файла. При обычной передаче буфера worker получает копию; отправитель продолжает владеть своими байтами и может их читать.
В мастерской создаётся буфер 128 КиБ, заполненный числом 7. Worker возвращает размер и сумму байтов. После обычного вызова byteLength исходного буфера остаётся прежним. Это наблюдаемое свойство помогает понять, почему обмен большими массивами может увеличивать расход памяти и время сообщения.
Копирование иногда является правильным выбором: например, интерфейсу ещё нужен оригинал для отмены изменения или сравнения вариантов. Не следует автоматически заменять все сообщения на transfer. Сначала определите владельца данных на каждом этапе и то, кто будет использовать их после передачи.
const buffer = new Uint8Array(131072).fill(7).buffer;
const result = await remote.sumBuffer(buffer);
console.log(buffer.byteLength); // 131072
console.log(result.sum); // 917504
Передача владения: Comlink.transfer
Transferable позволяет передать владение поддерживаемым ресурсом вместо обычного копирования. Для ArrayBuffer следствием является отсоединение буфера у отправителя: после успешной отправки его byteLength становится нулём. Представления, созданные поверх этого буфера, тоже нельзя продолжать использовать как прежний массив данных.
Второй буфер мастерской передаётся через Comlink.transfer. Сумма у worker остаётся такой же, а исходный буфер в интерфейсе теряет байты. Это не потеря данных: ими теперь владеет другая сторона. Если результат обработки должен вернуться в UI в бинарной форме, worker может передать новый или обработанный буфер обратно.
В transfer list нужно указать именно передаваемый ресурс, входящий в сообщение. Одна строка с transfer не делает произвольный объект переносимым. В частности, нельзя таким способом передать DOM-элемент. SharedArrayBuffer представляет другую модель совместной памяти с отдельными требованиями; для показанных задач он не нужен.
const buffer = new Uint8Array([1, 2, 3]).buffer;
const result = await remote.sumBuffer(Comlink.transfer(buffer, [buffer]));
console.log(result.sum); // 6
console.log(buffer.byteLength); // 0
Опыт 6: кооперативная отмена
При запуске каждой операции интерфейс назначает jobId. Команда cancel передаёт этот идентификатор worker. Если выполняется соответствующее задание, worker выставляет флаг cancelled. Следующая порция замечает флаг и возвращает отдельный результат отмены с количеством уже просмотренных строк.
Такое поведение полезно для импорта: пользователь сменил файл или решил остановить обработку, но приложение может аккуратно закрыть ресурсы и показать, на каком этапе завершилось задание. Возвращаемая частичная сумма не должна выглядеть как окончательный отчёт. Поэтому в мастерской отмена имеет явный статус и поле cancelled.
AbortController сам по себе не останавливает вычислительный цикл в другом потоке. Его можно включить в собственную архитектуру отмены, но потребуется передать событие и обработать его там, где идёт работа. Контракт отмены лучше описать рядом с API: когда он принимается, что возвращается и можно ли повторить запрос.
cancel(jobId) {
if (running?.id === jobId) {
running.cancelled = true;
return true;
}
return false;
}
Жёсткое завершение и повторный запуск
Worker.terminate прекращает весь worker. Это удобно как аварийная остановка, если код больше не уступает управление или пользователь покинул инструмент. Но у такого действия нет возможности выполнить обычный завершающий код внутри остановленного контекста. Частичный результат и незавершённая операция не возвращаются автоматически.
Ожидающий Comlink Promise после уничтожения канала не стоит считать гарантированно отклонённым. В UI нужно самостоятельно перевести задание в состояние остановки и перестать принимать его результат. В мастерской увеличивается поколение задания, TaskScope завершает прикладные ожидания ошибкой остановки, освобождается proxy, вызывается terminate и включается кнопка нового запуска. Это не меняет семантику внутреннего Promise Comlink. Новый worker создаётся лениво при следующем действии, а поздние события старого экземпляра игнорируются.
Для долгоживущего приложения лучше иметь небольшой контроллер жизненного цикла, который хранит активный worker, текущую операцию и таймаут. При аварии он должен очистить ссылки на завершённое задание и дать пользователю понятный способ повторить его. Не оставляйте бесконечный спиннер только потому, что транспорт перестал отвечать.
jobs.next(); // прежние ответы больше не актуальны
remote[Comlink.releaseProxy]();
worker.terminate();
remote = null;
worker = null;
showStatus("Остановлено. Можно запустить заново.");
Опыт 7: старый ответ не должен перезаписать новый
Пользователь быстро меняет фильтр: сначала выбирает CRM, затем Склад. Второй запрос выполняется быстрее, но первый позже тоже возвращается. Если безусловно выводить каждый ответ, интерфейс внезапно покажет результат предыдущего выбора. Это распространённая гонка и в запросах API, и в сообщениях worker.
Защита проста по идее: перед отправкой увеличиваем номер версии запроса; после получения проверяем, что версия всё ещё актуальна. В мастерской два подготовленных запроса имеют задержки 600 и 60 мс. Оба действительно выполняются, но в accepted попадает только новый, а в ignored — поздний старый ответ.
Игнорирование ответа не равно отмене вычисления. Оно защищает состояние UI, но старое задание всё ещё расходует ресурсы. В рабочем поиске полезно сочетать ограничение частоты ввода, отмену предыдущей обработки и проверку версии. Даже если отмена пришла слишком поздно, проверка версии не даст устаревшим данным появиться на экране.
let revision = 0;
async function search(query) {
const myRevision = ++revision;
const answer = await remote.search(query, 100);
if (myRevision !== revision) return;
render(answer);
}
Ошибки расчёта и ошибки загрузки — разные состояния
Исключение, брошенное в удалённой функции, можно перехватить вокруг await. Учебная кнопка вызывает метод fail, который намеренно сообщает об ошибке данных. После этого worker остаётся доступен: следующий корректный вызов может успешно выполниться. Нельзя считать каждую прикладную ошибку причиной уничтожения всего контекста.
Другая ситуация — worker.js не загрузился из-за неверного пути, неправильного MIME или Content Security Policy. Здесь вызов метода может вообще не дойти до worker. Полезно слушать событие error созданного Worker, иметь ограниченный таймаут ожидания готовности и показывать сообщение о загрузке. В мастерской ошибка worker переводит UI в диагностическое состояние.
Для продуктивного сервиса не выводите посетителю внутренние пути, секреты и полный stack trace. Сохраните диагностический идентификатор в своей системе, а человеку покажите доступное действие: исправить ввод, повторить запрос, уменьшить файл или сообщить об ошибке. Хороший текст ошибки относится к задаче пользователя, а не только к названию исключения.
try {
const result = await remote.fail();
} catch (error) {
status.textContent = error.message;
}
worker.addEventListener("error", () => {
status.textContent = "Не удалось загрузить worker. Проверьте путь и повторите.";
});
Очистка callback, proxy и worker
Comlink.releaseProxy освобождает удалённый proxy и связанный канал. Это отдельная операция от Worker.terminate: освобождение proxy не означает, что созданный вами worker автоматически завершён. Владелец Worker должен определить, когда контекст больше не нужен, и явно завершить его.
Callback прогресса тоже является proxy. В нашем API он используется только для одного задания и освобождается в finally на стороне worker, в том числе при отказе из-за неправильных аргументов. Это особенно важно при повторных запусках: интерфейс не должен создавать бесконечно растущее количество каналов. Если callback нужен нескольким операциям, его срок жизни нужно организовать иначе.
При закрытии страницы пример очищает UI-таймер и завершает worker. В SPA аналогичную очистку обычно привязывают к уничтожению компонента или смене инструмента. Важно не завершить worker, который ещё используется другим компонентом: общий сервис нуждается в явном владельце или счётчике потребителей.
try {
return await calculateWithProgress(progress);
} finally {
progress[Comlink.releaseProxy]();
}
// На стороне владельца Worker:
remote[Comlink.releaseProxy]();
worker.terminate();
Подключение к Vue, React и обычной странице
Comlink не требует UI-фреймворка. На обычной странице достаточно обновлять текст и элементы формы после получения данных. Во Vue удобно держать status, progress и result в реактивном состоянии, а worker создавать в клиентской части жизненного цикла. В React аналогичную роль выполняют состояние компонента и cleanup эффекта.
Не создавайте новый Worker при каждом рендере компонента. Это приводит к повторной загрузке модулей, потере состояния и лишним потокам. Храните экземпляр в стабильной ссылке или отдельном сервисе. Если приложение использует серверный рендеринг, обращение к Worker должно происходить только там, где действительно есть браузер.
Схема взаимодействия остаётся одинаковой: форма собирает проверенные параметры; контроллер назначает идентификатор; worker выполняет операцию; контроллер принимает актуальный результат; UI отображает его. Такое разделение помогает независимо тестировать расчёт и поведение интерфейса. Оно также упрощает замену браузерного worker серверным API, если задача перерастёт клиентское устройство.
Запуск на Windows: PowerShell и локальный HTTP
Установите поддерживаемый Node.js и распакуйте архив в отдельный каталог. В PowerShell вызов npm.cmd позволяет явно использовать командный shim, если политика выполнения блокирует npm.ps1. Сначала восстановите зависимости из lock, затем соберите файлы и запустите тесты. Локальный сервер по умолчанию слушает только 127.0.0.1:8500.
Откройте указанный HTTP-адрес в браузере. Не запускайте index.html двойным щелчком через file://: загрузка модулей и worker в таком режиме зависит от ограничений браузера и не соответствует нормальному размещению сайта. Если порт занят, задайте другой PORT перед запуском и откройте новый адрес.
Остановка локального сервера выполняется Ctrl+C в его консоли. Это учебный файловый сервер для проекта; его задача — корректно отдать HTML, CSS и JavaScript. Для постоянной публикации на VPS используйте обычный веб-сервер и отдавайте только содержимое public.
cd .\comlink-project
npm.cmd ci --ignore-scripts
npm.cmd run build
npm.cmd test
$env:PORT = "8500"
npm.cmd start
# Открыть http://127.0.0.1:8500
# После остановки, если переменная больше не нужна:
Remove-Item Env:PORT
Linux: сборка и практическая публикация на VPS
На Linux используйте поддерживаемый Node LTS, подходящий требованиям package.json. Команды сборки совпадают по смыслу: npm ci, npm run build и npm test. Для локальной проверки можно запустить PORT=8500 npm start, а на удалённом сервере открыть этот loopback-порт через SSH-туннель. Не нужно делать учебный сервер общедоступным только ради просмотра.
В production результат является статическим сайтом: скопируйте public в отдельный каталог релиза, например /var/www/comlink-demo/releases/20261111. Назначьте веб-серверу права чтения; право записи на опубликованные файлы ему обычно не требуется. Сначала проверьте новый каталог, затем переключите ссылку current или настройку root. Сохранённый предыдущий релиз упрощает возврат при ошибке.
Node и node_modules не обязаны находиться на сервере, который раздаёт готовые файлы. Сборку можно выполнить в CI или на отдельной машине, а на VPS доставить только проверенный public. При обновлении app.js и worker.js публикуйте их согласованно: старый UI и новый API worker могут оказаться несовместимыми.
npm ci --ignore-scripts
npm run build
npm test
PORT=8500 npm start
# Туннель с рабочего компьютера к запущенному loopback-серверу:
ssh -L 8500:127.0.0.1:8500 deploy@example.org
# Для production переносится содержимое public, а не весь проект.
Caddy, MIME и HTTPS
Для публичного размещения достаточно статического хостинга с правильным MIME для JavaScript. В примере файлы имеют расширение .js, чтобы избежать неожиданных правил сервера для .mjs. Worker должен загружаться по ожидаемому адресу и не получать HTML-страницу ошибки вместо JavaScript. Проверяйте не только HTTP 200, но и Content-Type ответа.
В конфигурации Caddy ниже root указывает на подготовленный статический каталог. Домен должен быть вашим и направлен на VPS; условия получения сертификата, доступность портов и права процесса нужно настроить в соответствии с окружением. Подробный путь есть в материале о Caddy. Перед применением конфигурацию следует проверить командой validate.
Не кэшируйте один worker.js навечно под неизменным именем, если API будет обновляться. Для учебной страницы удобно короткое кеширование; для производственной сборки обычно применяют имена с хешем содержимого и согласованный HTML. При проблемах после обновления сравните фактически полученные версии app.js и worker.js.
demo.example.org {
root * /var/www/comlink-demo/current
encode zstd gzip
file_server
header X-Content-Type-Options nosniff
}
# Перед reload:
# caddy validate --config /etc/caddy/Caddyfile
Content Security Policy и границы изоляции
При размещении на своём домене политика worker-src должна разрешать источник worker. В локальном сервере мастерской используется worker-src self вместе со script-src self. Все зависимости собраны локально, поэтому для запуска не приходится разрешать произвольные CDN или unsafe-eval. Политику нужно согласовать с остальной страницей, особенно если мастерская встроена в iframe.
Worker — отдельный контекст исполнения, но не место для хранения клиентских секретов. Посетитель контролирует свой браузер и может изучить загруженный JavaScript. API-ключ, спрятанный в worker.js, остаётся доступным пользователю. Для секретов и окончательной проверки прав нужен серверный обработчик.
Не загружайте произвольный адрес worker из пользовательского поля и не выполняйте предоставленный посетителем код. В нашем примере выбирается только фиксированный модуль и заранее определённые операции. Синтетические данные не отправляются наружу: результаты создаются локально, а скачивание JSON формирует файл в браузере.
Content-Security-Policy: default-src 'self'; script-src 'self'; worker-src 'self'; style-src 'self'; object-src 'none'; base-uri 'none'
Тестируем результат, обмен сообщениями и интерфейс отдельно
Чистое ядро легко проверить на маленьком известном наборе: три строки дают заранее вычисленную сумму, неизвестный регион — ноль совпадений, неверный размер — ошибку. Такие проверки ловят изменение бизнес-логики независимо от Comlink. Важно сравнивать с ожидаемыми значениями, а не вычислять ожидание той же функцией, которую тестируем.
Следующий уровень — настоящий обмен сообщениями. В архиве Node worker_threads запускает тот же API через адаптер Comlink. Проверяются результат, callback прогресса, сохранение обычного буфера, отсоединение переданного, удалённое исключение и кооперативная отмена. Это полезная автоматическая проверка транспорта, но она не доказывает браузерные пути, CSP и адаптивную верстку.
Браузерный уровень включает запуск настоящего Web Worker, быстрые смены сценария, отмену во время обработки, сброс, экспорт и повторный запуск после ошибки. На узком экране кнопки должны оставаться доступными, а длинный JSON прокручиваться внутри блока. Отдельно проверьте клавиатуру и состояние reduced motion, если используете анимацию индикаторов.
import {test} from "node:test";
import assert from "node:assert/strict";
import {aggregateSync} from "../src/core.js";
test("известные три строки", () => {
assert.equal(aggregateSync(3).amount, 411);
assert.equal(aggregateSync(3, "Нет").matched, 0);
});
Частые проблемы и понятный порядок диагностики
Если вызов никогда не возвращается, сначала проверьте, загрузился ли worker.js и выполнился ли expose. Затем проверьте отсутствие длинного бесконечного цикла, совместимость версий клиента и worker и факт завершения предыдущего контекста. Таймаут в UI должен завершать ожидание и предлагать повтор, а не молча оставлять кнопку навсегда заблокированной.
Если после transfer данные «исчезли», посмотрите byteLength исходного буфера. Для переданного ArrayBuffer нулевой размер ожидаем. Если нужен оригинал, заранее сделайте копию или выберите обычное сообщение. Если перестал работать прогресс, проверьте, что callback обёрнут в Comlink.proxy и не освобождён раньше времени.
Если worker быстро считает, а интерфейс всё равно тормозит, измерьте обработку результата. Создание десятков тысяч элементов таблицы или форматирование огромного JSON способно снова занять главный поток. Ограничивайте вывод, используйте страницы или виртуализацию и возвращайте из worker данные, подходящие для текущего экрана.
Что взять в свой проект и что потребуется расширить
Для небольшой клиентской обработки можно взять контроллер worker, проверку количества, последовательность состояний и защиту от поздних результатов. Для импорта реального файла добавьте разбор формата, проверку кодировки, ограничение памяти и отчёт ошибок по строкам. Для аналитики замените синтетическую генерацию загрузкой собственного набора и определите правила обновления данных.
Если несколько экранов используют один worker, понадобится политика очереди: допускается ли параллельная обработка, какой запрос важнее, когда освобождать набор данных. В нашем API одновременно выполняется только одна агрегирующая операция; это явно проверяется. Серверный пул задач и браузерный worker имеют похожие вопросы управления, но разные ограничения ресурсов и доступа.
Перед переносом в рабочий интерфейс измерьте полный путь: подготовка входа, сообщение, вычисление, возвращение и отрисовка. Затем проверьте отмену на слабом устройстве и ошибку загрузки после обновления сайта. Полезный результат внедрения — понятная, управляемая операция, во время которой человек сохраняет контроль над страницей.
Исходники и официальные материалы
В архив входят исходные модули, собранная мастерская, package-lock.json, лицензии зависимостей, тесты и README. Сначала запустите неизменённый проект, затем заменяйте расчёт небольшими шагами. Это помогает отличить ошибку установки или сервера от ошибки собственной логики.
Основной API описан в официальном репозитории Comlink. Правила платформы разобраны в документации Web Workers, structured clone и transferable objects. Сверяйте поддержку конкретных API с браузерами своих пользователей.
Для продолжения практики полезно сравнить этот пример с серверным пулом Piscina и с виртуализацией списков во Vue. Первый материал переносит CPU-работу из основного потока Node.js, второй помогает не перегружать браузер слишком большим DOM.


