Художественный подход к загрузке изображений

в 7:58, , рубрики: ajax, coffeescript, html, html5, javascript, xmlhttprequest, Блог компании Taggy.io, Веб-разработка

Как художнице и web-разработчику, у меня со временем появилась необходимость в собственной галерее. Обычно, у галерей две основные функции: показ витрины — всех (или некоторых) картин — и детальный показ одной. Реализация обеих функций есть практически в любой готовой галерее. Но «заношенный» внешний вид готовых галерей и, ставший стандартом, пользовательский интерфейс не годятся для художника :). А нестандартный — требует особой архитектуры и реализации кода, осуществляющего загрузку и показ картин. Сам показ и пользовательский интерфейс я в этой статье опущу. Основное внимание будет отдано загрузке картин с сервера. Об итоговой организации контролируемой загрузки с использованием очередей, асинхронного загрузчика, обработки блоб-объектов, каскадов выполнения обещаний и с возможностью приостановки и пойдет речь.

Художественный подход к загрузке изображений - 1

Примеры кода записаны на coffeeScript

Задачи

  1. Загрузка всех картин витрины требует времени. Мгновенное появление всех — невозможно. А первоочередное появление картин, на которые сразу посмотрит пользователь, возможно.
    Поэтому одной из задач была возможность осуществлять загрузку картин в нужной последовательности. Моя галерея визуально центроориентированная, следовательно порядок загрузки — центробежный, сначала грузятся картинки в центре экрана, а потом расходящимися кругами остальные. Таким образом, маленькие экраны заполняются достаточно быстро, а большие экраны позволяют получить доступ к управлению просмотром в кратчайшие сроки (элементы управления перемещением и переходом к детальному просмотру сосредоточены вокруг центральной картинки).
  2. Другой задачей была возможность приостанавливать загрузку картинок для страницы, с которой уходят, не дождавшись пока абсолютно все на ней загрузится, чтобы сразу начать грузить данные для страницы, на которую приходят. Для этого необходимо сделать паузу в посылке запросов, запомнить какие картины не догрузили, и после возвращения на предыдущую страницу возобновить загрузку.

Для этого была применена трехуровневая архитектура:
приложение -> менеджер загрузок -> асинхронный загрузчик
Художественный подход к загрузке изображений - 2

Уровень приложения

Приложение последовательно получает url картинок, которые надо загрузить и отрисовать на экране. Способ, которым поставляются url'ы не интересен. Для каждой будущей картины приложение создает DOM-узел img или div с фоном.

    imgNode = ($ '<div>')
            .addClass('item' + num)

После чего дает задание менеджеру загрузок, передавая ему url картинки c сервера. Менеджер возвращает обещание (JQuery promise), при выполнении которого мы получим url до экземпляра класса blob с данными загруженной картинки, хранящимися в памяти браузера (url поступит в imgBlobUrl). Это новая возможность, появившаяся в HTML5, позволяющая создавать url'ы до экземпляров классов File или Blob, полученных в данном случае, в результате ajax-запроса.

    loadingIntoLocal = @downloadMan.addTask image.url
    # тут нам нужно поставить реакцию на done, но imgNode будет перезаписан очередной картинкой, поэтому для его сохранения используем замыкание
    ((imgNode) -> loadingIntoLocal.done (imgBlobUrl) -> imgNode.attr(src: imgBlobUrl)
    )(imgNode)

Уровень менеджера загрузок

Менеджер загрузки управляет очередью заданий ( @queue). Каждое задание указывает: какой url надо загрузить, какое обещание исполним, когда получим результат, и, опционально, номер попытки загрузки для не-с-первого-раза-успешной загрузки. Как только поступило задание, ставим его в очередь, создаем обещание и возвращаем это обещание приложению, чтоб ему было не скучно ждать. Запускаем задания.

    addTask : (url) ->
    downloading = new $.Deferred()
    task = {
        url: url,
        promise: downloading
        numRetries: 0
    }
    @queue.push task
    @runTasks()

Чтобы наиболее эффективно использовать канал, будем запускать по несколько XMLHttpRequest'ов одновременно. Браузер позволяет это делать. Поэтому метод @runTasks() должен следить за тем, что бы в каждый момент времени в пути находился не один, а N запросов. В моем случае экспериментально было выбрано 3 «рикши». Если есть свободные «рикши», то даем на выполнение следующее задание из очереди.

    runTasks: ->
        if (@curTaskNum < @maxRunningTasks) && !@paused
            @runNextTask()

Художественный подход к загрузке изображений - 3

«Рикша» берет очередное задание и с помощью асинхронного загрузчика подтягивает изображение с сервера, получая url блоба.

    runNextTask: ->
        task = @queue.shift()
        @curTaskNum++
        downloading = @asyncLoader.loadImage task.url

Как только загрузчик выполнит свое обещание, освобождается один из «рикш», и если еще есть задания в очереди, то метод @runNextTask() запускает следующее. При этом рапортуем наверх, что обещание, данное приложению, выполнено.

        downloading.done (imgBlobUrl) =>
            task.promise.resolve imgBlobUrl
            @curTaskNum--
            if @queue.length != 0 && !@paused
                @runNextTask()

Код менеджера (упрощенная версия)

        class DownloadManager
            constructor: ->
                @queue = []
                @maxRunningTasks = 3
                @curTaskNum = 0
                @paused = false
                @asyncLoader = new AsyncLoader()

            addTask : (url) ->
                downloading = new $.Deferred()
                task = {
                    url: url,
                    promise: downloading
                    numRetries: 0
                }
                @queue.push task
                @runTasks()
                downloading

            runTasks: ->
                if (@curTaskNum < @maxRunningTasks) && !@paused
                    @runNextTask()
                
            runNextTask: ->
                task = @queue.shift()
                @curTaskNum++
                task.numRetries++
                downloading = @asyncLoader.loadImage task.url
                
                downloading.done (imgBlobUrl) =>
                    task.promise.resolve imgBlobUrl
                    @curTaskNum--
                    if @queue.length != 0 && !@paused
                        @runNextTask()

                downloading.fail =>
                    if task.numRetries < 3
                        @addTask task.url

            pause: ->
                @paused = true

            resume: ->
                @paused = false
                @runTasks()
 

Однако при такой реализации паузы через флажок, обозначающий можно ли запускать следующее задание, остановка загрузки работает грубо. Если переход на другую страницу произошел в момент, когда на всех парах в три потока шла загрузка, то прерывания текущих заданий не происходит, просто не запускаются следующие.
Реализация паузы, делающей XMLHttpRequest.abort() заданиям, находящимся на выполнении описано в разделе «Поумневшая пауза».

Уровень асинхронного загрузчика

Асинхронный загрузчик — это самый низкий уровень нашей архитектуры, это тот «вокзал», который осуществляет отправление XMLHttpRequest'ов и прием бинарных данных картинки с последуюим размещением на «складе быстрого доступа».
Художественный подход к загрузке изображений - 4

Снаряжаем «рикшу» в новую поездку и устанавливаем обработчики ее состояний. Отмечаем, что ожидаем получить данные, доступные как объект ArrayBuffer, который содержит raw байты. Отправляем «рикшу» в полет до сервера. И тут же обещаем наверх, что сообщим как только он вернется.

class AsyncLoader
    loadImage: (url) ->
        xhr = new XMLHttpRequest()

        xhr.onprogress = (event) =>
            ... # опционально используем для отображения прогресса

        xhr.onreadystatechange = =>
            ... # вернемся к этому ниже

        xhr.responseType = 'arraybuffer' 
        xhr.open 'GET', url, true
        xhr.send()
        loadingImgBlob = new $.Deferred()
        return loadingImgBlob

Когда ответ вернулся с данными картинки, создаем из них блоб-объект. Теперь чтобы получить url на этот объект достаточно сделать objectUrl из блоба.

        imgBlobUrl = window.URL.createObjectURL blob

Получившийся адрес на «локальном складе» возвращаем менеджеру. На этом мы дозагрузили картинку.

        xhr.onreadystatechange = =>
            if xhr.readyState == 4
                if (xhr.status >= 200 and xhr.status <= 300) or xhr.status == 304
                    contentType = xhr.getResponseHeader 'Content-Type'
                    contentType = contentType ? 'application/octet-binary'

                    blob = new Blob [xhr.response], type: contentType
                    imgBlobUrl = window.URL.createObjectURL blob
                    loadingImgBlob .resolve imgBlobUrl

Поумневшая пауза

Для корректного решения второй поставленной задачи (приостановка планируемой загрузки ради более срочных заданий) поменяем средний уровень нашей архитектуры DownloadManager. Менеджер загрузок помимо основной очереди заданий @queue, в которой лежат еще не отданные на выполнение задания, становится владельцем очереди @enRoute, в которой хранятся задания уже находящиеся в процессе выполнения и которые в случае срабатывания паузы необходимо остановить с тем, чтоб в последствии запустить докачку.

    class DownloadManager
        constructor: ->
            @queue = []
            @enRoute = []
            @maxRunningTasks = 3
            @curTaskNum = 0
            @paused = false
            @asyncLoader = new AsyncLoader()

Таким образом, задания могут поступать двух типов: на первичную закачку и докачку (в случае, если картинка уже попадала в очередь, начинала загружаться, а потом была остановлена). Причем Chrome именно докачивает недостающие данные, а не начинает качать заново. Если мы уже обещали загрузить поступающую в очередь картинку и ее ждут, то мы кладем ее в начало очереди. Если мы еще не начинали загружать ее, запросили первый раз, то — в конец очереди. Определить, была ли уже картинка частично скачана, можно по существованию объекта обещания о ее загрузке в addTask.

        addTask : (url, downloading) ->
            add = if !downloading then 'push' else 'unshift'
            downloading ?= new $.Deferred()  # если не было передано обещание, что загрузим картинку, то обещаем сейчас, иначе будем выполнять старое обещание
            task = {
                xhr: null, # теперь нужно знать с помощью какого XMLHttpRequest'а осуществлялась передача. чтобы иметь возможность ее отменить. Поэтому xhr будет передаваться сюда из метода loadImage в asyncLoader'e
                url: url,
                promise: downloading
                numRetries: 0
            }
            @queue[add] task
            @runTasks()
            return downloading

Стартер @runTasks() каждый раз проверяет есть ли невыполненные задания, есть ли кому их выполнять и не стоим ли мы на паузе. Если все так — работаем.

        runTasks: -> 
            while (@queue.length != 0) && (@curTaskNum < @maxRunningTasks) && !@paused
                @runNextTask()

При постановке на паузу все запросы, которые находились в пути ( @enRoute) отменяются (task.xhr.abort()) и заново планируются к доставке в следующий раз. Это время наступит как только resume() перезапустит стартер заданий.

        pause: ->
            @paused = true
            while @enRoute.length != 0
                task = @enRoute.shift()
                task.xhr.abort()
                @addTask task.url, task.promise # заново используем уже данное обещание
                @curTaskNum--

        resume: ->
            @paused = false
            @runTasks()

        runNextTask: ->
            task = @queue.shift()
            @enRoute.push task

            @curTaskNum++
            task.numRetries++
            { downloading, xhr } = @asyncLoader.loadImage task.url # При запуске задания на исполнение не забываем сохранить xhr, который взялся выполнять задание, чтоб знать кого на паузе останавливать.
            task.xhr = xhr
            
            downloading.done (imgBlobUrl) =>
                i = @enRoute.indexOf task
                @enRoute.splice i, 1

                task.promise.resolve imgBlobUrl
                @curTaskNum--
                @runTasks()

            downloading.fail =>
                if task.numRetries < 3
                    @addTask task.url

Я постаралась описать полный цикл контролируемой загрузки. Живой пример работы этой архитектуры можно посмотреть на галерее.
Демо для теста. Код демо для скачивания и экспериментов — на github'е.
Если при экспериментах с демо вы будете использовать другой сервис, предоставляющий картинки, то на нем необходимо будет настроить совместное использование ресурсов между разными источниками (Cross-origin resource sharing (CORS)), чтобы разрешить браузеру отдавать данные скрипту, загруженного с другого домена. В самом простом случае это означает, что веб-сервер должен возвращать в ответе заголовок Access-Control-Allow-Origin: *. Это будет говорить браузеру, что сервер разрешает скриптам с любых других доменов делать XHR-запросы. Подробнее можно прочитать на MDN.

Автор: Taggy.io

Источник

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


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