在浏览器端对图片进行压缩 & 上传

来源:互联网 发布:小米笔记本 pro linux 编辑:程序博客网 时间:2024/06/05 14:54

在浏览器端对图片进行压缩 & 上传

07 SEPTEMBER 2016 on multipart, ImageCompression, Blob, XHR2, Android 4.0, 前端

前言

在移动端,我们经常会有这样的情况发生: 
用户在 3G/2G 网络情况下,上传手机拍下的照片在经过上传再下载耗时非常长,流量消耗也不少。 
因此我们提出了一个要求:前端先压缩图片,在浏览器中预览,再上传到服务器,并且要兼容 Android 4.0。 
这篇博文主要介绍:

  • 对图像文件压缩的处理方法;
  • 对 File/Blob/data URIs 的互相转化;
  • 如何构造 multipart/form-data 格式的请求;
  • 以及用 xhr2 发送压缩文件到服务器的解决办法。

图像文件处理步骤

  1. 获取 input[type="file"] 控件上的图像文件对象;
  2. 使用 window.URL/FileReader 获取图像路径(BlobURL/DataURL)并通过 Image 对象载入;
  3. 通过载入图像的 Image 对象绘制到 canvas 画布上;
  4. 通过 canvas.toDataURL 方法将画布图像压缩并输出 base-64 编码的 dataURL 字符串;
  5. 通过 window.atob 将 base-64 字符串解码为 binaryString(二进制文本)
  6. 将 binaryString 构造为 multipart/form-data 格式
  7. 用 Uint8Array 将 multipart 格式的二进制文本转换为 ArrayBuffer

详细操作

测试页面: https://blade254353074.github.io/image-compress/ 
Repo 地址:https://github.com/blade254353074/image-compress

以下步骤均可以在测试页面中测试。

一、获取 file 类型

HTML
<input    id="J_File"  type="file"  accept="image/*"  capture="camera">

在 Android 浏览器中,input 加上 capture="camera" 属性后,点击 input 弹出的选项会只有「相机 + 图库」,体验会更好:

input

文件的 mime type 要缓存下来,后面在压缩时会用到:

JavaScript
var fileName  file = J_File.files[0]  fileName = file.name  // 某些浏览器得到的 file.type 为空fileType = file.type ||    'image/' + fileName.substr(fileName.lastIndexOf('.') + 1)

二、通过 URL/FileReader 获取文件的路径

这两个方法都是为了获取文件路径,让 image 可以加载图片文件,只不过 URL 获得是 Blob URL, FileReader 获得的是 Data URL (Base64)。这里有个坑,Android 4.0 不能加载 Data URL,后面详述。

注意:浏览器中 Image 支持的图像类型有限:JPEG、GIF、PNG、APNG、SVG、BMP、ICO(Chrome 还支持 WEBP)。

1. Blob URLs

Blob URLs 支持度如下: 
caniuse

Blob URLs 还是个实验中功能,为了支持老版本手机浏览器,需要加 webkit 前缀:window.URL = window.URL || window.webkitURL

使用 URL.createObjectURL(File/Blob) 可以将 file/blob 对象挂载到 Document 上并返回一个 BlobURL。

JavaScript
// 如果 document 已挂载过 Blob 对象,则先释放,避免浪费内存window.URL = window.URL || window.webkitURL  if (url) {    window.URL.revokeObjectURL(url)}url = window.URL.createObjectURL(file)  // blob:http://foo.bar/f6913fff-12c9-4c3c-8a40-3712a68e9de4
2. FileReader

FileReader 兼容性和 Blob URLs 相同,只不过不需要加 webkit 前缀。

用 FileReader 可以以异步的方式读取文件,要获取 DataURL 需要使用 FileReader.readAsDataURL(),它可以将文件读取为包含一个 data: URL 格式的字符串。

JavaScript
fileReader = new FileReader()  image = new Image()fileReader.onload = function (e) {    var dataURL = e.target.result  // fileReader.result (data:image/png;base64,iVBORw0KG...)  image.src = dataURL}image.addEventListener('load', function () {    // Image loaded!})image.addEventListener('error', function () {    alert('Image load error')})fileReader.readAsDataURL(file)  

注意:Android 4.0 Image 对象加载 dataURL 会有兼容性问题 
注意:Android 4.0 用 FileReader 读取的 dataURL 不完整。 缺少 data:image/png;base64 中的 image/png(如下图)。

使用 Android avd 模拟器测试后,发现:

  • 在 Android 4.0.3 下,将 dataURL 赋值到 image.src 后,图片会加载错误:

    4.0.3 image load error

  • 而在 Android 4.3.1 下,则不会加载失败。

Android 4.0.3 下,FileReader 读取的 dataURL 不完整,导致图片会加载失败,因此无法将图片绘制到 Canvas 上,更不用说压缩了。 
所以,我们需要使用 URL 对象 createObjectURL 方法来把图片文件挂载到 Document 上,对于重复挂载的操作别忘了用 revokeObjectURL 方法来释放挂载的文件。

三、绘制 Image 到 Canvas 画布上

JavaScript
var contextcanvas = new Canvas()  context = canvas.getContext('2d')  // 将 canvas 尺寸设置为图片原始尺寸canvas.width = image.naturalWidth  canvas.height = image.naturalHeight  // 将图片绘制到 canvas 画布上context.drawImage(image, 0, 0)  

四、通过 toDataURL 压缩图像

先来看一下 MDN 对这个方法的介绍:HTMLCanvasElement.toDataURL()

注意一点: 
如果图像本身是 image/png,则 type 参数不能为非 image/png 的其他类型

JavaScript
var quality = 30  compressedImageDataURL = canvas.toDataURL(filetype, quality/100)  

就这么简单的一行代码,就可以把画布上的内容进行压缩输出了。

如果要获取压缩过的图片大小,需要将 DataURL 转为 Blob 对象:

Android 3.0 - 4.2 之前的浏览器,包括微信浏览器等,都不支持 Blob 的构造方法,需要使用 BlobBuilder。

JavaScript
function newBlob (data, datatype) {    var out  try {    out = new Blob([data], { type: datatype })  } catch (e) {    window.BlobBuilder = window.BlobBuilder ||    window.WebKitBlobBuilder ||    window.MozBlobBuilder ||    window.MSBlobBuilder    if (e.name == 'TypeError' && window.BlobBuilder) {      var bb = new BlobBuilder()      bb.append(data)      out = bb.getBlob(datatype)    } else if (e.name == 'InvalidStateError') {      out = new Blob([data], { type: datatype })    } else {      throw new Error('Your browser does not support Blob & BlobBuilder!')    }  }  return out}// data URIs to Blobfunction dataURL2Blob (dataURI) {    var byteStr  var intArray  var ab  var i  var mimetype  var parts  parts = dataURI.split(',')  parts[1] = parts[1].replace(/\s/g, '')  if (~parts[0].indexOf('base64')) {    byteStr = atob(parts[1])  } else {    byteStr = decodeURIComponent(parts[1])  }  ab = new ArrayBuffer(byteStr.length)  intArray = new Uint8Array(ab)  for (i = 0; i < byteStr.length; i++) {    intArray[i] = byteStr.charCodeAt(i)  }  mimetype = parts[0].split(':')[1].split(';')[0]  return new newBlob(ab, mimetype)}var compressedImageBlob = dataURL2Blob(compressedImageDataURL)console.log(compressedImageBlob.size) // 压缩图像文件的大小  console.log(file.size) // 源文件的大小  
不过存在几个问题:

(1). 是否可以压缩所有的浏览器支持的图片格式? 
(2). 如果图片是已经压缩过的,会不会造成重复压缩问题? 
(3). 会不会压缩后,反而文件变大? 
(4). 压缩效率是不是很低?

(1). 是否可以压缩所有的浏览器支持的图片格式?

结论:只支持 JPEG,其他格式均不能实现真正意义上的「压缩」。

验证过程:

在分别用 lena_std 的 jpe、gif、png、bmp、ico 进行不同 Quality 的压缩测试后,发现:

lena_std_table.jpg

除了压缩 JPEG 会随着 quality 降低,输出的文件大小 & 质量降低,其他格式会输出一个固定大小的文件,并且这些其他格式的 dataURL 中 mediaType 均是 image/png。

因此,我们在客户端要压缩时应对 JPG 文件进行低质量压缩(toDataURL(filetype, quality/100)),其他格式只使用(toDataURL()),随后将 dataURL 转为 Blob,对比 Blob 和源文件的大小,优先上传较小的文件。

lena用到的莱娜图出自 www.lenna.org

原图为 TIF 格式,其他格式的「莱娜图」在 blade254353074/image-compress,以供测试。

(2). 如果图片是已经压缩过的,会不会造成重复压缩问题?

结论:当压缩算法不同时会重复压缩,但没有较大的质量损耗。

  • 对一张原图 toDataURL 压缩后,再对压缩图进行 toDataURL 压缩,如此递归,压缩文件的大小不会再改变,即没有变化
  • 对原图用 ps 低质量另存后,对其进行 toDataURL 压缩后。文件大小和对原图压缩有区别,且感官「画质」也有且些许区别(噪点增多),但没有过大的质量损耗

说明:压缩率和压缩算法有关系,采用不同的压缩算法,结果会不同。W3C 制定的压缩算法和 PhotoShop 的压缩算法不同。

(3). 会不会压缩后,反而文件变大?

结论:可能会,如果一个 JPEG 图片已经用同一个算法压缩到 10% 质量的话,再压缩为 30% 质量,文件会变大,但图像「感官质量」并不会提高。

(4). 压缩效率是不是很低?

结论:压缩的质量越低,压缩速度就越快。 
测试结果:手机(iPhone 5s)在压缩 2MB 的 JPEG 时toDataURL(type, quality) 的执行时间:

  • 92% 质量时为 220ms 左右;
  • 30% 质量为 130ms 左右。

dataURL2Blob(compressedImageDataURL) 的时间没有算进去)


现在,我们有了压缩过的 DataURL(Base64 String),并且能把它转为 Blob 对象,接口是接受 multipart 格式数据的,所以我们要把 Blob 添加到 FormData,再用 XHR2 来上传数据。 
但是,在 Android 4.3 以下这样发出去的 Request Body 是空的
,原因是:这是个 BUG(https://code.google.com/p/android/issues/detail?id=39882)。

这个 Bug 从 3.0 一直持续到 4.3,4.4 因为包含了应对这种情况的 Chrome Blink 引擎,所以就不会出现这种情况了。 
文中提到的对 3.0 - 4.3 的权宜之计是把 Blob 转成 ArrayBuffer

那么问题来了,如何把 Blob 转换为 ArrayBuffer?

一个简单的办法是:用 FileReader 的 readAsArrayBuffer(dataURLtoBlob(compressedImageDataURL)) 来获取 ArrayBuffer,可我们的文件上传接口是遵循 multipart/form-data 规范的,Request Body 里只有二进制数据流的话,接口也得做改动。

因此我们需要「曲线救国」:手动构造一个 multipart 格式的 Request Body。

因为我们要传的是文件,所以需要将 compressedImageDataURL 用 window.atob 解码为二进制字符串(即文件二进制内容),再构造为 multipart 格式。 
有了 multipart 格式的数据后,将它转为 ArrayBuffer 发出去即可。


五、用 window.atob 解码 Base64 字符串为 binaryString

兼容性:atob 除了 IE9 以外,其他所有浏览器均支持。

根据 [W3C] base64-utility-methods,atob 字面意思是 ASCII to Binary,实际作用是对 Base64 编码的字符串进行解码,将每个 Base64 字符转换为范围在 U+0000 - U+00FF 的字符,这些 Unicode 字符每个都代表一个 0x00 - 0xFF 的二进制字节。 
因此,atob 的参数必须符合 Latin1(兼容 ASCII 的编码) 字符范围。

data URIs 的语法结构为:

JavaScript
data:[<mediatype>][;base64],<data>  

所以我们只需要对 <data> 做解码处理就好:

JavaScript
// 解码前将 `data:image/png;base64,` 去除var pureBase64ImageData =    compressedImageDataURL.replace(    /^data:(image\/.+);base64,/,    function ($0, $1) {      contentType = $1      return ''    }  )// atobbinaryString = atob(pureBase64ImageData)  

其实就是把 Base64 字符串转为 BinaryString(二进制字符串):atob

这样,二进制字符串有了,终于可以拼接 multipart 了。

六、将 binaryString 构造为 multipart/form-data 格式

根据规范 [RFC1867] Form-based File Upload in HTML,我们需要发送这样格式的数据:

...Content-Type: multipart/form-data; boundary=customBoundary  ...--customBoundaryContent-Disposition: form-data; name="file"; filename="filename.jpg"  Content-Type: image/jpeg... contents of filename.jpg ...--customBoundary--

这个 multipart 格式需要注意几点:

  • FileContent 是我们之前用 atob 解码的 binaryString;
  • boundary 是每个 field 之间的分隔字串,可以自定义,但不要和 field 内容冲突
  • 在 Payload 中行之间需要用 CRLF 分隔,CR(Carriage Return,回车),LF(Line Feed,换行),即 \r\n
  • Payload 末尾也需要用 CRLF 来结束。

所以,我们要这么做:

JavaScript
multipartString = [    '--' + boundary,  'Content-Disposition: form-data; name="file"; filename="' + (file.name || 'blob') + '"',  'Content-Type: ' + contentType,  '', binaryString,  '--' + boundary + '--', ''].join('\r\n')

关于 multipart 更详细的介绍 —— [W3C] Form content types

七、把 multipart 格式的字符串转换为 ArrayBuffer

XHR2 可以发送的二进制数据有 ArrayBuffer, Blob, File,同时接口又需要数据保持 multipart 格式。我们没有压缩过的 File 对象,添加了 Blob 的 FormData 也不能用,所以只好用 ArrayBuffer 了。

ArrayBuffer(缓冲数组)是一种用于呈现通用、固定长度的二进制数据的类型。

这一个步骤要用到 Uint8Array,但 Typed Arrays(二进制数组)支持率稍低:Typed Arrays

不过 Android 3.0 是一个平板用的系统,用的人很少,所以不用管了。

JavaScript
function string2ArrayBuffer (string) {    var bytes = Array.prototype.map.call(string, function (c) {    return c.charCodeAt(0) & 0xff  })  return new Uint8Array(bytes).buffer}arrayBuffer = string2ArrayBuffer(multipartString)  

用 ajax/fetch 上传压缩过的图片

现在我们有上面第四步得到的 compressedImageBlob 和第七步得到的 ArrayBuffer。在不考虑 Android 4.3 以下系统时,可以直接用 xhr2 发送添加了 Blob 的 FormData:

JavaScript
var formData = new FormData()  var xhr = new XMLHttpRequest()  // 用第四步得到的 compressedImageBlobvar blobFile = compressedImageBlobformData.append('file', blobFile, file.name)  xhr.open('POST', url, true)  // 如果跨域请求需要 Cookies 的话,带上 credentialsxhr.withCredentials = true  xhr.addEventListener('load', function() { /* xhr.responseText */ })  xhr.send(formData)  

对于要支持 Android 4.3- 的需求来说,要发送 multipart 格式的 ArrayBuffer 需要对 Request Headers 做一点小的改动,即添加一个 Content-Type: multipart/form-data; boundary=customBoundary header:

JavaScript
var xhr = new XMLHttpRequest()xhr.open('POST', url, true)  xhr.withCredentials = true  // boundary 为第六步构造 multipart 格式时用到的 customBoundary// multipart 格式规定,两处 boundary 必须保持一致xhr.setRequestHeader('Content-Type', 'multipart/form-data; boundary=' + boundary)  xhr.addEventListener('load', function() { /* xhr.responseText */ })  // 第七步得到的 arrayBufferxhr.send(arrayBuffer)  

不过,我们在做移动端页面时,基本都会去用 Zepto.ajax,这里我踩了个坑(用zepto1.1.6发ajax在特定安卓机出现INVALID_STATE_ERR: DOM Exception 11异常 #6),在下面的评论找到了解决办法,所以结合 ArrayBuffer 在这里也发下:

JavaScript
Zepto.ajax({    url: url,  type: 'POST',  processData: false,  contentType: 'multipart/form-data; boundary=' + boundary,  beforeSend: function (xhr, settings) {    try {      xhr.withCredentials = true    } catch (e) {      var nativeOpen = xhr.open      xhr.open = function () {        var result = nativeOpen.apply(xhr, arguments)        xhr.withCredentials = true        return result      }    }  },  success: function (res, status, xhr) {},  error: function (xhr, errorType, error) {}})

上传效果如下:XHR2 upload ArrayBuffer

Fetch 版(虽然支持了 Fetch 也就支持了直接发 FormData with Blob,不过给没用过 Fetch 同学看一下比较完整的用法):

JavaScript
fetch(url, {    method: 'POST',  headers: {    'Accept': 'application/json',    'Content-Type': 'multipart/form-data; boundary=customFileboundary'  },  credentials: 'include',  body: arrayBuffer})  .then(response => {    const { status } = response    if (      response.ok &&      (        status >= 200 &&        status < 300      ) ||      status === 304    ) {      return response    } else {      const error = new Error(response.statusText)      error.response = response      throw error    }  })  .then(response => {    if (      response.status === 204 ||      response.headers.get('Content-Type').indexOf('application/json') === -1    ) {      return response    }    return response.json()  })  .then(res => {    // success    console.log(res)  })  .catch(error => {    // error    const { response } = error    if (response && response.headers.get('Content-Type').indexOf('application/json') > -1) {      response        .json()        .then(err => {          console.log(err)        })    } else {      console.error(error)    }  })  .then(() => {    // complete  })

最后

需要查看每步运行情况的可以访问 DEMO:Browser side image compression demo, 
需要进行上传测试的:

Bash
$ git clone https://github.com/blade254353074/image-compress.git$ npm install$ npm run server# Open http://localhost:8080/

参考

  • blade254353074/image-compress
  • [MDN] URL
  • [MDN] <img> - Supported image formats
  • [MDN] FileReader
  • [MDN] HTMLCanvasElement.toDataURL()
  • The Lenna Story - www.lenna.org
  • 如何在移动web上上传文件.. - AlloyTeam Blog
  • Issue 39882: Browser: Trying to send a Blob with XHR2 sends the request with an empty body
  • Send a JPEG Blob with AJAX on Android - Ionuț Colceriu
  • CR, LF, CR/LF区别与关系 - JeremyWei
  • [W3C] base64-utility-methods
  • Latin-1
  • [MDN] data URIs
  • [RFC1867] Form-based File Upload in HTML
  • [W3C] Form content types
  • [MDN ArrayBuffer]
  • [MDN Uint8Array]
原创粉丝点击