Недавно я разбирался с API Web Workers. Очень жаль, что я не уделил время этому отлично поддерживаемому инструменту раньше. Современные веб-приложения очень требовательны к возможностям главного потока выполнения JavaScript. Это воздействует на производительность проектов и на их возможности по обеспечению удобной работы пользователей. Веб-воркеры — это именно то, что в наши дни способно помочь разработчику в деле создания быстрых и удобных веб-проектов.



Момент, когда я всё понял


У веб-воркеров много положительных качеств. Но я по-настоящему осознал их полезность, столкнувшись с ситуацией, когда в некоем приложении используется несколько прослушивателей событий DOM. Таких, как события отправки формы, изменения размеров окна, щелчков по кнопкам. Все эти прослушиватели должны работать в главном потоке. Если же главный поток перегружен некими операциями, на выполнение которых нужно продолжительное время, это плохо отражается на скорости реакции прослушивателей событий на воздействия пользователей. Приложение «подтормаживает», события ждут освобождения главного потока.

Надо признать, что причина, по которой меня так заинтересовали именно прослушиватели событий, заключается в том, что я изначально неправильно понимал то, на решение каких задач рассчитаны веб-воркеры. Сначала я думал, что они могут помочь в деле повышения скорости выполнения кода. Я полагал, что приложение сможет сделать гораздо больше за некий отрезок времени в том случае, если какие-то фрагменты его кода будут выполняться параллельно, в отдельных потоках. Но в ходе выполнения кода веб-проектов весьма распространена ситуация, когда, прежде чем начать что-то делать, нужно дождаться некоего события. Скажем, DOM нужно обновить только после того, как завершатся некие вычисления. Я, зная это, наивно полагал, что, если мне, в любом случае, придётся ждать, это значит, что нет смысла переносить выполнение некоего кода в отдельный поток.

Вот пример кода, который тут можно вспомнить:

const calculateResultsButton = document.getElementById('calculateResultsButton');
const openMenuButton = document.getElementById('#openMenuButton');
const resultBox = document.getElementById('resultBox');

calculateResultsButton.addEventListener('click', (e) => {
    // "Зачем переносить это в веб-воркер, если, в любом случае, 
    // нельзя обновить DOM до завершения вычислений?"
    const result = performLongRunningCalculation();
    resultBox.innerText = result;
});

openMenuButton.addEventListener('click', (e) => {
    // Выполнить некие действия для открытия меню. 
});

Тут я обновляю текст в поле после того, как завершатся некие вычисления, предположительно — длительные. Вроде бы бессмысленно запускать этот код в отдельном потоке, так как DOM не обновить раньше, чем завершится выполнение этого кода. В результате я, конечно, решаю, что код этот нужно выполнять синхронно. Правда, видя подобный код, я сначала не понимал того, что до тех пор, пока главный поток заблокирован, другие прослушиватели событий не запускаются. Это означает, что на странице начинают проявляться «тормоза».

Как «тормозят» страницы


Вот CodePen-проект, демонстрирующий вышесказанное.


Проект, демонстрирующий ситуацию, в которой страницы «тормозят»

Нажатие на кнопку Freeze приводит к тому, что приложение начинает решать синхронную задачу. Всё это занимает 3 секунды (тут имитируется выполнение длительных вычислений). Если при этом пощёлкать по кнопке Increment — то, пока не истекут 3 секунды, значение в поле Click Count обновлено не будет. В это поле будет записано новое значение, соответствующее числу щелчков по Increment, только после того, как пройдут три секунды. Главный поток во время паузы заблокирован. В результате всё в окне приложения выглядит нерабочим. Интерфейс приложения «заморожен». События, возникающие в процессе «заморозки», ждут возможности воспользоваться ресурсами главного потока.

Если нажать на Freeze и попытаться поменять размер элемента resize me!, то, опять же, пока не истекут три секунды, размер поля не изменится. А после этого размер поля, всё же, поменяется, но при этом ни о какой «плавности» в работе интерфейса говорить не приходится.

Прослушиватели событий — это гораздо более масштабное явление, чем может показаться на первый взгляд


Любому пользователю не понравится работать с сайтом, который ведёт себя так, как показано в предыдущем примере. А ведь тут используется всего несколько прослушивателей событий. В реальном мире речь идёт совсем о других масштабах. Я решил воспользоваться в Chrome методом getEventListeners и, применив следующий скрипт, выяснить количество прослушивателей событий, прикреплённых к элементам DOM различных страниц. Этот скрипт можно запустить прямо в консоли инструментов разработчика. Вот он:

Array
  .from([document, ...document.querySelectorAll('*')])
  .reduce((accumulator, node) => {
    let listeners = getEventListeners(node);
    for (let property in listeners) {
      accumulator = accumulator + listeners[property].length
    }
    return accumulator;
  }, 0);

Я запускал этот скрипт на разных страницах и узнавал о количестве используемых на них прослушивателей событий. Результаты моего эксперимента приведены в следующей таблице.
Приложение
Количество прослушивателей событий
Dropbox
602
Google Messages
581
Reddit
692
YouTube
6054 (!!!)

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

Избавление от «тормозов» с помощью веб-воркеров


Учитывая всё вышесказанное, давайте перепишем предыдущий пример. Вот его новая версия. Выглядит она точно так же, как старая, но внутри она устроена иначе. А именно, теперь операция, которая раньше блокировала главный поток, вынесена в собственный поток. Если сделать с этим примером то же, что с предыдущим, можно заметить серьёзные позитивные отличия. А именно, если после нажатия на кнопку Freeze пощёлкать по Increment, то поле Click Count будет обновляться (после завершения работы веб-воркера, в любом случае, к значению Click Count будет добавлено число 1). То же самое касается и изменения размера элемента resize me!. Код, выполняющийся в отдельном потоке, не блокирует прослушиватели событий. Это позволяет всем элементам страницы оставаться работоспособными даже во время выполнения операции, которая раньше просто «замораживала» страницу.

Вот JS-код этого примера:

const button1 = document.getElementById('button1');
const button2 = document.getElementById('button2');
const count = document.getElementById('count');
const workerScript = `
  function pause(ms) {
    let time = new Date();
    while ((new Date()) - time <= ms) {}
               }

  self.onmessage = function(e) {
    pause(e.data);
    self.postMessage('Process complete!');
  }
`;
const blob = new Blob([
  workerScript,
], {type: "text/javascipt"});

const worker = new Worker(window.URL.createObjectURL(blob));

const bumpCount = () => {
  count.innerText = Number(count.innerText) + 1;
}

worker.onmessage = function(e) {
  console.log(e.data);
  bumpCount();
}

button1.addEventListener('click', async function () {
  worker.postMessage(3000);
});

button2.addEventListener('click', function () {
  bumpCount();
});

Если немного вникнуть в этот код, то можно заметить, что, хотя API Web Workers мог бы быть устроен и поудобнее, в работе с ним нет ничего особенно страшного. Вероятно, этот код выглядит страшновато из-за того, что перед вами — простой, быстро написанный демонстрационный пример. Для того чтобы повысить удобство работы с API и облегчить работу с веб-воркерами, можно воспользоваться некоторыми дополнительными инструментами. Например, мне показались интересными следующие:

  • Workerize — позволяет запускать модули в веб-воркерах.
  • Greenlet — даёт возможность выполнять произвольные фрагменты асинхронного кода в веб-воркерах.
  • Comlink — предоставляет удобный слой абстракции над API Web Workers.

Итоги


Если ваше веб-приложение — это типичный современный проект, значит — весьма вероятно то, что в нём имеется множество прослушивателей событий. Возможно и то, что оно, в главном потоке, выполняет множество вычислений, которые вполне можно выполнить и в других потоках. В результате вы можете оказать добрую услугу и своим пользователям, и прослушивателям событий, доверив «тяжёлые» вычисления веб-воркерам.

Хочется отметить, что чрезмерное увлечение веб-воркерами и вынос всего, что не относится напрямую к пользовательскому интерфейсу, в веб-воркеры, это, вероятно, не самая удачная идея. Подобная переработка приложения может потребовать много времени и сил, код проекта усложнится, а выгода от такого преобразования окажется совсем небольшой. Вместо этого, возможно, стоит начать с поиска по-настоящему «тяжёлого» кода и с выноса его в веб-воркеры. Со временем идея применения веб-воркеров станет более привычной, и вы, возможно, будете ориентироваться на неё ещё на этапе проектирования интерфейсов.

Как бы там ни было, рекомендую вам разобраться в API Web Workers. Эта технология пользуется весьма широкой поддержкой браузеров, а требования современных веб-приложений к производительности растут. Поэтому у нас нет причин отказываться от изучения инструментов, подобных веб-воркерам.

Уважаемые читатели! Пользуетесь ли вы веб-воркерами в своих проектах?


Комментарии (5)


  1. GCU
    16.12.2019 13:52

    Возможно стоит добавить что до Web Workers тяжёлые скрипты «деблокировались» другими средствами, например используя setTimeout(fn, 0). Несмотря на очевидную «костыльность» этого — перетаскивание кучи зависимостей в Worker на мой взгляд не сильно лучше.


    1. RUQ
      16.12.2019 14:49
      +1

      Нет. setTimout — просто отсрочит выполнение скрипта (до тех пор пока не выполняться все синхронные вычисления), но потом fn всё равно будет выполняться в основном потоке, и «фризнет» страницу. В случае же с webworker, fn будет выполняться в отдельном потоке не влияющем на основной (собственно потому там нет доступа к dom и прочему окружению).


      1. GCU
        16.12.2019 15:04
        -1

        Там изначально расчёт идёт чтобы не «фризить» сразу на 3 секунды страницу одним куском, а синхронно проделав часть работы (допустим за 1/20 с) дать возможность другим сообщениям отработать, повесив остаток работы на «потом» и так 60 раз :).
        Вместо одного большого фриза будет много маленьких фризят — но они конечным пользователем воспринимаются терпимее.


    1. hrie
      16.12.2019 15:39

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


  1. PsychodelEKS
    18.12.2019 12:18
    +1

    Удобнее все же workerScript не строкой хранить:

    let workerFunctionString = function () {
        // тут можно использовать только глобальные функции
        
        let privateData = {
            // ...
        };
    
        function privateMethod() {
            // ...
        }
    
        onmessage = function (msg) {
            // пришел запрос от основного окна
            // ...
            postMessage({
                // ответ основному окну
                // ...
            });
        };
    
        // готов работать
        postMessage('ready');
    }.toString();
    
    let blobURL = URL.createObjectURL(new Blob(['(', workerFunctionString, ')()'], {type: 'application/javascript'}));
    
    let myWorker = new Worker(blobURL);
    
    URL.revokeObjectURL(blobURL);
    
    let workerReadyCallback = function (message) {
        if (message.data === 'ready') {
            // воркер запустился
            removeEvent(myWorker, 'message', workerReadyCallback);
    
            myWorker.postMessage({
                // запускаем, что нужно
                // ...
            });
        }
    };
    addEvent(myWorker, 'message', workerReadyCallback);