Пишем модерновый аплодер к jQuery
kayo — Пт, 05/11/2010 - 12:47
В настоящее время отправка медиаконтента имеет важное значение в сферах, использующих технологии гипертекста, однако реализация этой возможности исключительно стандартными средствами до недавнего времени оставляла желать лучшего в плане удобства для конечных пользователей. Элемент формы INPUT получил значение атрибута TYPE="FILE" ещё в HTML3, с тех пор мало что изменилось, основные продвижки произошли лишь недавно с появлением HTML5. Последние версии браузеров уже поддерживают многие из новых возможностей, которые могут быть уже применены на практике, что мы и попытаемся сделать в статье, разработав jQuery plugin модерновой загрузки файлов. Также мы не откажемся от поддержки альтернативных технологий, чтобы не оставить не очень передовых пользователей совсем ни с чем.
Вводные слова
Отправка контента всегда была узким местом WEB-а. Это и понятно, ведь изначально технологии гипертекста разрабатывались прежде всего для получения содержимого многими пользователями по запросу. Само же содержимое должно быть сформировано иными средствами. Однако времена менялись, глобальная вычислительная сеть росла во многом благодаря расширению сфер применения технологии гипертекста. Чтобы обеспечивать растущие потребности в двустороннем обмене появились формы. Так в настоящее время основной контент сети формируется непосредственно в программах просмотра гипертекста, а возможность отправки медиаконтента стала во многом ключевой.
Впервые элемент формы INPUT получил новый TYPE="FILE" в HTML 3, который вышел в окончательной редакции 3.2 в 1997 году, что дало возможность прикреплять любые файлы при заполнении форм, однако удобство передачи таким образом медиаконтента оставляло желать лучшего. Странно то, что ситуация оставалась неизменной на протяжении всех этих лет. Это привело к тому, что WEB-инженеры стали прибегать к иным нестандартным технологиям, вроде SWF, чтобы обеспечить больше юзабильности пользователям своих проектов. Только в HTML 5 появились какие-то существенные продвижки в этом направлении.
В последних версиях браузеров появилась возможность выбора для загрузки нескольких файлов одновременно и доступ к данным файловых объектов непосредственно из ECMAScript. Мы будем разрабатывать jQuery plugin, который будет использовать эти возможности, если они доступны, и пытаться делать что-то другое в противном случае.
Архитектура плагина
Программа будет состоять из двух уровней:
- Уровень обработки событий и управления загрузкой
- Уровень осуществления загрузки
Первый будет взаимодействовать с пользовательским интерфейсом, отлавливать и обрабатывать определённые события, обращаясь в нужные моменты ко второму уровню. Последний в свою очередь может поддерживать несколько вариантов реализации, один из которых будет выбран путём проверки особенностей среды исполнения единовременно при первом обращении. Мы реализуем поддержку следующих вариантов (в порядке предпочтительности от большей к меньшей):
- HTML 5
- Google Gears
- Какие-то ещё
Процесс работы плагина мы условно разделим на две относительно независимых составляющих:
- Добавление файлов в очередь загрузки (и возможно удаление из неё). Осуществляется при появлении соответствующих событий браузера: переносе файлов в область загрузки либо выборе их в соответствующем диалоге.
- Выборка файлов из очереди и загрузка их на сервер. Осуществляется непосредственно при появлении новых файлов в очереди (если включена соответствующая опция) либо при появлении события upload.
Самое время набросать общую структуру плагина.
(function($){ var $$ = $.modernupload = { options: {}, // Значения параметров по умолчанию general: {}, // Реализация событий верхнего уровня variant: {} // Варианты реализации нижнего уровня }; })(jQuery);
Наш плугин будет вызывать пользовательские обработчики на разных этапах своей работы. Зададим обработчики по умолчанию, которые могут быть переопределены, либо оставлены в зависимости от потребностей приложения, а также другие опции.
$.extend($$.options, { // Заполняем значения параметров по умолчанию adopted: function(file, data){}, // Файл добавлен в очередь загрузки rejected: function(file, data){}, // Файл отклонён started: function(file, data){}, // Начата загрузка файла uploaded: function(file, data){}, // Успешно завершена загрузка файла failed: function(file, data){}, // Загрузка файла не удалась progress: function(file, data){}, // Отображение текущего прогресса /* addition: function(data){ return {}; } */ // Если требуется добавить некоторые данные в запрос, нужно вернуть объект с парами 'ключ': значение actionurl: null, // URL для асинхронных запросов extensions: null, // Допустимые расширения файлов extensions_regexp: null, // то же в виде регулярного выражения (более приоритетно), mimetypes: null, // Допустимые mime-типы файлов mimetypes_regexp: null, // то же в виде регулярного выражения (более приоритетно), maxfiles: null, // Сколько всего файлов может быть выбрано maxsize: null, // Максимальный допустимый размер каждого файла // (например связанный с ограничениями сервера) immediate: false // Запускать ли загрузку сразу же при добавлении файлов });
Для определённости зададимся следующей структурой реализации вариантов нижнего уровня:
$.extend($$.variant, { <название_варианта>: { probe: function(){ // Проверка возможности применения // Возвращаем истину в случае успеха }, init: function(t, h, v){ // Инициализация экземпляра // t - jQuery выборка, h - экземпляр загрузчика // v - тип применения (dnd, mlt, ...) // Здесь можно как-то видоизменить инициализируемый объект // Например добавить обработчик события или модифицировать значение свойства }, apply: function(e, h){ // Применение события // e - событие, h - экземпляр загрузчика // Вызывается при возникновении события // Обрабатываем добавление файлов }, upload: function(f, h){ // Выполнение загрузки // f - файловый объект, h - экземпляр загрузчика // Вызывается для осуществления загрузки // Осуществляем загрузку } } });
Реализация верхнего уровня
Для начала реализуем функцию, которая будет осуществлять выбор наиболее подходящего варианта для осуществления загрузки. Для этого она будет обращаться к функциям проверки реализаций нижнего уровня и анализировать возвращаемое ими значение.
$.extend($$.general, { probe: function(){ if(typeof $$.general.variant == 'object'){ // Подходящий вариант уже выбран ранее return true; } for(var n in $$.variant){ if($$.variant[n].probe()){ // Если подходит, выбираем $$.general.variant = $$.variant[n]; return true; } } return false; } });
При применении плагина с ним будет связан некий экземпляр загрузчика, который будет создаваться, либо приниматься в качестве параметра. Каждый экземпляр загрузчика имеет свою очередь файлов и работает независимо от других. В программе экземпляр будем представлять переменной с именем data. Инициализацией экземпляра будет заведовать следующая функция:
$.extend($$, { prepare_data: function(options, data){ if(typeof data != 'object'){ data = {}; // Создаём новый экземпляр если не создан } if(!(data.queue instanceof Array)){ data.queue = []; // Инициализируем очередь файловых объектов } if(!(data.infos instanceof Array)){ data.infos = []; // Инициализируем очередь информации о файлах } if(typeof data.opts != 'object'){ // Инициализируем опции data.opts = $.extend(true, {}, $$.options, options); } return data; } });
Далее нам потребуются два метода: первый будет вызываться при возникновении события, связанного с появлением новых файлов, второй будет вызван для добавления нового файла в очередь загрузки. Он будет проверять, удовлетворяет ли выбранный файл условиям допустимости, в случае успеха файл будет добавлен в очередь, иначе в его загрузке будет отказано.
$.extend($$.general, { apply: function(data, event){ // Обработка события появления новых файлов $$.general.variant.apply(event, data); if(data.opts.immediate){ $$.general.upload(data); } }, append: function(data, file, info){ // Добавление файлового объекта в очередь // data - экземпляр загрузчика // file - файловый объект для добавления в очередь // info - информация о файле var error = []; // коллектор ошибок if(data.opts.extensions || data.opts.extensions_regexp){ // проверка расширения if(!data.opts.extensions_regexp){ var extensions = data.opts.extensions; if(extensions instanceof Array){ extensions = data.opts.extensions.join('|'); } data.opts.extensions_regexp = new RegExp('\\.('+extensions+')$'); } if(!data.opts.extensions_regexp.test(info.name)){ error.push('extension'); } } if(data.opts.mimetypes || data.opts.mimetypes_regexp){ // проверка mime-типа if(!data.opts.mimetypes_regexp){ var mimetypes = data.opts.mimetypes; if(mimetypes instanceof Array){ mimetypes = data.opts.mimetypes.join('|'); } data.opts.mimetypes_regexp = new RegExp('^('+mimetypes+')$'); } if(!data.opts.mimetypes_regexp.test(info.type)){ error.push('mimetype'); } } if(data.opts.maxsize){ // проверка размера if(info.size > data.opts.maxsize){ error.push('maxsize'); } } if(data.opts.maxfiles){ // проверка числа файлов if(data.infos.length >= data.opts.maxfiles){ error.push('maxfiles'); } } if(error.length){ // проверка наличия ошибок info.error = error; data.opts.rejected(info, data); // файл отброшен return false; } data.queue.push(file); data.infos.push(info); data.opts.adopted(info, data); // файл принят return true; } });
Метод append вызывается с нижнего уровня из обработчика apply для каждого найденного в просматриваемом событии файлового объекта.
Теперь реализуем метод запуска загрузки и обработки результата:
$.extend($$.general, { upload: function(data){ // Старт загрузки if(data.queue.length > 0 && !data.inprocess){ // Очередь не пуста и ещё не стартовали var file = data.queue.pop(), info = data.infos.pop(); // Выбираем объекты из очереди $$.general.variant.upload(file, data); // Запускаем загрузку на нижнем уровне $$.general.progress(data, info); // Инициализируем обработчик прогресса data.opts.started(info, data); // Сообщаем о старте data.inprocess = true; // Уже в процессе загрузки return true; } return false; }, uploaded: function(data, result){ // Завершение загрузки var info = data.proc; // Выбираем информацию о загруженном вайле data.inprocess = false; // Теперь не в процессе data.proc = null; // Обнуляем информацию о загруженном файле info.result = result; // Пишем возвращённое значение if(result.state != 'success'){ data.opts.failed(info, data); // Сообщаем об ошибке загрузки }else{ data.opts.uploaded(info, data); // Сообщаем об успешном выполнении } return $$.general.upload(data); // Пробуем загружать следующий файл } });
Заботимся также об обработке прогресса:
$.extend($$.general, { now: function(a){ // Текущий момент в секундах с начала эпохи unix var d = new Date(), t = d.getTime() / 1000; delete d; return t; }, progress: function(data, arg, all){ // Инициализация и обновление состояния прогресса if(typeof arg == 'object'){ // Инициализируем data.proc = $.extend(arg, { cnt: 0, // Загружено байт all: arg.size, // Всего байт prc: 0, // Процент загрузки bps: 0, // Байт в секунду cts: $$.general.now() // Текущий момент времени }); return; } var p = data.proc; if(arg === true){ // Завершаем arg = p.size; } if(typeof arg == 'number'){ // Обновляем прогресс var cnt = p.cnt, cts = p.cts; p.cnt = arg; p.cts = $$.general.now(); p.bps = (p.cnt - cnt) / (p.cts - cts); if(typeof all == 'number'){ p.all = all; } p.prc = Math.round(100 * p.cnt / p.all); } if(arg === false){ // failed p.err = true; } data.opts.progress(p, data); // Сообщаем о прогрессе }, });
Метод progress должен вызываться с нижнего уровня для обновления информации о числе загруженных байт.
Плагин будет применяться к объектам разными способами, соответственно нам необходимо предусмотреть возможность применять к объектам различные обработчики событий для каждого из способов, но оставить также и общие обработчики, применяемые при любом способе.
Для начала мы осуществим два способа приёма файлов:
- DND - файлы кидаются в область в окне браузера и, тем самым, добавляются в очередь загрузки.
- MLT - файлы выбираются с помощью стандартного диалога выбора файла (INPUT TYPE="FILE"), расширяемого атрибутом multiple
Реализуем обработчик-заглушку для предотвращения обработки событий, которые нам не нужны:
$.extend($$, { dummy_event: function(e){ return false; } });
Реализуем функцию прикрепления обработчиков к объектам:
$.extend($$, { bind_events: function(self, type){ for(var n in $$.general.event){ if(typeof $$.general.event[n] == 'function'){ self.bind(n, $$.general.event[n]); // Прикрепляем общий обработчик }else if(n == type){ for(var k in $$.general.event[n]){ if(typeof $$.general.event[n][k] == 'function'){ self.bind(k, $$.general.event[n][k]); // Прикрепляем обработчик для выбранного способа } } } } } });
Теперь зададим обработчики событий:
$.extend($$.general, { event: { dnd:{ // События для DND способа dragover: $$.dummy_event, // Файл перемещается над объектом dragenter: function(e){ // Файл внесён в область объекта $(this).addClass('dndupload-hover'); return false; }, dragleave: function(e){ // Файл вынесен из области объекта $(this).removeClass('dndupload-hover'); return false; }, drop: function(e){ // Файл кинут в область объекта var self = $(this), data = self.data('-modern-upload-'); self.removeClass('dndupload-hover'); $$.general.apply(data, e); return false; } }, _dnd:{ // События для предотвращения обработки DND dragover: $$.dummy_event, dragenter: $$.dummy_event, dragleave: $$.dummy_event, drop: $$.dummy_event }, mlt:{ // События для MLT способа change: function(e){ // Выбраны файлы var self = $(this), data = self.data('-modern-upload-'); $$.general.apply(data, e); return false; } }, // Общие события upload: function(e){ // Начать загрузку var self = $(this), data = self.data('-modern-upload-'); $$.general.upload(data); return false; } } });
Реализация нижнего уровня
HTML5 загрузка
В ECMAScript движке браузеров на WebKit реализован прототип FormData, позволяющий генерировать формы, заполнять их данными и отправлять. Но в то же время основанные на Gecko браузерах раньше Firefox 4 прототип FormData не реализован, однако имеется возможность формировать и отправлять запросы в двоичном виде. Эту особенность нам необходимо учесть в первую очередь. Второе, что нам потребуется, прототип FileReader, работать с которым напрямую необходимо будет лишь в реализации без FormData. Отправлять данные мы будем используя объект XMLHttpRequest в обоих случаях, отличие будет лишь в вызове метода send для отправки FormData и sendAsBinary -для сырых данных запроса.
$.extend($$.variant, { html5: { probe: function(){ // Проверяем имеется ли прототип FileReader return (window.FileReader || window.FormData); }, init: function(t, h, v){ if(v == 'mlt'){ t.attr('multiple', 'multiple'); // Делаем доступным выбор нескольких файлов } }, apply: function(e, h){ var d = false, o = e.originalEvent; if(o.dataTransfer){ // Файлы переданы непосредственно через Drag'n'Drop d = o.dataTransfer.files; }else if(o.target && o.target.files){ // Файлы выбраны черед диалог выбора файлов d = o.target.files; } for(var i = 0; i < d.length; i++){ var f = d[i]; $$.general.append(h, f, { name: f.name, type: f.type, size: f.size }); } }, rawbinary_construct: function(x, f, h, e){ // Формирование данных в двоичном виде var d = [], b = '----modernUploadBoundary' + Math.round($$.general.now() * 1000), _b = '--' + b, _b_ = _b + '--'; x.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + b); // Добавляем дополнительные данные в форму if(typeof h.opts.addition == 'function'){ var a = h.opts.addition(h); for(var n in a){ var s = a[n]; d.push(_b); d.push('Content-Disposition: form-data; name="' + n + '"'); d.push(''); d.push(unescape(encodeURIComponent(s))); // Такая хитрая перекодировка нужна, чтобы передать строки в юникоде } } // Добавляем сам файл d.push(_b); d.push('Content-Disposition: form-data; name="' + h.opts.fieldname + '"; filename="' + encodeURIComponent(f.name) + '"'); d.push('Content-Type: ' + f.type); d.push(''); d.push(e.target.result); // Завершаем запрос d.push(_b_); // Отправляем в двоичном виде x.sendAsBinary(d.join('\r\n')); return true; }, formdata_construct: function(x, f, h){ // Формирование запроса через FormData var d = new FormData(); // Добавляем дополнительные данные в форму if(typeof h.opts.addition == 'function'){ var a = h.opts.addition(h); for(var n in a){ d.append(n, a[n]); } } // Добавляем сам файл d.append(h.opts.fieldname, f); // Отправляем форму x.send(d); return true; }, upload: function(f, h){ var x = new XMLHttpRequest(); // Создаём запрос // Устанавливаем обработчик прогресса x.upload.onprogress = function(e) { $$.general.progress(h, e.loaded, e.total); }; // Устанавливаем обработчик изменения статуса готовности запроса x.onreadystatechange = function() { if (x.readyState == 4) { if(x.status == 200) { var r = { data: x.responseText, status: x.status, state: 'success' }; $$.general.uploaded(h, r); } else { var r = { data: x.responseText, status: x.status, state: 'error' }; $$.general.uploaded(h, r); } } }; x.open('POST', h.opts.actionurl, true); if(typeof window.FormData == 'function'){ $$.variant.html5.formdata_construct(x, f, h); }else if(typeof x.sendAsBinary == 'function'){ var r = new FileReader(); // Создаём ридер r.onloadend = function(e){ $$.variant.html5.rawbinary_construct(x, f, h, e); }; r.readAsBinaryString(f); } } } });
Gears загрузка
Плагин Gears - проект с открытым исходным кодом, разрабатываемый для того, чтобы добавить поддержку новых возможностей в старые версии пользовательских агентов. Gears работает во всем известном широко распространённом (пока ещё) браузере, а также в Mozilla Firefox и некоторых других. В варианте gears мы реализуем всё то, что работает в html5, включая загрузку drag'n'drop-ом и выбор сразу нескольких файлов.
$.extend($$.variant, { gears: { probe: function(){ // Проверку а заодно и инициализацию осуществляем по мотивам gears.init.js if (window.google && google.gears) { return true; } var factory = null; if (typeof GearsFactory != 'undefined') { // Firefox factory = new GearsFactory(); } else { try { // IE factory = new ActiveXObject('Gears.Factory'); // privateSetGlobalObject is only required and supported on IE Mobile on // WinCE. if (factory.getBuildInfo().indexOf('ie_mobile') != -1) { factory.privateSetGlobalObject(this); } } catch (e) { // Safari and etc. if ((typeof navigator.mimeTypes != 'undefined') && navigator.mimeTypes['application/x-googlegears']) { factory = document.createElement('object'); factory.style.display = 'none'; factory.width = 0; factory.height = 0; factory.type = 'application/x-googlegears'; document.documentElement.appendChild(factory); if(factory && (typeof factory.create == 'undefined')) { // If NP_Initialize() returns an error, factory will still be created. // We need to make sure this case doesn't cause Gears to appear to // have been initialized. factory = null; } } } } if (!factory) { return false; } if (!window.google) { google = {}; } if (!google.gears) { google.gears = {factory: factory}; } return true; }, init: function(t, h, v){ // Реализуем поддержку диалога выбора файлов Gears if(v == 'mlt'){ t.bind('click', function(){ var desktop = google.gears.factory.create('beta.desktop'), filter = []; if(h.opts.mimetypes){ for(var i in h.opts.mimetypes){ filter.push(h.opts.mimetypes[i]); } } if(h.opts.extensions){ for(var i in h.opts.extensions){ filter.push('.' + h.opts.extensions[i]); } } desktop.openFiles(function(files) { if(files && files.length > 0){ t.trigger('change', [files]); } }, { filter: filter }); return false; }); } }, apply: function(e, h){ var desktop = google.gears.factory.create('beta.desktop'), d = e.originalEvent; if(d.target && d.target.files){ d = d.target.files; }else if((d = desktop.getDragData(d, 'application/x-gears-files')) && d.files){ d = d.files; }else{ return; } for (var i in d) { var f = d[i], m = desktop.extractMetaData(f.blob); $$.general.append(h, f, { name: f.name, type: m.mimeType, size: f.size || 0 }); f.m = m; } }, blobbuilder_construct: function(x, f, h){ // Будем использовать BlobBuilder из Gears var b = '----modernUploadBoundary' + Math.round($$.general.now() * 1000), _b = '--' + b, _b_ = _b + '--', _ = '\r\n', d = google.gears.factory.create('beta.blobbuilder'); // Устанавливаем заголовок x.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + b); // Добавляем дополнительные данные if(typeof h.opts.addition == 'function'){ var a = h.opts.addition(h); for(var n in a){ var s = a[n]; d.append(_b + _); d.append('Content-Disposition: form-data; name="' + n + '"' + _); d.append('' + _); //s = unescape(encodeURIComponent(s)); d.append(s); d.append(_); } } // Добавляем сам файл d.append(_b + _); d.append('Content-Disposition: form-data; name="' + h.opts.fieldname + '"; filename="' + encodeURIComponent(f.name) + '"' + _); d.append('Content-Type: ' + f.m.mimeType + _); d.append('' + _); d.append(f.blob); d.append(_); // Завершаем запрос d.append(_b_ + _); x.send(d.getAsBlob()); return true; }, upload: function(f, h){ // Загрузка файла var x = google.gears.factory.create('beta.httprequest'); // Обработчик прогресса загрузки x.upload.onprogress = function(e) { $$.general.progress(h, e.loaded, e.total); }; // Обработчик изменения состояния запроса x.onreadystatechange = function() { if (x.readyState == 4) { if(x.status == 200) { var r = { data: x.responseText, status: x.status, state: 'success' }; $$.general.uploaded(h, r); } else { var r = { data: x.responseText, status: x.status, state: 'error' }; $$.general.uploaded(h, r); } } }; x.open('POST', h.opts.actionurl, true); $$.variant.gears.blobbuilder_construct(x, f, h); } } });
SWF загрузка
Возможно, скоро будет.
Финальные шаги
Теперь нам осталось доработать интерфейс плагина, чтобы его уже можно было начать использовать в реальных проектах. Тут можно поступить более или менее универсально, мы же пойдём промежуточным путём. Сперва определим общий метод применения плагина следующим образом:
$.extend($$, { apply_plugin: function(self, options, data, type){ // self - jQuery объект // options - настройки // data - экземпляр // type - тип использования if(!$$.general.probe()){ // Инициализируем вариант return false; } // Инициализируем экземпляр var h = $$.prepare_data(options, data); // Вызываем специальный инициализатор if(typeof $$.general.variant.init == 'function'){ $$.general.variant.init(self, h, type); } // Сохраняем ссылку на экземпляр self.data('-modern-upload-', h); // Прикрепляем обработчики событий $$.bind_events(self, type); return true; } });
Теперь определим собственно функции jQuery плагина:
$.extend($.fn, { dndupload: function(options, data){ // Делаем объект принимающим файлы посредством Drag'n'Drop var s = (typeof options == 'object') ? '' : '_'; // Если вызвано без опций, просто предотвращаем приём событий объектом // Позволяет предотвратить замену страницы содержимым файла будучи применённым к body $$.apply_plugin(this, options, data, s + 'dnd'); return this; }, mltupload: function(options, data){ // Делаем диалог выбора файлов более продвинутым this.attr('multiple', 'multiple'); $$.apply_plugin(this, options, data, 'mlt'); return this; } });
Не плохо бы реализовать вызов, показывающий, какие варианты загрузки доступны в пользовательском агенте. Это поможет, например, сообщить пользователю о возможных способах прикрепления медиафайлов, а также дать ему рекомендации, что необходимо сделать, чтобы получить эти возможности, если они не доступны:
$.extend($$, { support: function(){ if($$.general.support){ return $$.general.support; } $$.general.support = {}; for(var n in $$.variant){ $$.general.support[n] = $$.variant[n].probe(); } return $$.general.support; } });
Вот в принципе и всё. Теперь осталось написать документацию, привести примеры использования и опубликовать.
Вложение | Размер |
---|---|
jquery.modernupload_src.js_.bz2 | 3.59 КБ |

Отправить комментарий