- PVSM.RU - https://www.pvsm.ru -
Как художнице и web-разработчику, у меня со временем появилась необходимость в собственной галерее. Обычно, у галерей две основные функции: показ витрины — всех (или некоторых) картин — и детальный показ одной. Реализация обеих функций есть практически в любой готовой галерее. Но «заношенный» внешний вид готовых галерей и, ставший стандартом, пользовательский интерфейс не годятся для художника :). А нестандартный — требует особой архитектуры и реализации кода, осуществляющего загрузку и показ картин. Сам показ и пользовательский интерфейс я в этой статье опущу. Основное внимание будет отдано загрузке картин с сервера. Об итоговой организации контролируемой загрузки с использованием очередей, асинхронного загрузчика, обработки блоб-объектов, каскадов выполнения обещаний и с возможностью приостановки и пойдет речь.
Примеры кода записаны на coffeeScript
Для этого была применена трехуровневая архитектура:
приложение -> менеджер загрузок -> асинхронный загрузчик
[2]
Приложение последовательно получает url картинок, которые надо загрузить и отрисовать на экране. Способ, которым поставляются url'ы не интересен. Для каждой будущей картины приложение создает DOM-узел img или div с фоном.
imgNode = ($ '<div>')
.addClass('item' + num)
После чего дает задание менеджеру загрузок, передавая ему url картинки c сервера. Менеджер возвращает обещание (JQuery promise [3]), при выполнении которого мы получим url до экземпляра класса blob с данными загруженной картинки, хранящимися в памяти браузера (url поступит в imgBlobUrl). Это новая возможность, появившаяся в HTML5, позволяющая создавать url'ы [4] до экземпляров классов File или Blob [5], полученных в данном случае, в результате 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'ов и прием бинарных данных картинки с последуюим размещением на «складе быстрого доступа».
[7]
Снаряжаем «рикшу» в новую поездку и устанавливаем обработчики ее состояний. Отмечаем, что ожидаем получить данные, доступные как объект 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() [8]) и заново планируются к доставке в следующий раз. Это время наступит как только resume [9]() перезапустит стартер заданий.
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
Я постаралась описать полный цикл контролируемой загрузки. Живой пример работы этой архитектуры можно посмотреть на галерее [10].
Демо [11] для теста. Код демо для скачивания и экспериментов — на github'е [12].
Если при экспериментах с демо вы будете использовать другой сервис, предоставляющий картинки, то на нем необходимо будет настроить совместное использование ресурсов между разными источниками (Cross-origin resource sharing (CORS)), чтобы разрешить браузеру отдавать данные скрипту, загруженного с другого домена. В самом простом случае это означает, что веб-сервер должен возвращать в ответе заголовок Access-Control-Allow-Origin: *. Это будет говорить браузеру, что сервер разрешает скриптам с любых других доменов делать XHR-запросы. Подробнее можно прочитать на MDN [13].
Автор: Taggy.io
Источник [14]
Сайт-источник PVSM.RU: https://www.pvsm.ru
Путь до страницы источника: https://www.pvsm.ru/javascript/106635
Ссылки в тексте:
[1] Image: http://habrahabr.ru/company/taggy/blog/273415/
[2] Image: https://habrastorage.org/files/969/638/e84/969638e844394659a07a4962dbbdc2c8.jpg
[3] JQuery promise: https://api.jquery.com/deferred.promise/
[4] возможность, появившаяся в HTML5, позволяющая создавать url'ы: https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL
[5] классов File или Blob: https://developer.mozilla.org/en/docs/Web/API/Blob
[6] Image: https://habrastorage.org/files/6ee/482/695/6ee48269536545f0a0d499f4bea30506.jpg
[7] Image: https://habrastorage.org/files/ffe/772/938/ffe7729386f042feb9c0ee576eb2ecc8.jpg
[8] abort(): https://developer.mozilla.org/en-US/docs/Web/Events/abort
[9] resume: http://habrahabr.ru/users/resume/
[10] галерее: http://art.tonight.io
[11] Демо: http://zvezdochka.github.io/gallery_loader/
[12] github'е: https://github.com/Zvezdochka/gallery_loader/
[13] MDN: https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS
[14] Источник: http://habrahabr.ru/post/273415/
Нажмите здесь для печати.