Создание превью изображений на клиенте: борьба с прожорливыми браузерами

в 20:52, , рубрики: ajax, canvas, file api, html5, image, img, javascript, memory leaks, Веб-разработка, метки: , , , , ,

Всем привет! Сегодня задача у нас следующая: необходимо создать интерфейс для загрузки картинок, который бы генерировал перед загрузкой превьюшки небольшого формата. На данный момент HTML5 вовсю шествует по планете, и, казалось бы, как это реализовать должно быть предельно ясно. Есть несколько русскоязычных статей на эту тему (вот, например). Но тут есть одно но. В рассматриваемом там подходе не уделено никакого внимания расходу памяти браузером. А расход может доходить до гигантских размеров. Разумеется, если загружать одновременно не более 5-10 картинок небольшого формата, то все остается в пределах нормы; но наш интерфейс должен позволять загружать сразу много изображений формата не меньше, чем у современных фотоаппаратов-мыльниц. И вот тогда-то свободная память начинает таять на глазах.

Для начала, чтобы оценить масштаб проблемы, реализуем подход, описываемый практически без изменений во всех статьях на эту тему, и попробуем проследить за использованием памяти. Код примеров я постарался сделать настолько простым, насколько это было возможно для демонстрации именно создания превью. Как реализовать Drag&Drop и загрузку можно посмотреть хотя бы даже в моей предыдущей статье

Код

var listen = function(element, event, fn) {
    return element.addEventListener(event, fn, false);
};

listen(document, 'DOMContentLoaded', function() {

    var fileInput = document.querySelector('#file-input');
    var listView = document.querySelector('#list-view');

    listen(fileInput, 'change', function(event) {
        var files = fileInput.files;
        if (files.lenght == 0) {
            return;
        }
        for(var i = 0; i < files.length; i++) {
            generatePreview(files[i]);
        }
        fileInput.value = "";
    });

    var generatePreview = function(file) {
        var reader = new FileReader();
        reader.onload = function(e) {
            var dataUrl = e.target.result;
            var li = document.createElement('LI');
            var image = new Image();
            image.width = 100;
            image.onload = function() {
                // some action here
            };
            image.src = dataUrl;
            li.appendChild(image);
            listView.appendChild(li);
        };
        reader.readAsDataURL(file);
    };
});

Для тестов я использовал папку ничем не примечательных фотографий размером 3648х2736 пикселей и средним объемом 4 мегабайта. А также набор браузеров актуальных версий: Chrome (31.0), Yandex (13.12), Firefox (26.0), и IE (11.0.1). Ну и обычный Task Manager (Win 8.1).

Итак, выбираем в поле 20 фотографий. Смотрим:

Браузер Потребляемая память, МБ
Chrome 994
Yandex 1045
Firefox 1388
IE 1080

Тут стоит отметить два момента: 1) Yandex и Chrome держат под каждую вкладку отдельный процесс, а Firefox и IE — нет, поэтому для последних двух в измерения попадают также некоторые накладные расходы, напрямую не связанные с нашим испытанием; 2) я снимал измерения (здесь и далее) приблизительно через 20 секунд после подгрузки всех картинок, чтобы дать возможность браузерам освободить память по горячим следам, что они и делают, хотя и совсем незначительно — в пределах 50Мб, т.е. продолжают удерживать все еще слишком большие объемы. После обновления/закрытия страницы все браузеры потихоньку освобождают память до нормальных объемов.

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

Первый подход к снаряду

Первой моей мыслью было: «а что если такой перерасход получается от попытки загружать все картинки параллельно? Может быть, попытаться делать это последовательно, тем самым давая браузерам возможность немножко отдышаться?». Что ж, пробуем реализовать простейшую очередь.

Код

// ... откинул повторяющийся код ...
var queue = [];
var isProcessing = false;

listen(fileInput, 'change', function(event) {
    var files = fileInput.files;
    if (files.lenght == 0) {
        return;
    }
    for(var i = 0; i < files.length; i++) {
        queue.push(files[i]);
    }
    fileInput.value = "";
    processQueue();
});

var processQueue = function() {
    if (isProcessing) {
        return;
    }
    if (queue.length == 0) {
        isProcessing = false;
        return;
    }
    isProcessing = true;
    file = queue.pop();
    var reader = new FileReader();
    reader.onload = function(e) {
        var dataUrl = e.target.result;
        var li = document.createElement('LI');
        var image = new Image();
        image.width = 100;
        image.src = dataUrl;
        li.appendChild(image);
        listView.appendChild(li);
        isProcessing = false;
        processQueue();
    };
    reader.readAsDataURL(file);
};

Результаты (на тех же самых 20-ти фотках):

Браузер Потребляемая память, МБ
Chrome 979
Yandex 1119
Firefox 1360
IE 399

Видим, что помогло это только в случае с IE. Ну что ж поделать — рекомендуем всем пользователям отказаться от использования каких-либо браузеров, помимо IE. Шутка. Думаем дальше…

Второй подход

После сеанса некоторого шаманства, приходит в голову мысль: «а может быть, проблема в том, что браузерам приходится держать в памяти широченные изображения, хотя по факту нам нужно всего лишь один раз ужать картинку до размера превью? Что если вместо обычного img использовать canvas, куда помещать уже ужатое изображение?». Так и поступим.

Код

var queue = [];
var isProcessing = false;

listen(fileInput, 'change', function(event) {
    // ...
});

var processQueue = function() {
    // ... те же проверки и установка флага
    file = queue.pop();
    var reader = new FileReader();
    reader.onload = function(e) {
        var dataUrl = e.target.result;
        var li = document.createElement('LI');
        var canvas = document.createElement('CANVAS');
        var ctx = canvas.getContext('2d');
        var image = new Image();
        listView.appendChild(li);
        image.onload = function() {
            var newWidth = 100;
            var newHeight = image.height * (newWidth / image.width);
            ctx.drawImage(image, 0, 0, newWidth, newHeight);
            li.appendChild(canvas);
        };
        image.src = dataUrl;
        isProcessing = false;
        processQueue();
    };
    reader.readAsDataURL(file);
};

Результаты (все те же 20 картинок):

Браузер Потребляемая память, МБ
Chrome 188 (в пиковые моменты доходил до ~800МБ, но быстро скинул)
Yandex 201 (в пиковые моменты доходил до ~1ГБ, но сразу скинул, как и Хром)
Firefox 661 (пик ~900. надо отметить, что подождав еще с минуту, скинул до 300)
IE 103 (пик ~260)

Несмотря на большой расход в процессе (у всех, кроме IE), браузеры хотя бы начали сразу освобождать память. Это уже не может не радовать. Но все же праздновать окончательную победу пока рановато. Думаем, что можно еще предпринять…

Третий подход

В процессе дальнейших метаний и не слишком удачных экспериментов, вспоминаем, что когда-то попадался на глаза такой API, как ObjectURL (создание и утилизация), который позволяет создавать локальные ссылки на любые бинарные данные, хранимые в кеше браузера, а также утилизировать их. В теории, это может помочь нам избежать обработки гигантских DataURL. Скорее пробуем

Код

// ... создание таких же переменных
var processQueue = function() {
    // ... проверки и установка флага
    isProcessing = true;
    file = queue.pop();
    var li = document.createElement('LI');
    var canvas = document.createElement('CANVAS');
    var ctx = canvas.getContext('2d');
    var image = new Image();
    listView.appendChild(li);
    image.onload = function() {
        var newWidth = 100;
        var newHeight = image.height * (newWidth / image.width);
        ctx.drawImage(image, 0, 0, newWidth, newHeight);
        URL.revokeObjectURL(image.src);
        li.appendChild(canvas);
        isProcessing = false;
        processQueue();
    };
    image.src = URL.createObjectURL(file);
};

Результаты:

Браузер Потребляемая память, МБ
Chrome 881
Yandex 927
Firefox 140 (пик ~860)
IE 36 (пик ~70)

Что же мы получили? Ну, во-первых, отличные результаты в IE. Более или менее приемлемые в FF. А вот с WebKit'овыми браузерами как-будто отскочили обратно. Справедливости ради надо отметить, что при этом во всех браузерах картинки стали обрабатываться быстрее чисто по ощущениям, но при этом в IE возникали кратковременные фризы. Не исключено также, что FF и IE по-честному сразу освобождают ресурсы после вызова URL.revokeObjectURL(), а вебкитовым браузерам нужно какое-то время для этого (возможно даже, что они будут шустрее это делать в условиях нехватки памяти). Дальше можно пойти двумя путями: 1) разделить подходы — в браузерах на вебките вернуться ко второму подходу (с этим все понятно — дело техники); и 2) попробовать везде довести до ума третий подход. Попробуем последний вариант…

Подход четвертый (и последний): что-бы еще такое заоптимизировать?

Немного поднатужившись, выжимаем из себя еще пару улучшений. Первое, это выносим создание элемента img из обработчика очереди: теперь будем повторно использовать один и тот же заранее созданный объект. Забегая вперед скажу, что это помогло существенно улучшить ситуацию с памятью в вебкитенышаховых браузерах — что и требовалось. А второе, это давно известный трюк — немного откладываем каждую очередную обработку при помощи setTimeout(), это помогло улучшить ситуацию с кратковременными фризами. Итак, результат:

Код

// Привожу код целиком
var listen = function(element, event, fn) {
    return element.addEventListener(event, fn, false);
};

listen(document, 'DOMContentLoaded', function() {

    var fileInput = document.querySelector('#file-input');
    var listView = document.querySelector('#list-view');

    var queue = [];
    var isProcessing = false;

    var image = new Image(); // теперь сразу создаем элемент img
    var imgLoadHandler;

    listen(fileInput, 'change', function(event) {
        var files = fileInput.files;
        if (files.lenght == 0) {
            return;
        }
        for(var i = 0; i < files.length; i++) {
            queue.push(files[i]);
        }
        fileInput.value = "";
        processQueue();
    });

    var processQueue = function() {
        if (isProcessing) {
            return;
        }
        if (queue.length == 0) {
            isProcessing = false;
            return;
        }
        isProcessing = true;
        file = queue.pop();
        var li = document.createElement('LI');
        var canvas = document.createElement('CANVAS');
        var ctx = canvas.getContext('2d');
        // теперь необходимо снимать старый обработчик
        image.removeEventListener('load', imgLoadHandler, false);
        imgLoadHandler = function() {
            var newWidth = 100;
            var newHeight = image.height * (newWidth / image.width);
            ctx.drawImage(image, 0, 0, newWidth, newHeight);
            URL.revokeObjectURL(image.src);
            li.appendChild(canvas);
            isProcessing = false;
            setTimeout(processQueue, 200); // добавили краткий таймаут
        };
        listView.appendChild(li);
        listen(image, 'load', imgLoadHandler);
        image.src = URL.createObjectURL(file);
    };
});

Тестируем:

Браузер Потребляемая память, МБ
Chrome 103 (пик ~150)
Yandex 113 (пик ~150, как и у Хрома)
Firefox 107 (пик ~510)
IE 40 (а выше и не подымалось. Как будто вообще никакой работы не происходило)

Заодно протестируем еще и на 100 картинках аналогичного размера:

Браузер Потребляемая память, МБ
Chrome 98 (пик ~150)
Yandex 150 (пик ~180)
Firefox 104 (пик ~520)
IE 40 (все те же 40МБ!)

Видим, что увеличение числа обрабатываемых изображений практически не увеличивает расход. На этом, пожалуй, и остановимся.

Заключение

Признаться, после самых первых изысканий, я в какой-то момент подумал, что при нынешнем состоянии дел не выйдет реализовать данную возможность без чрезмерного перерасхода памяти. Все-таки, некрасиво подвешивать пользователю [пожелавшему загрузить 100 картинок разом] его гипотетический нетбук. Но приятно, что эти сомнения удалось побороть :)

Итак, нам удалось выяснить несколько моментов. Номер раз: использование DataURL годится только для работы с картинками очень небольшого формата (для больших предпочтительней использовать API objectURL, состоящий всего из двух методов). Номер два: надо быть осторожным с созданием большого количества объектов Image. Номер три: не производить всю обработку одновременно.

Ну и, коль скоро это небольшое исследование непостижимым образом привело к сравнению браузеров, пробежимся по каждому в отдельности.

Firefox проиграл?

Несмотря на выделяющийся по сравнению с остальными пиковый расход, думаю, что все же ситуация вполне приемлемая. Во-первых (не упомянул выше), еще через 30 секунд после замеров память опускалась до 60МБ, что даже ниже по сравнению с вебкитовыми; а во-вторых, вполне вероятно, что в условиях жесткой нехватки Firefox периодически подчищал бы память в процессе обработки и в конце концов даже на пике не отъедал бы столько. В общем, ставим зачет.

Всем равняться на IE!

Это опять шутка :) Но если говорить объективно, то надо признать, что IE сейчас — не просто инструмент для скачивания нормального браузера, а как минимум еще один годный обозреватель.

Яндекс.Браузер немного отстает от старших братьев?

Не думаю. И вот почему: дело в том, что он у меня сейчас используется как основной, а соответственно нельзя назвать эксперимент кристально чистым. Пара плагинов, история, синхронизация — все это вполне могло и вызвать это небольшое отставание.

А где же Опера???

Очень не хотелось ставить отдельно под этот эксперимент 12-ю версию. Не смотря на то, что она еще кое-кем используется, скоро и это число преданных поклонников вынуждено будет либо обновиться, либо мигрировать на другой браузер. А по поводу новой, вебкитовой — есть все основания полагать, что ситуация схожа с Yandex'ом и Chrome'ом.

Ну а как же Safari?

На win это редкий зверь, но на маке протестировал итоговый вариант (все на тех же 20-ти фотографиях). В процессе обработки расход памяти вообще не увеличивался.

Что с мобильными браузерами?

Проверил также в Safari на iPhone 5S. Наблюдается кратковременный фриз, но при этом память количество свободной памяти практически не уменьшилось. Я не нашел сходу, можно ли как-то увидеть, сколько конкретно резервирует каждый процесс в отдельности, буду признателен, если кто-то подскажет в каментах. Устройства на Android, к сожалению, под рукой не оказалось. Быть может, кто-то не поленится проверить самостоятельно и поделиться результатами.

Спасибо за внимание. Надеюсь, кому-то статья поможет не тратить время на аналогичные изыскания. И с прошедшими праздниками тебя, %username%!

Автор: safron

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js