JavaScript event loop в картинках (часть 2)
В первой части статьи был описан пример зависания 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(), который также вернёт промис. А когда он зарезолвится (когда картинка будет обработана) — мы её отображаем и переходим к следующей.
Вот и все, чем хотел с вами поделиться.
Напишите, пожалуйста, в коммментах, сталкивались ли вы с подобной проблемой, и в таком случае, как её решали.
На этом картинки закончились, спасибо за внимание!