У меня, как у художницы и web-разработчика, со временем появилась необходимость в собственной галерее. Обычно, у галерей две основные функции: показ витрины — всех (или некоторых) картин — и детальный показ одной. Реализация обеих функций есть практически в любой готовой галерее. Но «заношенный» внешний вид готовых галерей и, ставший стандартом, пользовательский интерфейс не годятся для художника :). А нестандартный — требует особой архитектуры и реализации кода, осуществляющего загрузку и показ картин. Сам показ и пользовательский интерфейс я в этой статье опущу. Основное внимание будет отдано загрузке картин с сервера. Об итоговой организации контролируемой загрузки с использованием очередей, асинхронного загрузчика, обработки блоб-объектов, каскадов выполнения обещаний и с возможностью приостановки и пойдет речь.
Примеры кода записаны на coffeeScript
Для этого была применена трехуровневая архитектура:
приложение -> менеджер загрузок -> асинхронный загрузчик
Приложение последовательно получает url картинок, которые надо загрузить и отрисовать на экране. Способ, которым поставляются url'ы не интересен. Для каждой будущей картины приложение создает DOM-узел img или div с фоном.
После чего дает задание менеджеру загрузок, передавая ему url картинки c сервера. Менеджер возвращает обещание (JQuery promise), при выполнении которого мы получим url до экземпляра класса blob с данными загруженной картинки, хранящимися в памяти браузера (url поступит в imgBlobUrl). Это новая возможность, появившаяся в HTML5, позволяющая создавать url'ы до экземпляров классов File или Blob, полученных в данном случае, в результате ajax-запроса.
Менеджер загрузки управляет очередью заданий ( @queue). Каждое задание указывает: какой url надо загрузить, какое обещание исполним, когда получим результат, и, опционально, номер попытки загрузки для не-с-первого-раза-успешной загрузки. Как только поступило задание, ставим его в очередь, создаем обещание и возвращаем это обещание приложению, чтоб ему было не скучно ждать. Запускаем задания.
Чтобы наиболее эффективно использовать канал, будем запускать по несколько XMLHttpRequest'ов одновременно. Браузер позволяет это делать. Поэтому метод @runTasks() должен следить за тем, что бы в каждый момент времени в пути находился не один, а N запросов. В моем случае экспериментально было выбрано 3 «рикши». Если есть свободные «рикши», то даем на выполнение следующее задание из очереди.
«Рикша» берет очередное задание и с помощью асинхронного загрузчика подтягивает изображение с сервера, получая url блоба.
Как только загрузчик выполнит свое обещание, освобождается один из «рикш», и если еще есть задания в очереди, то метод @runNextTask() запускает следующее. При этом рапортуем наверх, что обещание, данное приложению, выполнено.
Однако при такой реализации паузы через флажок, обозначающий можно ли запускать следующее задание, остановка загрузки работает грубо. Если переход на другую страницу произошел в момент, когда на всех парах в три потока шла загрузка, то прерывания текущих заданий не происходит, просто не запускаются следующие.
Реализация паузы, делающей XMLHttpRequest.abort() заданиям, находящимся на выполнении описано в разделе «Поумневшая пауза».
Асинхронный загрузчик — это самый низкий уровень нашей архитектуры, это тот «вокзал», который осуществляет отправление XMLHttpRequest'ов и прием бинарных данных картинки с последуюим размещением на «складе быстрого доступа».
Снаряжаем «рикшу» в новую поездку и устанавливаем обработчики ее состояний. Отмечаем, что ожидаем получить данные, доступные как объект ArrayBuffer, который содержит raw байты. Отправляем «рикшу» в полет до сервера. И тут же обещаем наверх, что сообщим как только он вернется.
Когда ответ вернулся с данными картинки, создаем из них блоб-объект. Теперь чтобы получить url на этот объект достаточно сделать objectUrl из блоба.
Получившийся адрес на «локальном складе» возвращаем менеджеру. На этом мы дозагрузили картинку.
Для корректного решения второй поставленной задачи (приостановка планируемой загрузки ради более срочных заданий) поменяем средний уровень нашей архитектуры DownloadManager. Менеджер загрузок помимо основной очереди заданий @queue, в которой лежат еще не отданные на выполнение задания, становится владельцем очереди @enRoute, в которой хранятся задания уже находящиеся в процессе выполнения и которые в случае срабатывания паузы необходимо остановить с тем, чтоб в последствии запустить докачку.
Таким образом, задания могут поступать двух типов: на первичную закачку и докачку (в случае, если картинка уже попадала в очередь, начинала загружаться, а потом была остановлена). Причем Chrome именно докачивает недостающие данные, а не начинает качать заново. Если мы уже обещали загрузить поступающую в очередь картинку и ее ждут, то мы кладем ее в начало очереди. Если мы еще не начинали загружать ее, запросили первый раз, то — в конец очереди. Определить, была ли уже картинка частично скачана, можно по существованию объекта обещания о ее загрузке в addTask.
Стартер @runTasks() каждый раз проверяет есть ли невыполненные задания, есть ли кому их выполнять и не стоим ли мы на паузе. Если все так — работаем.
При постановке на паузу все запросы, которые находились в пути ( @enRoute) отменяются (task.xhr.abort()) и заново планируются к доставке в следующий раз. Это время наступит как только resume() перезапустит стартер заданий.
Я постаралась описать полный цикл контролируемой загрузки. Живой пример работы этой архитектуры можно посмотреть на галерее.
Демо для теста. Код демо для скачивания и экспериментов — на github'е.
Если при экспериментах с демо вы будете использовать другой сервис, предоставляющий картинки, то на нем необходимо будет настроить совместное использование ресурсов между разными источниками (Cross-origin resource sharing (CORS)), чтобы разрешить браузеру отдавать данные скрипту, загруженного с другого домена. В самом простом случае это означает, что веб-сервер должен возвращать в ответе заголовок Access-Control-Allow-Origin: *. Это будет говорить браузеру, что сервер разрешает скриптам с любых других доменов делать XHR-запросы. Подробнее можно прочитать на MDN.
Примеры кода записаны на coffeeScript
Задачи
- Загрузка всех картин витрины требует времени. Мгновенное появление всех — невозможно. А первоочередное появление картин, на которые сразу посмотрит пользователь, возможно.
Поэтому одной из задач была возможность осуществлять загрузку картин в нужной последовательности. Моя галерея визуально центроориентированная, следовательно порядок загрузки — центробежный, сначала грузятся картинки в центре экрана, а потом расходящимися кругами остальные. Таким образом, маленькие экраны заполняются достаточно быстро, а большие экраны позволяют получить доступ к управлению просмотром в кратчайшие сроки (элементы управления перемещением и переходом к детальному просмотру сосредоточены вокруг центральной картинки).
- Другой задачей была возможность приостанавливать загрузку картинок для страницы, с которой уходят, не дождавшись пока абсолютно все на ней загрузится, чтобы сразу начать грузить данные для страницы, на которую приходят. Для этого необходимо сделать паузу в посылке запросов, запомнить какие картины не догрузили, и после возвращения на предыдущую страницу возобновить загрузку.
Для этого была применена трехуровневая архитектура:
приложение -> менеджер загрузок -> асинхронный загрузчик
Уровень приложения
Приложение последовательно получает 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()
«Рикша» берет очередное задание и с помощью асинхронного загрузчика подтягивает изображение с сервера, получая 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'ов и прием бинарных данных картинки с последуюим размещением на «складе быстрого доступа».
Снаряжаем «рикшу» в новую поездку и устанавливаем обработчики ее состояний. Отмечаем, что ожидаем получить данные, доступные как объект 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.
Комментарии (7)
arielf
19.12.2015 00:54Прошу прощения, но немного не по-русски звучит —
Как художнице и web-разработчику, у меня со временем появилась необходимость в собственной галерее.
Лучше: У меня, как у художницы и web-разработчика, со временем появилась необходимость в собственной галерее.Nookie-Grey
21.12.2015 23:18Просто у автора своя манера мыслеизъяснения. Это скорее литературный стиль, чем отстранённость от языка.
Соглашусь, с первого раза читать трудновато.
Илюстрации великолепные! Интересно, вы действительно свободно рисуете как левой, так и правой или сменили кисть для баланса картины?ZvezdochkaIO
23.12.2015 16:04Левой рукой только мелочи поправляю, когда правая занята. Свободно, к сожалению, левой не рисую.
По поводу стиля, вы правы. Видимо, аналогии отошли слишком далеко от конкретики. Но я старалась больше для людей, которые, как и я, лучше воспринимают наглядно-образные описания, чем аналитические. Хотелось уйти от чукчеобразного комментирования, типа
А больше пояснять зачем мы так делаем.var a=5 # и теперь мы присваиваем переменной 5"
Плюс, много надежд было на код: «ведь люди, умеющие понимать код, могут понимать почти все» :)
vayho
А не думали использовать веб сокеты вместо XMLHttpRequest? Например поднять несколько соединений и гонять по ним Blob. Возможно по скорости это будет быстрее.
Кстати можно еще оптимизировать сразу подгружая центральную (или несколько вокруг) картинку чтобы пользователь не ждал.
ZvezdochkaIO
Может я не совсем понятно выразила мысль, но упорядоченная загрузка (в моем случае, сначала центральная картинка, потом те, что вокруг) — одна из задач, котоые мой подход решает.