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

前言

在移动端,我们经常会有这样的情况发生:
用户在 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 类型

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

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

input

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

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。

// 如果 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 格式的字符串。

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 画布上

var context

canvas = 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 的其他类型

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

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

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

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

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 Blob
function 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 的语法结构为:

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

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

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

// atob
binaryString = atob(pureBase64ImageData)  

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

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

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

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

...
Content-Type: multipart/form-data; boundary=customBoundary  
...

--customBoundary
Content-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 来结束。

所以,我们要这么做:

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 是一个平板用的系统,用的人很少,所以不用管了。

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:

var formData = new FormData()  
var xhr = new XMLHttpRequest()  
// 用第四步得到的 compressedImageBlob
var blobFile = compressedImageBlob

formData.append('file', blobFile, file.name)  
xhr.open('POST', url, true)  
// 如果跨域请求需要 Cookies 的话,带上 credentials
xhr.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:

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 */ })  
// 第七步得到的 arrayBuffer
xhr.send(arrayBuffer)  

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

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 同学看一下比较完整的用法):

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
需要进行上传测试的:

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

参考

正在加载 Disqus 评论组件...