В один прекрасный момент передо мной встала задача создать API для работы с файлами на клиенте и их загрузки на сервер.
Я работаю в Почте Mail.Ru, и моей прямой обязанностью является работа с JavaScript во всех его проявлениях. Прикрепление файлов к письму — одна из основных функций любой почты. Мы тут не исключение: у нас уже был Flash-загрузчик, который вполне исправно работал и долгое время нас устраивал. Однако у него был ряд недостатков. Вся верстка, графика, бизнес-логика, и даже локализация были зашиты в нем, в результате чего решение было громоздким, а внести правки мог только Flash-разработчик. В какой-то момент мы поняли, что нам необходим новый механизм. О том, как его создать, пойдет речь в этой статье.
Те, кто писал Flash-загрузчик, понимают, с какими проблемами приходится сталкиваться:
- cookies будут взяты из IE;
- настройки proxy — та же история;
- Error #2038, #2048,… — плавающая ошибка, зависит от сочетания сеть + браузер + Flash Player;
- AdBlock и т.п. — без комментриев.
И вот, глядя на всё это, мы решили: пора, момент настал — и сформировали следующие требования:
- множественный выбор файлов;
- получение информации (название, размер и mime-тип);
- создание предпросмотра изображения до загрузки;
- масштабирование, кадрирование и поворот на клиенте;
- загрузка всего, что получилось, на сервер + CORS;
- независимость от сторонних библиотек;
- расширяемость.
Последние 4 года всё активнее обсуждаются возможности HTML5, в том числе и File API. По этому поводу написано множество статей, есть работающие примеры. Казалось бы, вот готовый инструмент для решения поставленных задач. Но так ли все просто, как кажется на первый взгляд? Рассмотрим статистку пользователей Mail.Ru по браузерам. Из списка выбраны только те версии, который поддерживают File API, пусть и не на 100%.
Как видно из диаграммы, чуть более 63% используемых браузеров поддерживают File API:
- Chrome 10+ (~26%)
- Firefox 3.6+ (~19%)
- Opera 11.10+ (~17%)
- Safari 5.4+ (~1.4%)
Также не стоит забывать о мобильных устройствах, доля которых растет день ото дня. iOS 6 уже поддерживает File API.
Internet Explorer обещает поддержку с 10-ой версии.
Но 63% — это отнюдь не 100%. Так что отказываться от Flash’а рано.
Таким образом, задача свелась к созданию механизма, который совмещал бы в себе обе технологии (File API и Flash) и был реализован так, чтобы конечному разработчику было неважно, как происходит загрузка файлов. В ходе работы возникла идея оформить все наработки в виде отдельной библиотеки (унифицированного API), которая бы работала независимо от окружения и могла использоваться не только в рамках Почты Mail.Ru, но и где угодно.
Рассмотрим на конкретных примерах, как проходил процесс разработки.
Получение списка файлов
Вот так выглядит получение списка файлов на HTML5. Всё очень просто.
<input id="file" type="file" multiple />
<script>
var input = document.getElementById("file");
input.addEventListener("change", function (){
var files = input.files;
}, false);
</script>
Но что делать, когда не поддерживается File API, но зато поддерживается Flash? Основной принцип работы с Flash в том, что всё взаимодействие происходит непосредственно через него. Нельзя просто взять и вызвать диалог выбора файлов.
Для этого необходимо, чтобы пользователь кликнул по Flash. Только в этот момент можно открыть диалог — такова политика безопасности.
Поэтому Flash-объект размещается над нужным input. Сделано это очень просто: на весь документ вешается обработчик события mouseover, и при наведении на input[type=«file»] в “родитель” публикуется Flash-объект и занимает всё его пространство.
При клике по флешке открывается файловый диалог, пользователь в нем что-то выбирает и кликает ОК. После чего данные передаются от Flash в JS посредством ExternalInterface. JS связывает полученные данные с нужным input и эмулирует событие «change».
[[Flash]] --> jsFunc([{
id: "346515436346", // уникальный идентификатор
name: "hello-world.png", // название файла
type: "image/png", // mime-type
size: 43325 // рамер
}, {
// etc.
}])
Все дальнейшее взаимодействие между JS и Flash будет осуществляться через единственный доступный метод у Flash-объекта. Первым аргументом передается название команды, вторым — объект параметров с двумя обязательными полями: id файла и callback. Callback будет вызван из Flash по завершении команды.
flash.cmd("imageTransform", {
id: "346515436346", // идентификатор файла
matrix: { }, // матрица трансформации
callback: "__UNIQ_NAME__"
});
После совмещения двух способов получилось API, максимально приближенное к Native JS. Единственное различие — это способ получения файлов. Теперь мы используем метод API, т.к. свойство files у input есть только в том случае, когда браузер поддерживает HTML5/File API; в случае с Flash cписок берется из связанных с ним данных.
<span class="js-fileapi-wrapper" style="position: relative">
<input id="file" type="file" multiple />
</span>
<script>
var input = document.getElementById("file");
FileAPI.event.on(input, "change", function (){
var files = FileAPI.getFiles(input);
});
</script>
<span class="js-fileapi-wrapper" style="position: relative">
<input id="file" type="file" multiple />
</span>
<script>
var input = document.getElementById("file");
FileAPI.event.on(input, "change", function (evt){
var files = FileAPI.getFiles(evt);
});
</script>
Фильтрация
Как правило, при загрузке файлов есть ряд ограничений. Одни из самых популярных — размер файла, тип и ширина/высота изображения. Стандартная схема решения таких задач: сначала загрузить файл на сервер, а после валидации сообщить юзеру, что файл не подошел, «попробуйте ещё раз». Создавая метод фильтрации, я постарался решить эту проблему, дав возможность оперировать во время фильтрации более детальной информацией о файле.
В чем же сложность? Вся соль в том, что изначально после получения списка файлов мы имеем только минимальные сведения, такие как название, размер и тип. Для того, чтобы получить более детальную информацию, файл нужно прочесть. Это можно сделать через FileReader.
// Если вы не знали, IE10 поддерживает HTML5/FileAPI:
var reader = new FileReader;
reader.readAsBinaryString(file); // error: Object doesn't support method or property "readAsBinaryString"
// Но, выход есть!
var reader = new FileReader;
reader.onload = function (evt){
var base64 = evt.result.replace(/^data:[^,]+,/, '');
var binaryString = window.atob(base64); // bingo!
};
reader.readAsDataURL(file);
В итоге получился следующий метод фильтрации:
FileAPI.filterFiles(files, function (file, info){
if( /^image/.test(file.type) ){
return info.width > 320 && info.height > 240;
} else if( file.size ){
return file.size < 10 * FileAPI.MB;
} else {
// Увы, нет поддержки File API и Flash, валидировать придется на сервере.
// Случай очень редкий, но в рамках большого проекта, приходиться учитывать и его.
return true;
}
}, function (files, ignore){
if( files.length > 0 ){
// ...
}
});
Также “из коробки” поддерживается определение высоты и ширины изображения, а ещё есть возможность самому реализовать сбор нужной информации:
FileAPI.addInfoReader(/^audio/, function (file, callback){
// собираем нужную информацию
// и возвращаем её
callback(
false, // или текст ошибки
{ artist: "...", album: "...", title: "...", ... }
);
});
Работа с изображениями
В процессе создания API хотелось получить инструмент по работе с изображением — например, с созданием предпросмотра — и чтобы базовая функциональность поддерживалась HTML5 и Flash.
Flash
Первым делом нужно было понять, как это сделать через Flash, т.е. что предать в JS, чтобы построить изображение. Как вы поняли, это осуществляется при помощи Data URI. Flash читает файл как Base64, передает в JS. В начало добавляем «data:image/png;base64,» и используем полученную строку в качестве «src».
Happy end? Увы, но в IE6-7 нет поддержки Data URI, а IE8+, поддерживающий Data URI, не обрабатывает больше 32 КБ. В этих случаях JS публикует вторую флешку, которой передает Base64, а она восстанавливает изображение.
HTML5
Тут вам нужно сначала получить оригинал, а потом через Сanvas провести необходимые трансформации. Оригинал можно получить двумя способами. Первый — прочесть файл как DataURL при помощи FileReader. Второй — URL.createObjectURL создает ссылку на файл, связанную с текущим табом. Конечно для создания предпросмотра достаточно второго способа, но не все бразуеры его поддерживают. А некоторые не поддерживают сопутствующий URL.revokeObjectURL, который сообщает браузеру, что больше не нужно держать ссылку на файл.
После совмещения всех этих способов получился класс FileAPI.Image:
- crop(x, y, width, height) — кадрирование;
- resize(width[, height]) — масштабирование;
- rotate(deg) — поворот;
- preview(width, height) — кадрирует и масштабирует;
- get(callback) — получить итоговое изображение.
Все эти методы заполняют матрицу трансформации, и только при вызове метода get, она будет применена. Трансформация происходит через Canvas или внутри Flash, когда работает через него.
{ // параметры фрагмента оригинала
sx: Number,
sy: Number,
sw: Number,
sh: Number,
// требуемые размеры
dw: Number,
dh: Number,
deg: Number
}
FileAPI.Image(imageFle)
.crop(300, 300)
.resize(100, 100)
.get(function (err, img){
if( !err ){
images.appendChild(img);
}
})
;
Процесс ресайза
В нашу жизнь уже давно вошли зеркалки и просто «мыльницы», которые при цене в 1.2К рублей выдают от 10Mpx. И когда мы начали сжимать такие картинки, то получили примерно это:
Как видите, ничего хорошего — сплошные искажения. Но, если сжимать в два раза, потом ещё и ещё, пока не получим требуемый размер, то результат заметно лучше.
Вот, сравните, разница налицо:
Если добавить немного шарпа, будет просто идеально.
Еще мы пробовали другие способы, такие как бикубическая интерполяция и алгоритм Ланцоша. Они дают чуть лучший результат, но очень медленные: 1.5s против 200-300ms. Также данный метод дает одинаковый результат в Canvas и Flash.
Загрузка файлов
Сделаю резюме тех способов, которыми сейчас можно загрузить файл на сервер.
iframe
Да, и спустя года он всё ещё в строю:
<form
target="__UNIQ__"
action="/upload"
method="post"
enctype="multipart/form-data">
<iframe name="__UNIQ__"></iframe>
<input name="files" type="file" />
<input name="foo" value="bar" type="hidden" />
</form>
Сначала создаётся форма-транспорт с iframe внутри (атрибут target формы и name iframe должны совпадать). Потом в неё нужно переместить input[type=«file»], т.к. если поместить клон, он будет «пустым». Именно поэтому на события подписываемся через методы API, чтобы сохранить их при клонировании. После чего вызываем form.submit() и всё содержимое формы отправляется через iframe. Ответ получаем при помощи JSONP.
Flash/SIlverlight
Сначала появился Flash, за ним Silverlight, который собирался стать убийцей Flash, но что-то не срослось. В общем, там всё просто: JS вызывает метод у Flash-объекта и передаёт id файла, который нужно загрузить, а Flash, в свою очередь, все состояния и события дублирует в JS.
XMLHttpRequest + FormData
Теперь можно отправить не просто тестовые данные, но и бинарные. Делается это очень просто:
// собираем данные для отправки
var form = new FormData
form.append("foo", "bar"); // первый параметр название POST-параметра,
form.append("attach", file); // второй строка, файл или blob
// отправояем на сервер
var xhr = new XMLHttpRequest;
xhr.open("POST", "/upload", true);
xhr.send(form)
Но что делать, когда вам нужно отправить не файл, а, например, Canvas? Тут есть два пути. Первый, он же самый правильный и простой, это, конечно же, преобразовать Canvas в Blob:
canvasToBlob(canvas, function (blob){
var form = new FormData
form.append("foo", "bar");
form.append("attach", blob, "filename.png"); // не все поддерживают третий параметр
// ...
});
Как вы поняли, не везде есть возможность провернуть такой фокус. Если у Canvas отсутствует метод Canvas.toBlob (либо его нельзя реализовать), идем другим путем. Он также подходит для тех браузеров, которые не поддерживают FormData.
Суть этого способа в том, чтобы руками составить multipart-запрос и отправить его на сервер. Для Canvas код будет выглядеть следующим образом:
var dataURL = canvas.toDataURL("image/png"); // или результат чтения FileReader
var base64 = dataURL.replace(/^data:[^,]+,/, ""); // отрезаем начало
var binaryString = window.atob(base64); // разворачиваем Base64
// а теперь собираем muptipart, ничего сложногоЖ
var uniq = '1234567890';
var data = [
'--_'+ uniq
, 'Content-Disposition: form-data; name="my-file"; filename="hello-world.png"'
, 'Content-Type: image/png'
, ''
, binaryString
, '--_'+ uniq +'--'
].join('rn');
var xhr = new XMLHttpRequest;
xhr.open('POST', '/upload', true);
xhr.setRequestHeader('Content-Type', 'multipart/form-data; boundary=_'+uniq);
xhr.sendAsBinary(data);
if( xhr.sendAsBinary ){
// ...
}
else {
var bytes = Array.prototype.map.call(data, function(c){ return c.charCodeAt(0) & 0xff; });
xhr.send(new Uint8Array(bytes).buffer);
}
В итоге родился метод:
var xhr = FileAPI.upload({
url: '/upload',
data: { foo: 'bar' },
headers: { 'Session-Id': '...' },
files: { images: imageFiles, others: otherFiles },
imageTransform: { maxWidth: 1024, maxHeight: 768 },
upload: function (xhr){},
progress: function (event, file){},
complete: function (err, xhr, file){},
fileupload: function (file, xhr){},
fileprogress: function (event, file){},
filecomplete: function (err, xhr, file){}
});
У него куча параметров, но особое внимание я уделю imageTransform. Через него задается информация для трансформации изображений на клиенте. Работает как через Flash, так и через HTML5. Но это ещё не всё: также imageTransform может быть множественным:
{
huge: { maxWidth: 800, maxHeight: 600, rotate: 90 },
medium: { width: 320, height: 240, preview: true },
small: { width: 100, height: 120, preview: true }
}
Т.е. помимо оригинала на сервер уйдет ещё и три его копии. Зачем? Моё мнение таково: если вы можете перенести нагрузку с сервера на клиент — сделайте это. На сервере должна остаться только минимальная валидация входящих данных.
Также функция upload возвращает xhr-образный объект, т.е. он реализует некоторые свойства и методы XMLHttpRequest, такие как:
- status — HTTP status code
- statusText — HTTP status text
- responseText — ответ сервера
- getResponseHeader(name) — получить заголовок ответа сервера
- getAllResponseHeaders() — получить все заголовки
- abort() — отменить загрузку
Хотя HTML5 и умеет загружать файлы одним запросом, стандартный механизм Flash позволяет грузить только по одному. Кроме того, грузить все сразу не очень удачная идея — пользователь может и передумать.
Так вот, тот xhr, который возвратил upload, на самом деле является proxyXHR. Его методы и свойства отражают состояния именно для того файла, который грузится в данный момент. Если пользователь решил отменить загрузку, то действие будет выполнено для файла, который загружается в данный момент.
Эпилог
В завершение, я хочу показать вам маленький пример загрузки файлов при помощи drag'n'drop:
<div id="el" class="dropzone"></div>
<script>
if( FileAPI.support.dnd ){
// элемент, куда можно кинуть файлы
var el = document.getElementById("el");
// подписываемся на события связанные Drag'n'Drop
FileAPI.event.dnd(el, function (over){
// этот метод, будет срабатывать при enter/leave на элемент
if( over ){
el.classList.add("dropzone_hover");
} else {
el.classList.remove("dropzone_hover");
}
}, function (dropFiles){
// Пользователь бросил файлы
FileAPI.upload({
url: "/upload",
files: { attaches: dropFiles },
complete: function (err, xhr){
if( !err ){
// файлы загружены
}
}
});
});
}
</script>
Библиотека лежит на github, багрепорты и pull requests приветствуются.
Полезные ссылки
— https://github.com/mailru/FileAPI (demo)
— Mail.ru github (Tarantool, fest и многое другое)
— input[type=«file» multiple]
— File API support
— FileReader
— URL.createObjectURL, URL.revokeObjectURL
— XMLHttpRequest
— FormData
Автор: RubaXa