JavaScript event loop в картинках (часть 2)

Pavel Bely
6 min readJun 27, 2017

--

В первой части статьи был описан пример зависания UI вследствии блокирования event loop при уменьшении загружаемых пользователем картинок.

В приложениии, которое мы разрабатываем, пользователь может добавлять товары — вводить информацию о товаре и его изображения. Изображения напрямую с телефона пользователя отсылаются в Amazon S3, но прежде прямо на клиенте предуменьшаются, чтобы ускорить процесс их загрузки. И на этом этапе обработки картинок UI зависает на пару секунд (это особенно ощутимо на мобильном телефоне).

Давайте вернемся к этому примеру: я создал простой прототип нашего приложения в plunkr, в котором можно вводить некую текстовую инфу и добавлять картинки. Их отсылать мы никуда не будем, а просто уменьшать и отображать.

Image resize (step 1)

Вот как выглядит наш обработчик выбора картинок (весь код — тут)

function handleFileSelectSeq(event) {
let files = event.target.files;
for (let i = 0; i < files.length; i++) {
let file = files[i];
// display spinner ImageTools.resize(file, {
width: 300, // maximum width
height: 300 // maximum height
}, function(blob, didItResize) {
// remove spinner
// display image
});
}
}

Здесь мы в в цикле проходимся по массиву картинок, создаем спиннер (индикатор загрузки), который будет отображаться пока мы обрабатываем картинки; и каждую уменьшаем, а в callback’е (передаваемой функии, которая будет вызвана после уменьшения) — отображаем.

Уменьшение картинки — довольно затратная операция, требующая проведения большого объема вычислений. Поэтому, если мы попробуем оптом загрузить десяток картинок по несколько МБ каждая, то UI на пару секунд замрёт (ввод с клавиатуры, скроллинг, клики мышкой — все эти действия какое-то время не возымеют эффекта). Особенно это заметно на мобильнике.

Вот как это можно изобразить:

В процессе выполнения “тяжелого” обработчика картинок происходят пользовательские события. Их обработчики становятся в очередь и ждут, пока завершится обработка картинок.

Image resize (step 2)

Очевидно, надо как-то “разбить” обработку картинок на части и между ними “вкрапить” обработку пользовательских событий. Т.е. хотелось бы, чтобы программа работала как-то так:

Перепишем наш обработчик выбора картинок:

function handleFileSelectRec(event) {
let files = event.target.files;
let containers = [];
for (let i = 0; i < files.length; i++) {
let file = files[i];
// create container for image
// create spinner
containers.push(container);
}
resizeImageRec(files, containers, 0);
}

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

function resizeImageRec(files, imageBlockObjs, i) {
if (i >= files.length) {
return;
}
ImageTools.resize(files[i], {
width: 800,
height: 600
}, function(blob, didItResize) {
// remove spinner
// add resized image to page
setTimeout(function() {
resizeImageRec(files, containers, ++i);
});
});
}

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

Итого, с новым рекурсивным обработчиком:
- UI практически не зависает;
- спиннеры отображаются сразу после выбора картинок;
- картинки появляются одна за другой по мере уменьшения.

Неплохо, но можем ли мы как-то это улучшить?

Web workers

Оказывается, существует-таки возможность распараллелить выполнение JavaScript кода. Это позволяет спецификация Web workers, с помощью которых можно запускать фоновые потоки. Worker — это объект, созданный при помощи конструктора , который запускает JavaScript файл с кодом, который будет выполнен в потоке Worker’а:

var myWorker = new Worker("worker.js");

У каждого веб воркера, как и у главного потока, имеется свой stack, heap и event loop. Послать сообщения в веб воркер (например, передать данные для расчетов) можно с помощью метода postMessage().

function sendImageDataToWorker(imageData) {
myWorker.postMessage(imageData);
console.log('Message posted to worker');
}

Это создаст событие типа message в очередь событий web worker’а, которое будет обработано в следующей итерации event loop воркера.

onmessage = function(e) {
console.log('Message received from main script');
var workerResult = 'Result: ' + (e.data[0] * e.data[1]);
console.log('Posting message back to main script');
postMessage(workerResult);
}

Аналогично, результаты вычислений и прочие данные воркер может отправить в главный поток с помощью метода postMessage(). И это опять же добавит событие типа message в очередь события главного потока.

myWorker.onmessage = function(e) {
result.textContent = e.data;
console.log('Message received from worker');
}

Image resize (step 3)

Но функционал Web Workerов ограничен, они не имеют доступа к DOM, и не могут использовать так нужный нам для уменьшения изображений Canvas.

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

Уменьшать каждую картинку, как и на 2м шаге, мы будем рекурсивно, поэтому привожу только код рекурсивной функции-ресайзера (полный код примера доступен тут)

function resizeImageWebWorker(files, imageBlockObjs, i) {
if (i >= files.length) {
return;
}
let file = files[i]; let imageCanvas = createDestinationCanvas();
let canvasContext = imageCanvas.getContext("2d");
let worker = new Worker("lanczos.js"); let img = new Image(); img.onload = function() {
// create source Canvas
// get its image data as imageData
// create destination Canvas
// get its image data as imageDataNew
worker.postMessage({
'imageData': imageData,
'imageDataNew': imageDataNew,
'width': 1024,
'lobes': 1
});
}
img.src = window.URL.createObjectURL(file);
worker.addEventListener('message', function(event) {
// remove spinner
// add resized image to page
setTimeout(function() {
resizeImageWebWorker(files, imageBlockObjs, ++i);
});
}, false);
}

Здесь сначала выполняется проверка, не пора ли завязать с ресайзом. После — создаём результирующий Canvas, получаем его контекст (куда будем писать результат ресайза).

После чего — создаём воркер, который выполнит код хранимый в файле lanczos.js.

Далее — создаём Image, в котором “отрисовываем” уменьшаемую картинку. А в обработчике её загрузки создаём исходный Canvas, с помощью которого вычитываем пиксельные данные картинки и передаём эти данные в воркер.

И в конце ресайз-функции добавляем обработчик события message от воркера, в котором, как мы это и делали ранее, удаляем спиннер, отображаем уменьшенную картинку и вызываем ресайз следующей картинки.

Казалось бы, теперь все должно вообще “летать” и ничего не зависать. А на деле — это худший по производительности из трёх рассмотренных вариантов — UI зависает, ресайз крайне медленный. В этом вы можете убедиться воочию на примере в plunker.

Почему? Ведь мы же перенесли всё бремя ресайза на веб воркер, который выполняется в фоновом потоке — ничего зависать не должно.

Проблема кроется в том, как мы получаем данные изображения с помощью Canvas. В предыдущих 2 вариантах (последовательный в цикле и рекурсивный) уменьшение изображения, собственно, и выполнялось вписыванием изображения в меньший по размеру Canvas, из которого мы получали результирующее изображение.

В последнем же варианте мы отрисовываем полноразмерный Canvas, т.е. в случае фото с телефона — около 3000 x 2000. Это протекает медленно, и UI зависает. После мы создаём только один воркер, и передаём ему вычитанный из Canvas массив пикселей. Воркер в одиночку обрабатывает эту громадину и очень нескоро возвращает результат.

Как же это улучшить?

Image resize (part 4)

Будем уменьшать каждую картинку не “одним махом”, а по кусочкам (tiles). Ведь, отрисовка Canvas меньшего размера для каждого кусочка займет гораздо меньше времени, и в промежутках между их отрисовкой движок сможет обработать события от пользователя.

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

Все это предоставляет библиотека pica.js. И вот как будет выглядеть обработчик выбора картинок с её использованием:

function resizeImagePica(files, imageBlockObjs, i) {
console.log('starting process files : ' + files + ', ' + i);
if (i >= files.length) {
console.log('exiting');
return;
}
let file = files[i]; let srcImage = new Image();
srcImage.onload = function() {
console.log("image is loaded");

let to = createDestinationCanvas();
let picaLib = pica(); picaLib.resize(srcImage, to)
.then(result => picaLib.toBlob(result, 'image/jpeg', 90))
.then(blob => {
let imageBlockObj = imageBlockObjs[i];
// remove spinner
// add resized image to page
setTimeout(function() {
resizeImagePica(files, imageBlockObjs, ++i);
});
});
}
srcImage.src = URL.createObjectURL(file);
}

Здесь мы вызываем функию resize() из библиотеки Pica.js, которая возвращает Promise. Когда этот промис заресолвится, вызывается метод toBlob(), который также вернёт промис. А когда он зарезолвится (когда картинка будет обработана) — мы её отображаем и переходим к следующей.

Вот и все, чем хотел с вами поделиться.

Напишите, пожалуйста, в коммментах, сталкивались ли вы с подобной проблемой, и в таком случае, как её решали.

На этом картинки закончились, спасибо за внимание!

Credits

Ufocoder — Event Loop in the browser Javascript

--

--