使用 HTML5 接口实现上传进度条

#javascript

XHR2(XMLHttpRequest level 2) 在 XHR 上做了扩展,使其更强大,严格讲 它不属于 HTML5 ,但却在 HTML5 App 中举足轻重,这里记录一下如何摒弃 Flash实现上传进度条,其中还会涉及 CORS。

XHR.upload

XHR 2 新增 upload 属性引用着一个 XMLHttpRequestUpload 对象,可以在 这个对象上添加 progress 事件,来监听上传进度。

var xhr = new XMLHttpRequest(); xhr.upload.onprogress = function (event) { if (event.lengthComputable) { console.info(event.loaded / event.total); } }

// xhr.open() // xhr.onload = … // xhr.send()

event.lengthComputable 判断是否可以获得文件大小。 event.loaded / .total 分别为已上传大小和总大小。

FormData

监听上传事件后,还需要把本地文件上传到服务器,XHR2 除了上传文本外, 还可以上传多种数据类型(ArrayBuffer, Blob, FormData). 用这三种类型 都可以上传本地文件,但 FormData 最为方便。

var formdata = new FormDate(formElement); formdata.append(‘username’, ‘dexter’); formdata.append(‘userfile’, input.file[0])

如上面的例子,FormData 接受一个 HTMLForm 对象做为可选参数,FormData 会自动添加 Form 内所有字段,除此之外,还可以通过 append() 方法添加 更多字段。字段值可以是文本也可以是 File 对象(通过 input.files 属性 获得)。综合一下上面两段代码。

var xhr = new XMLHttpRequest(); var formdata = new FormData();

xhr.upload.onprogress = function (event) { if (event.lengthComputable) { console.info(event.loaded / event.total); } }

xhr.open(‘POST’, ‘url’, true); xhr.onload = function() { console.info(xhr.response) }

formdata.append(‘username’, ‘dexter’); formdata.append(‘userfile’, document.querySelector(‘input[type=file]’).files[0])

xhr.send(formdata)

CORS (Cross-Orgin Resource Sharing)

如果是在同域名下上传,上面的代码就足够了,但很多时候文件都会 上传到单独的服务器上(比如upload.example.com)。这种时候就需要 使用 CORS 协议。有两种 CORS 请求:简单请求和需要预请求的请求 (preflighted request 或者直接称作非简单请求).

简单请求与普通的 XHR 请求没有两样,只要服务器返回头部包含 Access-Control-Allow-Orgin: * 或者使用具体的域名替换 * 就 会返回数据,否则浏览器会报跨域错误。

非简单请求会首先发送一个 OPTIONS 请求,等服务器返回并允许后, 再发送真正的请求。OPTIONS 预请求可以缓存,也就是说并不是每次 非简单请求都需要预发送一个 OPTIONS 请求。

哪些情况浏览器会发出简单请求,哪些又会是非简单请求?我搜索 了 HTML ROCKS, W3C specification 和 MDN 上面的教程和规范, 内容上虽然大致相同,但细节并不同,按 MDN 的说法,只要满足以下 两个条件,便发送非简单请求,其他情况发送简单请求:

  • It uses methods other than GET or POST. Also, if POST is used to send request data with a Content-Type other than application/x-www-form-urlencoded, multipart/form-data, or text/plain, e.g. if the POST request sends an XML payload to the server using application/xml or text/xml, then the request is preflighted.

  • It sets custom headers in the request (e.g. the request uses a header such as X-PINGOTHER)

换做 W3C 更详细的说法是:如果请求方法是 GET / HEAD / POST ,同时请求 header 内只包含 Accept / Accept-Language / Content-Language / Content-Type(Content-Type 的值只能为 appplication/x-www-from-urlencoded ,multipart/form-data 或 text/plain),并且未设置 force preflight flag 参数。或者已经发送过 preflight 请求并且结果被缓存。以上情况 发送简单请求,否则会先发送 preflight 请求。

但实际情况并不总是这样,比如在 Chrome 中即使满足发送简单请求的 条件,但是 POST 请求中发送了数据,比如 xhr.send(‘string’) 就会触发 preflight 请求。我想不是 Chrome 对 W3C 规范做了优化, 就是我对原规范理解上有错误。不管怎样,如果服务器提供跨域资源, 那么最好支持 preflight 请求,做到万无一失。

一个非简单请求的例子,下面的代码会产生一个非简单请求(因为它 有一个自定义 header 头,并且也发送了数据)。

var xhr = new XMLHttpRequest(); xhr.open(‘POST’, ‘http://upload.example.com:8000’, true); xhr.setRequestHeader(‘custom-header’, ‘dexbol’); xhr.onload = function() { console.info(this.response); } xhr.send(‘xx’);

抓包可以看到下面的 HTTP 请求 :

OPTIONS / HTTP/1.1 Host: upload.example.com:8000 Connection: keep-alive Access-Control-Request-Method: POST Origin: http://example.com:8000 User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.116 Safari/537.36 Access-Control-Request-Headers: origin, custom-header, content-type Accept: / Referer: http://example.com:8000/ Accept-Encoding: gzip,deflate,sdch Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4

其中 Origin 表示来源(任何 CORS 请求中都会有这个头,即使是简单请求)。 Access-Control-Request-Method: POST 表示浏览器需要发送一个 POST 请求。 Access-Control-Request-Headers: origin, coustom-header, content-type 表示实际请求中会带有如下自定义头部。然后服务器返回:

HTTP/1.0 200 OK Date: Thu, 04 Jul 2013 03:23:04 GMT Server: WSGIServer/0.1 Python/2.7.3 Access-Control-Allow-Origin: http://example.com:8000 Access-Control-Allow-Methods: GET, POST, PUT Access-Control-Allow-Headers: origin, custom-header, content-type Access-Control-Allow-Max-Age: 60 Content-Length: 0

其中 Access-Control-Allow-Origin 表示允许请求的域,可以使用通配符 * 表示允许任何域请求资源。Allow-Control-Allow-Methods 表示允许实际请求 的类型。Access-Control-Allow-Headers 允许实际请求发送的自定义头部。 Access-Control-Allow-Max-Age preflight 请求缓存时间,由于每次请求 资源前都预发送 OPTIONS 请求会消耗很多资源,因此浏览器可以把返回 缓存,这样下次就可以直接发送实际请求了,单位为秒,因此这里的过期 时间是一分钟。通过返回可以看到,实际请求使用的 POST 方法,和自定义头 custom-header, 以及来源都是被允许的,所以接下来浏览器会发送实际的 请求(否则浏览器会报错并终止接下来的操作)。

POST / HTTP/1.1 Host: upload.example.com:8000 Connection: keep-alive Content-Length: 2 Origin: http://example.com:8000 User-Agent: Mozilla/5.0 (Windows NT 6.2; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/27.0.1453.116 Safari/537.36 custom-header: dexbol Content-Type: application/xml Accept: / Referer: http://example.com:8000/ Accept-Encoding: gzip,deflate,sdch Accept-Language: zh-CN,zh;q=0.8,en-US;q=0.6,en;q=0.4

 xx

返回:

HTTP/1.0 200 OK Date: Thu, 04 Jul 2013 03:56:39 GMT Server: WSGIServer/0.1 Python/2.7.3 Content-Type: text/plain Access-Control-Allow-Origin: *

Congratulation! you got it.

这次我把 Access-Control-Allow-Origin 设为通配符,可以看到与 直接设置域名的效果是相同的。到此我们讲都是服务应该实现的逻辑 客户端的 CROS 逻辑主要由浏览器完成,上传进度条的 JS 不用做任 何改动就可以跨域上传了。

大多数服务器是不允许游客上传文件的,一般需要进行身份验证,最 常用的验证方式就是验证cookie是否合法 ,如果直接使用上面的JS 上传文件,即使用户合法登录也不会成功,因为 CROS 默认是不会发送 cookie 信息的(Authorization header 也不会被发送)。解决办法是 设置 xhr 属性 withCredentials 为 true

 xhr.withCredentilas = true;

只需一条语句前端的任务就完成了,唯一需要注意的是IE10 中此语句 必须在调用 .open() 方法后执行 否则会报错。

服务器如果允许接收 cookie 只需添加 Access-Control-Allow-Credentials: true 头部信息。但有两点需要注意:

  • 如果服务器端需要接收 cookie , 那么 Access-Control-Allow-Origin 不能为通配符,只能指定具体的域名。如果服务器想接收任何域下的 cookie 可以从 request header 获得 origin ,并设为它的值

  • 不管在 preflighted request 中还是在实际请求中,返回头部都需要包含 Access-Control-Allow-Credentials: true

完整实例

把上面的示例代码整合一下,提供一个完整实例,逻辑很简单:上传一个 文件,服务器会验证 cookie 后返回文件名,上传过程中会更新进度条。 一共两个文件:

下载上面两个文件,放到同一目录中,然后添加 hosts :

 127.0.0.1 example.com
 127.0.0.1 upload.example.com

打开 cmd.exe 或者 shell , 在当前目录下输入:

 >>python server.py

打开一个现代浏览器,打开控制台,输入地址 exmaple.com:8000 , Good Luck。

Reference