Mail.ru Group corporate blog
Website development
JavaScript
6 November 2013

FileAPI 2.0: Загрузка файлов на сервер год спустя

FileAPI 2.0Привет Хабр! Примерно год назад я представил вашему вниманию первую версию open-source библиотеки FileAPI, предназначенную для работы с файлами на клиенте и последующей загрузки на сервер.

За это время был пройден долгий путь. Библиотека заработала 670+ звезд и 90+ форков. С помощью github-сообщества удалось исправить множество «детских» проблем и внести ряд улучшений. Было закрыто более 100 тасков, и благодаря Илье Лебедеву сделана загрузка файлов по частям. Сегодня я с гордостью хочу представить вам FileAPI 2.0.


Итак, первая версия обладала следующими возможностями:
  • множественный выбор файлов;
  • получение информации (название, размер и mime-тип);
  • генерация предпросмотра до загрузки;
  • масштабирование, кадрирование и поворот на клиенте;
  • загрузка всего, что получилось, на сервер + CORS;
  • поддержка всего вышеперечисленного в старых браузерах, включая IE6.


Для поддержки старых браузеров используется Flash. В отличие от других подобных решений, где нужно явным образом задать элемент, который будет кнопкой «Выбрать файлы», FileAPI не накладывает таких ограничений. Разработчику не нужно думать о том, какую технологию в данный момент использует библиотека. При этом написанный код максимально приближен к нативному, т.е. HTML5:

<span class="js-fileapi-wrapper">
    <input id="file" type="file" multiple />
</span>
<script>
    var input = document.getElementById("file");
    FileAPI.event.on(input, "change", function (){
        var list = FileAPI.getFiles(input); // Получаем список файлов
    
        // Загружаем на сервер
        FileAPI.upload({
             url: "./ctrl.php",
             files: { userFiles: list },
             complete: function (err, xhr){ /*...*/ }
        });
    });
</script>

Библиотека определят возможности браузера и, если чего-то не хватает, переключается на Flash.

Почти сразу после публикации я начал получать первые отзывы и пожелания, приведу самые интересные из них.

Ошибка при обфускации
У одного юзера код не работал, совсем. Проблема была в конструкции (api.expando + ++gid). Оказалось, его обфускатор не понимал её и просто убирал пробелы, что приводило к синтаксической ошибке, поэтому код пришлось изменить на (++gid, api.expando + gid).

Особенности интеграции с Amazon S3
При вызове метода FileAPI.upload к url, на который нужно сделать запрос, библиотека добавляет уникальный GET-параметр, чтобы избежать кеширования POST-пост запроса на мобильных устройствах. А при интеграции с Amazon S3 выяснилось, что он не допускает GET-параметры. Так как невозможно с достаточной точностью определить все мобильные устройства по user-agent, в upload добавлена опция cache, при помощи которой можно влиять на добавление уникального GET-параметра.

Работа с изображениями
Все изображения принудительно конвертировались в png, что приводило к увлечению размера файла на выходе, к тому же менялся исходный тип, что было критично для многих задач. Помимо этого, часто требуется добавить водяной знак в загружаемое изображение или сфотографировать «себя» при помощи WebCam.

«Загрузка» без файлов
Так как API создавалось для загрузки файлов, то метод FileAPI.upload выдавал ошибку при вызове его без самих файлов. Как оказалось, это достаточно частый случай. Например, когда у вас есть форма, в которой поле «файл» необязательно.

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

Собрав и проанализировав отзывы, был составлен план действий:
  • Grunt — инструмент для сборки кода;
  • QUnit — тестирование основного функционала (Grunt + PhantomJS);
  • Фичи — улучшенная работа с изображениями и WebCam;
  • jQuery plugin — супер-пупер плагин для типовых задач;
  • Документация — подробное описание методов и примеры.



Grunt


Как я уже говорил, в первый версии js собирался при помощи примитивного скрипта, который просто сливал и обфуцировал 6 файлов в один. Для того, чтобы вносить изменения или дебажить код, нужно было подключить 6 исходников в определенном порядке. Это неудобно, поэтому требовался инструмент для сборки проекта. В качестве такого инструмента был выбран Grunt, который де-факто является стандартом при разработке и сборке проекта. С его помощью мы не только собираем FileAPI, но и прогоняем ее код через JSHint и QUnit-тесты, о которых я расскажу дальше. Для того, чтобы начать использовать Grunt, достаточно создать два файла: package.json c описанием пакета и Gruntfile.js с перечислением нужных тасков и их опций.

Давайте подробно рассмотрим Gruntfile.js. Он состоит из 5 основных тасков:

jshint

Это ответвление от JSLint, валидатора для проверки корректности JavaScript-кода. В отличие от него, JSHint можно настроить под ваш код, чтобы следить за единым стилем оформления, проверять на пропущенные точку с запятой, лишние запятые в конце массива или объекта, неиспользуемые переменные и части кода.

concat

Собирает файлы в один. В FileAPI эта секция состоит из двух частей `all` и `html5`, что соответствует двум сборкам: c поддержкой flash и без неё, например, для мобильных проектов.

uglify

Обфускация кода, в нашем случае он сжимает файлы, полученные после concat.

watch

Т.к. библиотека состоит из нескольких файлов, а подключается только один, то данный таск следит за изменениями js файлов и запускает таск concat.

qunit

При помощи PhantomJS выполняет QUnit тесты, что позволяет протестировать базовый функционал.

Использовать grunt очень просто, но перед началом работы нужно установить необходимые зависимости. Делается это единожды, при помощи команды npm install.

Теперь мы можем запускать таски:
$ /FileAPI/ > grunt — выполнить default таск (jshint + build)
$ /FileAPI/ > grunt build — сборка и обфускация (concat + uglify + qunit)
$ /FileAPI/ > grunt watch — следить за изменениями и, в случае обнаружения таковых, запускать concat


Тестирование


Как вы уже поняли, для тестирования используется QUnit, мне он всегда нравился своей лаконичностью и простотой. К тому же, для него есть готовый grunt-таск. Тесты запускаются через PhantomJS, а во время разработки можно просто обновлять страницу и ждать результатов теста.

FileAPI + Qunit

Но, как оказалось, в стандартном grunt-таске нельзя привязать файлы к нужным мне для теста инпутам. Поэтому пришлось его немного модифицировать:

qunit: {
    options: {
         // Файлы: ключ — название инпута, значение — список файлов
         files: {
             one: ["foo.jpeg"],
             multiple: ["bar.txt", "baz.png", "qux.zip"]
         }
     },
     all: ["tests/*.html"]
}

Первыми тестируются методы работы с файлами. Такие как получение мета информации (название, тип, размер, exif), чтение содержимого (DataURL, BinaryString и Text). Далее загрузка, в ходе которой проверяются события и ответ от сервера.

Но самое интересное, это тестирование работы с изображениями, тут всё хитрей. Так как FileAPI «из коробки» умеет трансформировать изображения согласно заданным правилам, то нужно проверять, чтобы изображение, полученное сервером, было именно тем, которое нужно. Для этого используется два набора изображений: исходные, которые загружает библиотека, и эталонные, с которыми сравнивается результат. Как же это происходит? FileAPI загружает изображение с тестируемыми параметрами трансформации и в ответ получает dataURL. Данные передаются в assert-функцию, преобразуются в canvas и попиксельно сравниваются с эталонным изображением. Если расхождение меньше < 1%, то тест пройден.

function imageEqual(actual/**Image*/, expected/**Image*/, message/**String*/){
	actual = toCanvas(actual);
	expected = toCanvas(expected);

	if( actual.width != expected.width ){
		ok(false, message + ' (width: ' + actual.width + ' != ' + expected.width + ')');
	}
	else if( actual.height != expected.height ){
		ok(false, message + ' (height: ' + actual.height + ' != ' + expected.height + ')');
	}
	else {
		var actualData = actual.getContext('2d').getImageData(0, 0, actual.width, actual.height);
		var expectedData = expected.getContext('2d').getImageData(0, 0, expected.width, expected.height);
		var pixels = 0, failPixels = 0;

		for( var y = 0; y < actualData.height; y++ ){
			for( var x = 0; x < actualData.width; x++ ){
				var idx = (x + y * actualData.width) * 4;
				pixels++;
				if( !pixelEqual(actualData.data, expectedData.data, idx) ){
					failPixels++;
				}
			}
		}

		ok(failPixels / pixels < .01, text + ' (fail pixels: ' + (failPixels / pixels) + ')');
	}
}

function pixelEqual(actual, expected, idx){
	var delta = 3; // допустимая погрешность
	return (Math.abs(actual[idx] - expected[idx]) < delta)
	     && (Math.abs(actual[idx+1] - expected[idx+1]) < delta)
             && (Math.abs(actual[idx+2] - expected[idx+2]) < delta);
}

Особенность этого метода заключается в том, что эталонное изображение нужно делать под каждый браузер (Phantom, Firefox, Chrome). Это связанно c тем, что цветопередача и алгоритмы сжатия в каждом браузере свои. Забавная ситуация произошла с Safari. Изначально я сохранял эталонные изображения средствами браузера, а не на серверной стороне. Так вот, в Safari изображение, построенное на основе dataURL и сохраненное диск, не совпадает с тем, что вы видите на экране, цвета искажены.

Увы, это только функциональное тестирование, которое очень сильно помогает, но не может заменить ручное там, где используется Flash. Помимо этого есть идея создать grunt-таск, который запустит QUnit тесты через Selenium, вот тогда заживем.


Фичи


Grunt, тестирование — это, конечно, хорошо, но никак не тянет на версию 2.0, хотелось чего-то эдакого.

Image overlay

Примерно через месяц после публикации, у меня спросили, как при помощи библиотеки наложить watermark на изображение и загрузить результат на сервер? Эту задачу можно было решить, предоставив прямой доступ к canvas, через который происходят трансформации (как это сделано в jQuery FileUpload). Но, увы, существуют IE ниже 10 версии, где все трансформации иду через Flash, поэтому решено было создать метод, который позволял бы делать любое количество наложений с гибкой системой позиционирования:

FileAPI.Image(file)
    .overlay([
         {
             x: 10, y: 5,    //  смещение
             rel: FileAPI.Image.RIGHT_BOTTOM, // центр
             opacity: 0.7, // степень прозрачности, от 0 до 1
             src: "watermark.png" // накладываемое изображение
         }
    ])
    .get(err/**Mixed*/, img/**HTMLElement*/){ /*__*/  })
;

Также, одноименное свойство поддерживается в imageTransform:

FileAPI.upload({
     imageTransform: {
           overlay: {
                x: 5, y: 5,
                rel: FileAPI.Image.CENTER_TOP,
                src: "watermark.png" // накладываемое изображение
           }
     }
});

Как видите, метод получился простой, но гибкий.


WebCam


Главным нововведением стала работа с веб-камерой. Для этого в HTML5 появился метод navigator.getUserMedia. Работать с ним очень просто:

http://jsfiddle.net/RubaXa/uZhRp/
function setWebCam(video/**HTMLVideo*/, doneFn/**Function*/) {
    var navigator = window.navigator;
    var getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;

    getMedia.call(navigator, {
        video: true // запрашиваем доступ к веб-камере
    }, function (stream) {
        var URL = window.URL || window.webkitURL || window.mozURL;

        video.addEventListener('loadedmetadata', function () {
            doneFn();
        }, false);

        video.src = URL.createObjectURL(stream);
        video.play();
    }, function () { /* в доступе отказано */ });
}

setWebCam(videoEl, function () {
     // Есть сигнал
});

Вроде все работает, но если открыть этот пример в FireFox, то будет видно, что callback срабатывает раньше, чем появляется изображение. Поэтому пришлось сделать определение сигнала через canvas:

http://jsfiddle.net/RubaXa/uZhRp/
Листинг
function setWebCam(video/**HTMLVideo*/, doneFn/**Function*/) {
    function _detectVideoSignal() {
        var canvas = document.createElement('canvas'), ctx, res = false;
        try {
            ctx = canvas.getContext('2d');
            ctx.drawImage(video, 0, 0, 1, 1);
            res = ctx.getImageData(0, 0, 1, 1).data[4] != 255;
        } catch (e) {}
        return res;
    }

    var navigator = window.navigator;
    var getMedia = navigator.getUserMedia || navigator.webkitGetUserMedia || navigator.mozGetUserMedia || navigator.msGetUserMedia;

    getMedia.call(navigator, { video: true  }, function (stream) {
        var pid, URL = window.URL || window.webkitURL || window.mozURL;

        pid = setInterval(function () {
            if (_detectVideoSignal()) {
                clearTimeout(pid);
                doneFn();
            }
        }, 100);

        // ...
    }, function () { /* в доступе отказано */ });
}

setWebCam(videoEl, function () {
    // Есть сигнал
});

Увы, но на момент написания статьи метод navigator.getUserMedia поддерживают только FireFox и Сhrome, что, конечно, хорошо, но не достаточно. Поэтому мы сделали fallback во Flash, что позволило задействовать все остальные браузеры. В итоге получилось следующие API для работы с камерой:

var el = document.getElementById('container');

// Публикуем кумеру
FileAPI.Camera.publish(el, { width: 320, height: 240 }, function (err, cam/**FileAPI.Camera*/){
      var btn = document.getElementById('shot')

      // btn — кнопка, при помощи которой делаем скрин
      FileAPI.event.on(btn, 'click', function (evt){
           var shot = cam.shot(); // Экземпляр FileAPI.Image
           
           FileAPI.upload({
                url: './ctrl.php',
                files: { photo: shot }
           });
      });
});


FileAPI.Camera

  • publish(el, options, callback) — статический метод, для публикации камеры;
  • start(callback) — начать вещание;
  • shot() — создать скриншот, возвращает наследника FileAPI.Image;
  • stop() — остановить камеру.



Фильтры


Сделав две фичи, я подумал, что нужно что-то ещё, не хватает вау-эффекта. Но в голову ничего не приходило. Спустя некоторое время, я случайно наткнулся на замечательную библиотеку CamanJS, которая позволяет не только делать цветокоррекцию, но и использовать сложные режимы наложения изображений, а также мощные фильтры — очень советую. Это было то, что нужно: камера есть, работа с изображениями тоже, осталось добавить CamanJS — и свой «инстаграм» с FileAPI, WebCam и фильтрами готов.

Использовать все это очень просто, в составе CamanJS идут 10 предустановленных фильтров, посмотреть их в работе вы можете тут и тут.
FileAPI.Image(file)
    .filter('hazyDays') // CamanJS фильтр
    .get(function (err, img/**Image*/){
        /* ... */
    })
;

Помимо этого, можно передать функцию и в ней реализовать более сложные трансформации. На вход будут переданы ссылка на canvas и функция обратного вызова.

FileAPI.Image(file)
    .filter(function (canvas, callback){
          // модифицируем canvas
          callback();
    })
    .get(function (err, img/**Image*/){
        /* ... */
    })
;

Увы, всё это работает только при поддержке HTML5. Если честно, очень хотелось сделать поддержку вышеперечисленного функционала через Flash, и это даже возможно: всего-то нужно реализовать необходимые методы для работы с canvas во Flash. Как-нибудь в другой раз.


jQuery.FileAPI


Финальным нововведением, стал полноценный плагин для jQuery. В нем я постарался учесть самые распространенные особенности загрузки файлов, такие как:

  • «Одной кнопкой» — выбрать и автоматически загрузить файл;
  • «Ограничения» — минимальный/максимальный размер как файла, так и изображения, по ширине и высоте;
  • «Работа с очередью» — сортировка и фильтрация очереди загрузки файлов;
  • «Изображения» — предпросмотр, поворот и кадрирование;
  • «Интерфейс» — гибкая и прозрачная настройка интерфейса;
  • А также Drag’n’Drop и WebCam.


Оценить возможности вы можете на демо странице, github или заглянув под спойлер.

«Одной кнопкой»
image
Загрузка аватары
image
Множественная загрузка
image

Здесь разберу только один пример, загрузку аватара, а именно: выбор фотографии, кадрирование и последующую загрузку на сервер. Как это ни удивительно, но ни одно из популярных решений не дает такой возможности. По этой причине данный кейс был реализован в первую очередь:

Верстка
<div id="userpic" class="userpic">
   <div class="js-preview userpic__preview"></div>
   <div class="btn btn-success js-fileapi-wrapper">
      <div class="js-browse">
         <span class="btn-txt">Choose</span>
         <input type="file" name="filedata">
      </div>
      <div class="js-upload" style="display: none;">
         <div class="progress progress-success"><div class="js-progress bar"></div></div>
         <span class="btn-txt">Uploading</span>
      </div>
   </div>
</div>

$("#userpic").fileapi({
	url: "./ctrl.php",
	accept: "image/*",
	imageSize: { minWidth: 200, minHeight: 200 },
	elements: {
		// Если аплоудер активен (идет загрузка), то
		active: {
			show: ".js-upload", // показать блок
			hide: ".js-browse" // скрыть блок
		},
		// Куда выводить предпросмотр выбранного излбражения
		preview: {
			el: ".js-preview",
			width: 200, // его размеры
			height: 200
		},
		// Элемент отвечающий за отображение хода загрузки
		progress: ".js-progress"
	},
	onSelect: function (evt, ui){
		var image = ui.files[0];
		if( image ){
			createModal(function (overlay){
				$(overlay).on("click", ".js-upload", function (){
					closeModal();
					$("#userpic").fileapi("upload"); // Загружаем
				});

				$(".js-img", overlay).cropper({
					// Файл изображения
					file: image,
					// Цвет затемнения
					bgColor: "#fff",
					// Максимальные размеры, в которые нужно вписать изображение
					maxSize: [$(window).width() - 100, $(window).height() - 100],
					// Минимальные размера кроп-рамки
					minSize: [130, 130],
					// Размер кроп-рамки: Math.min(width*.9, height*.9)
					selection: 0.9, // или "90%"
					// Соотношение сторон кроп-рамки
					aspectRatio: 1,
					// Выбрана новая кроп-область
					onSelect: function (coords/**Object*/){
						$("#userpic").fileapi("crop", image, coords);
					}
				});
			});
		}
	}
});


Кроме того, плагин обладают очень гибкими настройками внешнего вида, что позволяет подстроиться под большинство задач. Ну, а если чего-то не хватает или вы нашли баг, то вы всегда можете оставить тикет на github, либо написать мне — буду рад помочь.

Помимо самой библиотеки и плагина, очень сильно переработана документация. Теперь это два полноценных сайта:



Также вы можете следить за нашими проектами через:
github.com/mailru — FileAPI, Tarantool, Fest и многое другое
github.com/rubaxa — мой github
@ibnRubaXa

+148
67.4k 708
Comments 85