[译] 我要写个上传组件…(实践教程)

原文地址: http://coding.smashingmagazine.com/2013/10/11/we-wanted-to-build-a-file-uploader/

标题: So We Wanted To Build A File Uploader… (A Case Study)

作者: Konstantin Lebedev

某一天我发现自己需要设计一个API实现从客户端上传文件到服务端的功能。我在一家名叫Mail.ru的公司工作,我主要负责开发俄罗斯语言的web邮箱服务,每天都要处理这些方面的javascript代码。Web邮箱服务有个最基本的功能就是给邮件添加附件

Mail.ru也不例外: 我们过去用的是Flash上传组件,这种方式其实还是相当不错的,但也存在一些问题。HTML标记,图表,业务逻辑,甚至一些本土化的特性都杂糅在里面, 导致上传组件相当臃肿。更糟糕的是,只有Flash开发者才能修改它。于是我们意识到需要构建一个全新的上传组件。本文会详细介绍我们是如何构建这个更棒的工具(上传组件)的。

所有写过Flash上传组件的人都知道它通常会带来以下问题:

  • 用于验证的Cookie很难维护,因为这些Cookie依赖于浏览器的实现以及不同的操作系统,所以在Flash中表现很不稳定(比如,HTTP请求与FileReference的上传/下载不能共享cookie)。官方Flash只在IE下支持cookie,并且不能在其它浏览器间共享,否则会被IE回收;
  • 虽然没被官方证实,有假设表明在Flash中,cookie都是从Internet Exporer中读取的;
  • 代理设置很难更新;用Flash时,cookie都会被IE回收,无论使用的是哪个浏览器;
  • 错误#2038 和#2048,在某些网络设置,浏览器,以及Flash播放器版本结合的情况下,会产生难以捉摸的错误。

  • AdBlock及类似(不予评论)。

于是我们决定是时候改变了。下面是我们期望的新的解决方案的功能列表:

  • 能够多选文件;
  • 获取文件信息(name, type, mini-type);
  • 上传前预览图片;
  • 在客户端缩放,裁剪,旋转图片;
  • 上传处理后的结果到服务端,包括CORS
  • 独立于外部库;
  • 可扩展。

在过去的四年内,我们都听闻了关于HTML5的丰富特性和方案的激烈讨论, 包括File API。 很多刊物都谈及这个API,而我们也有一些关于这个API的运行示例。也许有人会想,“这是一个解决之前问题的工具。” 但它真如看起来那么简单么?

我们来看看Mail.ru的浏览器统计数据。我们只选择了一些支持File API的浏览器版本,尽管在某些情况下,这些浏览器不一定会完全支持这个API。

支持File API的浏览器

上图显示超过87%的浏览器都支持File API:

  • Chrome 10+
  • Firefox 3.6+
  • Opera 11.10+
  • Safari 5.4+
  • IE 10+

当然,我们不能忽略日渐流行的移动设备上的浏览器。拿iOS 6+举例,已经支持了File API。然而, 87%并不是100%,在我们的产品中,现阶段完全放弃Flash是不切实际的。

于是,我们的任务就涉及到构建一个结合了两种技术(File API和Flash)的工具,并且这个利器能够让开发者忽略文件上传的实现方式。在开发过程中,我们决定结合所有的初期开发工作以实现一个独立的库(独立API),这个库能够独立于环境运行,并且在任何地方使用,不仅仅是在我们的服务中。

那我们就详细探讨一下这次开发过程中的一些细节,看看我们构建了什么,如何构建,以及我们在这个过程中学到的什么。

获取文件列表

从基础开始。下面是HTML5中获取文件的方式。十分简单。

<input id="file" type="file" multiple="multiple" />
<script>
      var input = document.getElementById("file");
      input.addEventListener("change", function (){
           var files = input.files;
      }, false);
</script>

 

但是当你只支持Flash,没有File API时该怎么办呢?对于支持Flash的用户,我们的基本思想就是所有的交互都通过Flash完成。你不能简单地调起一个文件选择框。由于安全策略的限制,只有点击了Flash Object之后才能打开文件选择框。

这也是为什么要把Flash Object定位到目标输入框之上的原因。然后你就能给document 添加一个mouseover事件,并且在用户hover 到Flash object上时把Flash object放到input元素的父元素中。

用户就能够点击Flash object,打开文件选择框,并且选择文件了。利用ExternalInterface,文件数据会通过Flash 传到Javascript。Javascript会把接收到的数据和input 元素绑定,用来模拟change事件。

[[Flash]] --> jsFunc([{
     id: "346515436346", // 文件id
     name: "hello-world.png", // 文件名
     type: "image/png", // mime-type
     size: 43325 // 文件大小
   }, {
     // etc.
 }])

 

Javascript和Flash更进一步的交互都是通过Flash中唯一可用的一个方法。这个方法第一个参数是一个命令名,第二个参数是一个object,它有两个必须的属性: 文件idcallbackcallback会在这个命令执行结束后从Flash中调用。

flash.cmd("imageTransform", {
    id: "346515436346",    // 文件id
    matrix: { },    // 变换矩阵
    callback: "__UNIQ_NAME__"
});

 

两种方式结合的效果会体现在API中,API看起来非常像原生的Javascript。唯一的区别就是文件的接收方式。现在我们只能使用API方法来得到文件, 因为只有当浏览器支持HTML5和File API时input才有files属性,而在Flash中方式中,文件列表来自于与之相关的数据信息。

<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>

 

过滤器

通常情况下,文件上传总是伴随着一系列的限制。最常见的限制就是文件大小,图片类型和尺寸(宽,高)。你会发现很多解决方案都是在服务端检查这些限制,如果文件不符合这些限制条件,用户就会收到一个错误信息。我尝试了另外一种解决办法,在客户端就验证文件的限制条件,即在文件上传之前。

太好了!不过,真的这么容易么?我们最开始拿到文件列表的时候,只能获得关于文件的极少信息:文件名,大小及类型。为了得到更多文件信息,我们需要读取这些文件。因此我们使用了FileReader

我们来研究一下FileReader,就能得到下面一些过滤技术:

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实现这一功能——也就是,把什么传给Javascript来构建图片。大家也许知道,通常使用data URI来完成传递工作。Flash以Base64形式读取文件然后传递给Javascript。因此我们将data:image/png;base64加到前面,并将这个字符串作为图片的src

万事大吉了么?可惜, IE 6 、7不支持data URI, 还有IE 8+,虽然支持data URI,却不能处理大于32KB的图片。这样一来,Javascript就需要创建第二个Flash Object,并且把Base64编码的内容传递给它。用这个Flash Object来保存图片。

HTML5

使用HTML5方式时,我们会先得到原始图片,然后用canvas进行指定的图片处理(转换)。有两种方式获得原始图片。一种就是用FileReader来读取文件的dataURI。另一种就是用URL.createObjectURL给文件生成一个链接,这个链接会绑定到当前标签页。当然,这种方式也很好,足以用来生成预览,但并不是所有浏览器都支持。比如Opera 12就不支持后续需要调用的URL.revokeObjectURL,这个方法能告知浏览器不必再为这个文件保留链接。

于是我们整合了这些方法,得到了一个FileAPI.Image类:

  • crop(x, y, width, height)
  • resize(width,[height])
  • rotate(deg)
  • preview(width, height) — 裁切和缩放
  • get(callback) — 得到最终的图片

所有这些方法会生成一个变换矩阵,当get()方法被调用的时候这个变换矩阵就会在图片上生效。变换会通过HTML5 的canvas或者Flash(采用Flash接口上传文件时)执行。

下面对我们矩阵的描述:

{   // 原来的参数片段
   sx: Number,
   sy: Number,
   sw: Number,
   sh: Number,

   // 目标大小
   dw: Number,
   dh: Number,
   deg: Number
}

 

下面是一个简短的例子:

FileAPI.Image(imageFle) // 返回 FileAPI.Image 实例
   .crop(300, 300)      // 裁切图像的宽高
   .resize(100, 100)    // 缩放到100x100px
   .get(function (err, img){
      if( !err ){
        // 将结果Append到DOM节点中(<div id="images">).
         images.appendChild(img);
      }
   });

 

缩放

数码相机出现很久了,至今还是非常流行。有些只需要花$20到$30就能拍出10 MP甚至更高分辨率的照片。我们曾尝试降低这些照片的大小,下面就是我们最后得到的效果:

一次性降低图片大小

你也看到了,质量实在不咋地。不过,如果我们先将图片缩放到一半,这样重复几次,直到达到我们想要的尺寸,这时候图片质量就好多了。这种方法其实很古老了,这就是最近邻插值的结果;而当直接压缩图像时,我们就会立马损失图像质量。

多次缩小图片到一半的大小

差别很明显:

图片质量差别明显

加上轻微的锐化效果,图像就能达到很理想的效果了。

我们也尝试过其它方法,比如双三次插值和 Lanczos 算法。结果会更好些,但是处理起来更费时: 1.5秒至200到300毫秒。在canvas和Flash中也是产生相同的结果。

上传文件

现在总结一下我们上传文件到服务器的各种选项。

IFRAME

没错,我们在多年之后还在用这种方式:

<form
   target="__UNIQ__"
   action="/upload"
   method="post"
   enctype="multipart/form-data"><!-- This bit is often forgotten -->
        <iframe name="__UNIQ__" height="240" width="320"></iframe>
        <input type="file" name="files" />
        <input type="hidden" name="foo" value="bar" />
</form>

 

首先,我们创建一个form元素,内部嵌套一个iframe。(这个form的target属性和iframe的name应保持一致。)然后,把input[type="file"]移到里面,因为如果你拷贝一个input再放进去,这个拷贝就是空的。

为了说明这个问题,想像一下你通过iframe加载了一个文件。我们用如下的代码来说明问题:

var inp = document.getElementById('photo');
var form = getIFrameFormTransport();
form.appendChild(inp.cloneNode(true)); // 发送一个“拷贝”
form.submit();

 

然而,这种input在IE下就是“空的”,即它不会包含选择的文件,这也就是我们需要“发送”原始文件(原来的input)并且用一个拷贝来替换它的原因。

这也是为什么我们通过API方法注册这样的事件,用以在拷贝的同时保存文件。然后,我们调用form.submit(),把form的内容通过iframe提交。我们就能通过JSONP得到结果。

var inp = document.getElementById('photo');
var cloneInp = inp.cloneNode(true);
var form = getIFrameFormTransport();

// 将“拷贝”的节点插入到“原始”的那个后面
inp.parentNode.insertBefore(cloneInp, inp);

form.appendChild(inp); // 发送“原始”的节点
form.submit();

 

是的,这方法确实很诡异。

FLASH

原则上,每件事情都很简单: Javascript调用Flash object的方法,把要上传的文件的ID传给Flash。反过来Flash会复制Javascript中所有的状态和事件。

XMLHTTPREQUEST 和 FORMDATA

我们现在不仅可以发送文本数据,还可以发送二进制数据。很简单:

// 收集要发送的数据
var form = new FormData
form.append("foo", "bar"); // 第一个参数是POST参数名
form.append("attach", file); // 第二个参数是字符串,文件或者Blob

// send to server
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的浏览器也支持这种方法。这个方法的重点就是手动创建多部分请求,将其发送到服务端。canvas的代码如下:

var dataURL = canvas.toDataURL("image/png"); // 或者FileReader的结果
var base64 = dataURL.replace(/^data:[^,]+,/, ""); // 去掉开头部分
var binaryString = window.atob(base64); // decode Base64

// 现在把这些部分拼起来,一点都不复杂
var uniq = '1234567890';
var data = [
      '--_'+ uniq
    , 'Content-Disposition: form-data; name="my-file"; filename="hello-world.png"'
    , 'Content-Type: image/png'
    , ''
    , binaryString
    , '--_'+ uniq +'--'
].join('\r\n');

var xhr = new XMLHttpRequest;
xhr.open('POST', '/upload', true);
xhr.setRequestHeader('Content-Type', 'multipart/form-data; boundary=_'+uniq);

if( xhr.sendAsBinary ){
  xhr.sendAsBinary(data);
} 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){}
});

 

这个API方法有很多参数,最重要的就是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 }
}

 

这意味着3份拷贝(除了原始图片)会被发送到服务端。为什么要这么做呢?假如你能够把从服务端加载转移到直接在客户端显示,这会是一个很好的主意。服务端只需对输入的文件进行最低限度的验证。首先,你不仅减少了一次加载,还避免了在服务端写更多的逻辑,可以完全由客户端来完成这些工作。

其次,假如这些文件原本就没必要上传到服务端,那我们就节省了带宽。还有,经常会有这样的问题,那就是无法在服务端进行进一步的处理,比如与第三方服务(例如Amazon S3)集成的时候。我们的经验是,将原本在服务端执行的附加的逻辑转移到客户端是完全没问题的。

上传函数也会返回一个类似于XMLHttpRequest的对象; 也就是说, 它借鉴了XMLHttpRequest的一些属性和方法, 比如:

  • status
    HTTP 状态码

  • statusText
    HTTP状态描述

  • responseText
    服务端返回

  • getResponseHeader(name)
    得到服务端返回的头部

  • getAllResponseHeaders()
    得到全部头部

  • abort()
    取消上传

尽管HTML5允许在一次请求中上传多个文件,但标准的Flash只允许一次上传一个文件。 而且,我们觉得,批量上传文件并不是一个好主意。一来Flash不支持这么做,且我们希望Flash和HTML5方式保持一样的行为。二来在用户那里也许会发生内存不足,导致浏览器崩溃的状况。

实际上,从上传函数返回的XMLHttpRequest是一个代理XMLHttpRequest。它的方法和属性反映了当前正在上传的文件的状态。

结束语

给大家看一个关于我们API中拖拽上传文件的简单的例子:

<div id="el" class="dropzone"></div>
<script>
    if( FileAPI.support.dnd ){
        // 文件被拖放到的元素
        var el = document.getElementById("el");

        // 订阅拖拽相关的事件
        FileAPI.event.dnd(el, function (over){
            // 当鼠标ernter/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>

 

我们花了不少时间去开发这个上传库。我们利用日常工作挤出来的时间,耗费了5个月来完成这个库的开发工作。主要难点就是不同浏览器的细节上的差异。Chrome, Firefox 以及IE10+都还好,但是Safari和Opera每个版本之间的表现都有很大的差异,包括在Win/Mac平台上的不一致。不过,主要问题还是结合所有三种技术——iframe, Flash, HTML5——来完成一个强大的上传组件。

现在在GitHub上能够看到我们的上传库, 我们还写了一个文档。希望大家能够多多反馈这个组件的Bugs以及多使用它哦!

有用的链接

本文由Andrew Sumin审校和编辑,他是一个前端工程师,在Mail.ru前端组工作。

 

posted @ 2013-12-13 14:32  lu.huang  阅读(...)  评论(...编辑  收藏