[译] 我要写个上传组件…(实践教程)
原文地址: 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回收,无论使用的是哪个浏览器;
-
AdBlock及类似(不予评论)。
于是我们决定是时候改变了。下面是我们期望的新的解决方案的功能列表:
- 能够多选文件;
- 获取文件信息(name, type, mini-type);
- 上传前预览图片;
- 在客户端缩放,裁剪,旋转图片;
- 上传处理后的结果到服务端,包括CORS;
- 独立于外部库;
- 可扩展。
在过去的四年内,我们都听闻了关于HTML5的丰富特性和方案的激烈讨论, 包括File API。 很多刊物都谈及这个API,而我们也有一些关于这个API的运行示例。也许有人会想,“这是一个解决之前问题的工具。” 但它真如看起来那么简单么?
我们来看看Mail.ru的浏览器统计数据。我们只选择了一些支持File API的浏览器版本,尽管在某些情况下,这些浏览器不一定会完全支持这个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,它有两个必须的属性: 文件id
和callback
。callback
会在这个命令执行结束后从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以及多使用它哦!
有用的链接
- FileAPI(以及demo), Mail.ru, GitHub
- Mail.ru, GitHub
查找Tarantool, fest还有更多其它 - “HTML5 Form Features,” Can I Use…?
检查哪些浏览器支持input[type="file" multiple]
。 - “File API,” Can I Use…?
- “FileReader,” Mozilla Developer Network
- “URL.createObjectURL” 以及“URL.revokeObjectURL,” Mozilla Developer Network
- “XMLHttpRequest,” Mozilla Developer Network
- “FormData,” Mozilla Developer Network
本文由Andrew Sumin审校和编辑,他是一个前端工程师,在Mail.ru前端组工作。